use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use crate::core::attribution::ATTRIBUTION_FOOTER;
pub const SESSIONS_PREFIX: &str = ".trusty-mpm/sessions/";
pub const FALLBACK_BRANCH: &str = "main";
const DETACHED_HEAD: &str = "HEAD";
const BLOB_MODE: &str = "100644";
#[derive(Debug, Clone)]
pub struct CmdOut {
pub success: bool,
pub stdout: String,
pub stderr: String,
}
impl CmdOut {
fn out(&self) -> &str {
self.stdout.trim()
}
fn failure(&self) -> String {
let e = self.stderr.trim();
if e.is_empty() {
self.stdout.trim().to_string()
} else {
e.to_string()
}
}
}
pub trait PauseVcs {
fn git(
&self,
repo: &Path,
args: &[String],
index_file: Option<&Path>,
) -> anyhow::Result<CmdOut>;
fn tm(&self, repo: &Path, args: &[String]) -> anyhow::Result<CmdOut>;
}
#[derive(Debug)]
pub struct PublishRequest<'a> {
pub repo: &'a Path,
pub session_id: &'a str,
pub session_name: Option<&'a str>,
pub timestamp: DateTime<Utc>,
pub paths: Vec<String>,
pub default_branch: Option<&'a str>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PublishOutcome {
pub branch: String,
pub commit: String,
pub pr_url: String,
pub auto_merge_armed: bool,
pub auto_merge_error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PublishError {
#[error("`{0}` is git-ignored in this checkout; session snapshots are not tracked here")]
NotTracked(String),
#[error("`{0}` is not a git repository; there is nothing to publish a snapshot into")]
NotAGitRepo(String),
#[error(
"pause snapshots cannot publish from a detached checkout: HEAD names no branch, \
and this project's default is `{expected}` (git reported `{actual}`). \
The snapshot file was written; commit and open its PR by hand."
)]
NotOnDefaultBranch {
expected: String,
actual: String,
},
#[error(
"session-snapshot publish failed at `{step}`: {detail}{}",
commit_note(.branch.as_deref(), .commit.as_deref())
)]
Step {
step: &'static str,
detail: String,
branch: Option<String>,
commit: Option<String>,
},
}
fn commit_note(branch: Option<&str>, commit: Option<&str>) -> String {
match (branch, commit) {
(Some(b), Some(c)) => {
format!(" — the commit {c} is on local branch `{b}`; push it and open its PR by hand.")
}
_ => " — no commit was created.".to_string(),
}
}
pub fn publish_pause_snapshot<V: PauseVcs>(
vcs: &V,
req: &PublishRequest<'_>,
) -> Result<Option<PublishOutcome>, PublishError> {
let paths = allowlisted(req.repo, &req.paths)?;
let branch_out = vcs
.git(
req.repo,
&owned(&["rev-parse", "--abbrev-ref", "HEAD"]),
None,
)
.map_err(|e| step_err("branch", e.to_string(), None, None))?;
if !branch_out.success {
return Err(PublishError::NotAGitRepo(req.repo.display().to_string()));
}
let current = branch_out.out().to_string();
let default = resolve_default_branch(vcs, req);
if current == DETACHED_HEAD {
return Err(PublishError::NotOnDefaultBranch {
expected: default,
actual: current,
});
}
let ignored = vcs
.git(
req.repo,
&owned(&["check-ignore", "-q", "--", &paths[0]]),
None,
)
.map_err(|e| step_err("check-ignore", e.to_string(), None, None))?;
if ignored.success {
return Err(PublishError::NotTracked(paths[0].clone()));
}
git(vcs, req, &["fetch", "origin", &default], None, "fetch")?;
let origin_ref = format!("origin/{default}");
let base = git(vcs, req, &["rev-parse", &origin_ref], None, "rev-parse")?
.out()
.to_string();
let base_tree = git(
vcs,
req,
&["rev-parse", &format!("{base}^{{tree}}")],
None,
"rev-parse",
)?
.out()
.to_string();
let scratch =
tempfile::TempDir::new().map_err(|e| step_err("scratch", e.to_string(), None, None))?;
let index = scratch.path().join("index");
let idx = Some(index.as_path());
let mut blobs: Vec<(String, String)> = Vec::with_capacity(paths.len());
for rel in &paths {
let abs = req.repo.join(rel);
let sha = git(
vcs,
req,
&[
"hash-object",
"-w",
"--path",
rel,
"--",
&abs.to_string_lossy(),
],
None,
"hash-object",
)?
.out()
.to_string();
blobs.push((rel.clone(), sha));
}
git(vcs, req, &["read-tree", &base], idx, "read-tree")?;
for (rel, sha) in &blobs {
git(
vcs,
req,
&[
"update-index",
"--add",
"--cacheinfo",
&format!("{BLOB_MODE},{sha},{rel}"),
],
idx,
"update-index",
)?;
}
let tree = git(vcs, req, &["write-tree"], idx, "write-tree")?
.out()
.to_string();
if tree == base_tree {
return Ok(None);
}
let branch = branch_name(req.session_id, req.timestamp);
let title = pr_title(req.session_id, req.timestamp);
let message = format!("{title}\n\n{ATTRIBUTION_FOOTER}\n");
let commit = git(
vcs,
req,
&["commit-tree", &tree, "-p", &base, "-m", &message],
None,
"commit-tree",
)?
.out()
.to_string();
let branch_ref = format!("refs/heads/{branch}");
git_at(
vcs,
req,
&["update-ref", &branch_ref, &commit],
"update-ref",
&branch,
&commit,
)?;
git_at(
vcs,
req,
&["push", "origin", &format!("{branch_ref}:{branch_ref}")],
"push",
&branch,
&commit,
)?;
let body_path = scratch.path().join("pr-body.md");
std::fs::write(&body_path, pr_body(req.session_id, &paths)).map_err(|e| {
step_err(
"pr-body",
e.to_string(),
Some(branch.clone()),
Some(commit.clone()),
)
})?;
let open = vcs
.tm(
req.repo,
&owned(&[
"pr",
"open",
"--title",
&title,
"--body-file",
&body_path.to_string_lossy(),
"--base",
&default,
"--head",
&branch,
"--docs-only",
"--rung",
"1",
"--session",
workstream_session(req),
]),
)
.map_err(|e| {
step_err(
"pr-open",
e.to_string(),
Some(branch.clone()),
Some(commit.clone()),
)
})?;
if !open.success {
return Err(step_err(
"pr-open",
open.failure(),
Some(branch),
Some(commit),
));
}
let (pr_number, pr_url) = parse_pr(&open.stdout).ok_or_else(|| {
step_err(
"pr-open",
format!(
"could not read a PR number from `tm pr open` output: {}",
open.out()
),
Some(branch.clone()),
Some(commit.clone()),
)
})?;
let auto_merge_error = match vcs.tm(req.repo, &owned(&["pr", "merge", &pr_number, "--auto"])) {
Ok(o) if o.success => None,
Ok(o) => Some(o.failure()),
Err(e) => Some(e.to_string()),
};
if let Some(detail) = &auto_merge_error {
tracing::warn!(
"session-snapshot PR {pr_url} was opened but auto-merge did not arm: {detail}"
);
}
Ok(Some(PublishOutcome {
branch,
commit,
pr_url,
auto_merge_armed: auto_merge_error.is_none(),
auto_merge_error,
}))
}
fn allowlisted(repo: &Path, paths: &[String]) -> Result<Vec<String>, PublishError> {
if paths.is_empty() {
return Err(step_err(
"allowlist",
"no snapshot paths given".into(),
None,
None,
));
}
for p in paths {
if !p.starts_with(SESSIONS_PREFIX) || p.split('/').any(|seg| seg == "..") {
return Err(step_err(
"allowlist",
format!("`{p}` is outside `{SESSIONS_PREFIX}` and may not be committed by a pause"),
None,
None,
));
}
walk_is_unredirected(repo, p).map_err(|d| step_err("allowlist", d, None, None))?;
}
Ok(paths.to_vec())
}
fn walk_is_unredirected(repo: &Path, p: &str) -> Result<(), String> {
let segments: Vec<&str> = p.split('/').filter(|s| !s.is_empty()).collect();
let leaf = segments.len().saturating_sub(1);
let mut at = repo.to_path_buf();
for (i, segment) in segments.iter().enumerate() {
at.push(segment);
let Ok(meta) = std::fs::symlink_metadata(&at) else {
return Ok(());
};
if i == leaf {
if !meta.is_file() {
return Err(format!(
"`{p}` is not a regular file and may not be committed by a pause"
));
}
} else if meta.is_symlink() {
return Err(format!(
"`{p}` passes through the symlink `{segment}` and may not be committed by a pause"
));
}
}
Ok(())
}
fn resolve_default_branch<V: PauseVcs>(vcs: &V, req: &PublishRequest<'_>) -> String {
if let Some(b) = req.default_branch.map(str::trim).filter(|b| !b.is_empty()) {
return b.to_string();
}
if let Ok(out) = vcs.git(
req.repo,
&owned(&["rev-parse", "--abbrev-ref", "origin/HEAD"]),
None,
) && out.success
&& let Some(b) = out.out().strip_prefix("origin/")
&& !b.is_empty()
{
return b.to_string();
}
FALLBACK_BRANCH.to_string()
}
fn branch_name(session_id: &str, ts: DateTime<Utc>) -> String {
format!(
"chore/sessions-{}-{}",
slug(session_id),
ts.format("%Y%m%d-%H%M%S")
)
}
fn workstream_session<'a>(req: &'a PublishRequest<'a>) -> &'a str {
req.session_name
.map(str::trim)
.filter(|name| !name.is_empty())
.unwrap_or(req.session_id)
}
fn pr_title(session_id: &str, ts: DateTime<Utc>) -> String {
format!(
"chore(sessions): pause snapshot for {session_id} {}",
ts.format("%Y-%m-%d %H:%MZ")
)
}
fn slug(session_id: &str) -> String {
let mut out = String::new();
for ch in session_id.chars() {
if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
} else if !out.ends_with('-') {
out.push('-');
}
}
let trimmed = out.trim_matches('-');
let bounded: String = trimmed.chars().take(40).collect();
let bounded = bounded.trim_end_matches('-').to_string();
if bounded.is_empty() {
"session".to_string()
} else {
bounded
}
}
fn pr_body(session_id: &str, paths: &[String]) -> String {
let files = paths
.iter()
.map(|p| format!("- `{p}`"))
.collect::<Vec<_>>()
.join("\n");
format!(
"## Outcome\n\
Session `{session_id}` paused; its snapshot reaches `origin/main` as a PR \
rather than a stranded commit on the main checkout.\n\n\
## Changes\n{files}\n\n\
## Risk\n\
None. Session state only — no crate source, no CI configuration.\n\n\
## Tests\n\
Rung 1 (docs-only): no Cargo gate applies.\n\n\
## Baseline\n\
No pre-existing failures relevant to this diff.\n\n\
## Gates not run\n\
none — rung 1 runs no Cargo gate (#7336).\n\n\
## Partial-red accounting\n\
none.\n\n\
## Docs\n\
No changelog fragment owed — `.trusty-mpm/sessions/**` is not crate source.\n\n\
## Review\n\
Machine-generated pause snapshot; no review findings.\n\n\
{ATTRIBUTION_FOOTER}\n"
)
}
fn parse_pr(stdout: &str) -> Option<(String, String)> {
let line = stdout
.lines()
.find(|l| l.contains("http") && l.contains('#'))?;
let url = line.split_whitespace().find(|w| w.starts_with("http"))?;
let number = url.rsplit('/').next()?;
if number.is_empty() || !number.chars().all(|c| c.is_ascii_digit()) {
return None;
}
Some((number.to_string(), url.to_string()))
}
fn step_err(
step: &'static str,
detail: String,
branch: Option<String>,
commit: Option<String>,
) -> PublishError {
PublishError::Step {
step,
detail,
branch,
commit,
}
}
fn git<V: PauseVcs>(
vcs: &V,
req: &PublishRequest<'_>,
args: &[&str],
index_file: Option<&Path>,
step: &'static str,
) -> Result<CmdOut, PublishError> {
let out = vcs
.git(req.repo, &owned(args), index_file)
.map_err(|e| step_err(step, e.to_string(), None, None))?;
if out.success {
Ok(out)
} else {
Err(step_err(step, out.failure(), None, None))
}
}
fn git_at<V: PauseVcs>(
vcs: &V,
req: &PublishRequest<'_>,
args: &[&str],
step: &'static str,
branch: &str,
commit: &str,
) -> Result<CmdOut, PublishError> {
let at = || (Some(branch.to_string()), Some(commit.to_string()));
let out = vcs.git(req.repo, &owned(args), None).map_err(|e| {
let (b, c) = at();
step_err(step, e.to_string(), b, c)
})?;
if out.success {
Ok(out)
} else {
let (b, c) = at();
Err(step_err(step, out.failure(), b, c))
}
}
fn owned(args: &[&str]) -> Vec<String> {
args.iter().map(|s| (*s).to_string()).collect()
}
pub struct RealPauseVcs;
impl PauseVcs for RealPauseVcs {
fn git(
&self,
repo: &Path,
args: &[String],
index_file: Option<&Path>,
) -> anyhow::Result<CmdOut> {
let mut cmd = trusty_common::git::command_in(repo);
cmd.args(args);
if let Some(idx) = index_file {
cmd.env("GIT_INDEX_FILE", idx);
}
let out = cmd.output()?;
Ok(CmdOut {
success: out.status.success(),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
})
}
fn tm(&self, repo: &Path, args: &[String]) -> anyhow::Result<CmdOut> {
let bin: PathBuf = trusty_common::bin_resolve::resolve_binary("tm")
.ok_or_else(|| anyhow::anyhow!("`tm` is not on PATH; cannot open the snapshot PR"))?;
let out = std::process::Command::new(bin)
.args(args)
.current_dir(repo)
.output()?;
Ok(CmdOut {
success: out.status.success(),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
})
}
}
#[cfg(test)]
#[path = "session_pause_pr_tests.rs"]
mod tests;