use std::path::Path;
use std::time::Duration;
const SYNC_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SyncOutcome {
UpToDate,
FastForwarded {
from: String,
to: String,
},
NotAGitWorktree,
NoUpstream,
Failed(String),
}
pub fn sync_worktree_with_upstream(workspace: &Path) -> SyncOutcome {
if !workspace.join(".git").exists() {
return SyncOutcome::NotAGitWorktree;
}
let Some(upstream) = git_stdout(
workspace,
&[
"rev-parse",
"--abbrev-ref",
"--symbolic-full-name",
"@{upstream}",
],
) else {
return SyncOutcome::NoUpstream;
};
let Some(remote) = upstream.split('/').next().filter(|s| !s.is_empty()) else {
return SyncOutcome::NoUpstream;
};
let before = git_stdout(workspace, &["rev-parse", "HEAD"]).unwrap_or_default();
if let Err(stderr) = git_run(
workspace,
&[
"-c",
"http.lowSpeedLimit=1000",
"-c",
"http.lowSpeedTime=5",
"fetch",
remote,
],
) {
return SyncOutcome::Failed(format!("git fetch failed: {stderr}"));
}
if let Err(stderr) = git_run(workspace, &["merge", "--ff-only", &upstream]) {
return SyncOutcome::Failed(stderr);
}
let after = git_stdout(workspace, &["rev-parse", "HEAD"]).unwrap_or_default();
if before == after {
SyncOutcome::UpToDate
} else {
SyncOutcome::FastForwarded {
from: before,
to: after,
}
}
}
pub fn self_heal_claude_md(workspace: &Path) -> bool {
let path = workspace.join("CLAUDE.md");
let Ok(original) = std::fs::read_to_string(&path) else {
return false;
};
let cleaned = crate::core::instruction_pipeline::strip_delegation_block(&original);
if cleaned == original {
return false;
}
std::fs::write(&path, cleaned).is_ok()
}
pub async fn resume_self_heal(workspace: &Path, session_id: &str) {
match sync_worktree_with_upstream_bounded(workspace).await {
SyncOutcome::FastForwarded { from, to } => {
tracing::info!(id = %session_id, %from, %to, "resume: worktree fast-forwarded to upstream");
}
SyncOutcome::Failed(reason) => {
tracing::warn!(id = %session_id, "resume: worktree/upstream sync skipped (non-fatal): {reason}");
}
SyncOutcome::UpToDate | SyncOutcome::NotAGitWorktree | SyncOutcome::NoUpstream => {}
}
if self_heal_claude_md(workspace) {
tracing::info!(id = %session_id, "resume: stripped legacy delegation-directive pollution from CLAUDE.md");
}
}
async fn sync_worktree_with_upstream_bounded(workspace: &Path) -> SyncOutcome {
let workspace = workspace.to_path_buf();
let task = tokio::task::spawn_blocking(move || sync_worktree_with_upstream(&workspace));
match tokio::time::timeout(SYNC_TIMEOUT, task).await {
Ok(Ok(outcome)) => outcome,
Ok(Err(join_err)) => SyncOutcome::Failed(format!("sync task panicked: {join_err}")),
Err(_elapsed) => SyncOutcome::Failed(format!(
"worktree sync timed out after {}s",
SYNC_TIMEOUT.as_secs()
)),
}
}
fn git_stdout(workspace: &Path, args: &[&str]) -> Option<String> {
let out = trusty_common::git::command_in(workspace)
.args(args)
.output()
.ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
if text.is_empty() { None } else { Some(text) }
}
fn git_run(workspace: &Path, args: &[&str]) -> Result<(), String> {
let out = trusty_common::git::command_in(workspace)
.args(args)
.output()
.map_err(|e| format!("git exec failed: {e}"))?;
if out.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
#[cfg(test)]
mod tests {
use std::process::Command;
use super::*;
use tempfile::TempDir;
struct Fixture {
_origin_dir: TempDir,
_base_dir: TempDir,
_seed_dir: TempDir,
worktree_dir: TempDir,
origin: std::path::PathBuf,
seed: std::path::PathBuf,
}
fn git_ok(dir: &Path, args: &[&str]) -> bool {
Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn configure_identity(dir: &Path) {
let _ = Command::new("git")
.arg("-C")
.arg(dir)
.args(["config", "user.email", "ci@test.invalid"])
.output();
let _ = Command::new("git")
.arg("-C")
.arg(dir)
.args(["config", "user.name", "CI"])
.output();
}
fn build_fixture() -> Option<Fixture> {
let origin_dir = crate::test_support::hermetic_temp_dir();
let origin = origin_dir.path().to_path_buf();
if !git_ok(&origin, &["init", "--bare"]) {
eprintln!("worktree_sync tests: git unavailable, skipping");
return None;
}
let seed_dir = crate::test_support::hermetic_temp_dir();
let seed = seed_dir.path().to_path_buf();
if !git_ok(&seed, &["init"]) {
return None;
}
configure_identity(&seed);
if !git_ok(&seed, &["checkout", "-b", "main"]) {
return None;
}
std::fs::write(seed.join("CLAUDE.md"), "old content\n").ok()?;
std::fs::write(seed.join("OTHER.md"), "other v1\n").ok()?;
if !git_ok(&seed, &["add", "-A"]) {
return None;
}
if !git_ok(&seed, &["commit", "-m", "init"]) {
return None;
}
if !git_ok(
&seed,
&["remote", "add", "origin", origin.to_str().unwrap()],
) {
return None;
}
if !git_ok(&seed, &["push", "origin", "main"]) {
return None;
}
if !git_ok(&origin, &["symbolic-ref", "HEAD", "refs/heads/main"]) {
return None;
}
let base_dir = crate::test_support::hermetic_temp_dir();
let base = base_dir.path().to_path_buf();
let _ = std::fs::remove_dir(&base);
if !git_ok(
Path::new("."),
&["clone", origin.to_str().unwrap(), base.to_str().unwrap()],
) {
return None;
}
configure_identity(&base);
let worktree_dir = crate::test_support::hermetic_temp_dir();
let worktree = worktree_dir.path().to_path_buf();
let _ = std::fs::remove_dir(&worktree);
if !git_ok(
&base,
&[
"worktree",
"add",
"-b",
"session-branch",
worktree.to_str().unwrap(),
],
) {
return None;
}
configure_identity(&worktree);
if !git_ok(&base, &["config", "extensions.worktreeConfig", "true"]) {
return None;
}
if !git_ok(
&worktree,
&["config", "--worktree", "push.default", "current"],
) {
return None;
}
if !git_ok(
&worktree,
&["branch", "--set-upstream-to=origin/main", "session-branch"],
) {
return None;
}
Some(Fixture {
_origin_dir: origin_dir,
_base_dir: base_dir,
_seed_dir: seed_dir,
worktree_dir,
origin,
seed,
})
}
fn push_claude_md_update(fx: &Fixture, new_content: &str) {
assert!(git_ok(&fx.seed, &["pull", "origin", "main"]));
std::fs::write(fx.seed.join("CLAUDE.md"), new_content).unwrap();
assert!(git_ok(&fx.seed, &["add", "-A"]));
assert!(git_ok(&fx.seed, &["commit", "-m", "update CLAUDE.md"]));
assert!(git_ok(&fx.seed, &["push", "origin", "main"]));
let _ = &fx.origin; }
#[test]
fn sync_fast_forwards_tracked_file_leaves_uncommitted_user_file_untouched() {
let Some(fx) = build_fixture() else {
return;
};
let worktree = fx.worktree_dir.path();
std::fs::write(worktree.join("OTHER.md"), "other v1 + user edit\n").unwrap();
push_claude_md_update(&fx, "new content\n");
let outcome = sync_worktree_with_upstream(worktree);
assert!(
matches!(outcome, SyncOutcome::FastForwarded { .. }),
"expected a fast-forward, got {outcome:?}"
);
assert_eq!(
std::fs::read_to_string(worktree.join("CLAUDE.md")).unwrap(),
"new content\n",
"tm-tracked CLAUDE.md must refresh to the current origin/main content"
);
assert_eq!(
std::fs::read_to_string(worktree.join("OTHER.md")).unwrap(),
"other v1 + user edit\n",
"uncommitted user work in an untouched file must survive the sync"
);
}
#[test]
fn sync_is_noop_when_already_up_to_date() {
let Some(fx) = build_fixture() else {
return;
};
let worktree = fx.worktree_dir.path();
let first = sync_worktree_with_upstream(worktree);
assert_eq!(first, SyncOutcome::UpToDate);
let second = sync_worktree_with_upstream(worktree);
assert_eq!(second, SyncOutcome::UpToDate);
}
#[test]
fn sync_skips_when_no_upstream_configured() {
let tmp = crate::test_support::hermetic_temp_dir();
let dir = tmp.path();
if !git_ok(dir, &["init"]) {
return;
}
configure_identity(dir);
std::fs::write(dir.join("f.txt"), "x").unwrap();
if !git_ok(dir, &["add", "-A"]) || !git_ok(dir, &["commit", "-m", "init"]) {
return;
}
assert_eq!(sync_worktree_with_upstream(dir), SyncOutcome::NoUpstream);
}
#[test]
fn sync_reports_not_a_git_worktree_for_non_git_dir() {
let tmp = crate::test_support::hermetic_temp_dir();
assert_eq!(
sync_worktree_with_upstream(tmp.path()),
SyncOutcome::NotAGitWorktree
);
}
#[test]
fn sync_fails_open_on_conflicting_uncommitted_change() {
let Some(fx) = build_fixture() else {
return;
};
let worktree = fx.worktree_dir.path();
std::fs::write(worktree.join("CLAUDE.md"), "local uncommitted edit\n").unwrap();
push_claude_md_update(&fx, "new content\n");
let outcome = sync_worktree_with_upstream(worktree);
assert!(
matches!(outcome, SyncOutcome::Failed(_)),
"expected a fail-open Failed outcome, got {outcome:?}"
);
assert_eq!(
std::fs::read_to_string(worktree.join("CLAUDE.md")).unwrap(),
"local uncommitted edit\n",
"a refused merge must leave the uncommitted local edit untouched"
);
}
#[test]
fn sync_fails_open_when_branches_have_diverged_with_local_commit() {
let Some(fx) = build_fixture() else {
return;
};
let worktree = fx.worktree_dir.path();
std::fs::write(worktree.join("OTHER.md"), "other v1 + local work\n").unwrap();
assert!(git_ok(worktree, &["add", "-A"]));
assert!(git_ok(worktree, &["commit", "-m", "local wip commit"]));
let head_before = git_stdout(worktree, &["rev-parse", "HEAD"]).unwrap();
push_claude_md_update(&fx, "new content\n");
let outcome = sync_worktree_with_upstream(worktree);
assert!(
matches!(outcome, SyncOutcome::Failed(_)),
"expected a fail-open Failed outcome on diverged histories, got {outcome:?}"
);
let head_after = git_stdout(worktree, &["rev-parse", "HEAD"]).unwrap();
assert_eq!(
head_before, head_after,
"a refused non-fast-forward merge must never move the branch tip"
);
assert_eq!(
std::fs::read_to_string(worktree.join("OTHER.md")).unwrap(),
"other v1 + local work\n",
"the local commit's content must survive an untouched worktree"
);
assert_eq!(
std::fs::read_to_string(worktree.join("CLAUDE.md")).unwrap(),
"old content\n",
"CLAUDE.md must stay at the worktree's own committed content, not \
silently pick up origin's unrelated commit"
);
}
#[test]
fn self_heal_claude_md_strips_legacy_block() {
let tmp = crate::test_support::hermetic_temp_dir();
let begin = "<!-- trusty-mpm:delegation-directive:begin \
(framework-owned — do not edit; regenerated on every session start, see issue #2125) -->";
let end = "<!-- trusty-mpm:delegation-directive:end -->";
let polluted = format!(
"{begin}\n\nSome injected directive text.\n\n{end}\n\n# My Project\n\nNotes.\n"
);
std::fs::write(tmp.path().join("CLAUDE.md"), &polluted).unwrap();
assert!(self_heal_claude_md(tmp.path()));
let cleaned = std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
assert!(!cleaned.contains(begin));
assert!(!cleaned.contains("Some injected directive text."));
assert_eq!(cleaned, "# My Project\n\nNotes.\n");
}
#[test]
fn self_heal_claude_md_noop_when_clean() {
let tmp = crate::test_support::hermetic_temp_dir();
let clean = "# My Project\n\nNotes.\n";
std::fs::write(tmp.path().join("CLAUDE.md"), clean).unwrap();
assert!(!self_heal_claude_md(tmp.path()));
assert_eq!(
std::fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap(),
clean
);
}
#[test]
fn self_heal_claude_md_noop_when_absent() {
let tmp = crate::test_support::hermetic_temp_dir();
assert!(!self_heal_claude_md(tmp.path()));
}
}