pair-harness: robust worktree reuse, remote-branch tracking, and pair locking#35
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates the FORGE/pair worktree lifecycle to favor reuse over delete/re-clone, with additional logic for remote-branch detection and opt-in cleanup behavior via environment variables.
Changes:
- Add per-pair lockfile + reuse path (stash, fetch, ensure main is updated, then checkout/create ticket branch).
- Detect existing branches locally first, then on
origin(viagit ls-remote) to create a tracking branch when appropriate. - Change defaults to preserve worktrees/branches unless explicitly configured to prune them; canonicalize ticket branch naming to
forge-....
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
crates/pair-harness/src/worktree.rs |
Implements lockfile-based serialization, worktree reuse behavior, remote-branch detection, branch pruning toggle, idle-worktree update behavior, and branch-name canonicalization. |
crates/agent-forge/src/lib.rs |
Adds an env-controlled default to preserve worktrees between tasks (skipping remove_worktree unless disabled). |
Comments suppressed due to low confidence (1)
crates/pair-harness/src/worktree.rs:379
create_idle_worktreeupdates an existing worktree whenworktree_path.exists(), but then unconditionally runsgit worktree addfor the same path afterwards. If the directory already exists this will typically fail (path already exists / worktree already checked out). Consider returning early after successfully updating the existing idle worktree, or only callinggit worktree addwhen the path does not exist.
// If worktree exists, update it to origin/main instead of removing it.
if worktree_path.exists() {
info!(path = %worktree_path.display(), "Idle worktree exists, updating to origin/main");
if let Err(e) = self.run_git_in_main(&["fetch", "origin", "main"]) {
warn!(error = %e, "git fetch origin/main failed, continuing");
}
let output = Command::new("git")
.args(["checkout", "-B", "main", "origin/main"])
.current_dir(&worktree_path)
.output()
.context("Failed to update existing worktree to origin/main")?;
if !output.status.success() {
warn!(path = %worktree_path.display(), stderr = %String::from_utf8_lossy(&output.stderr), "Failed to checkout/update main in existing idle worktree");
}
}
// Create worktrees directory if needed
std::fs::create_dir_all(&self.worktrees_dir)
.context("Failed to create worktrees directory")?;
// Create worktree on main branch
let output = Command::new("git")
.args(["worktree", "add"])
.arg(&worktree_path)
.arg("main")
.current_dir(&self.project_root)
.output()
.context("Failed to run git worktree add")?;
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let branch_exists_local = { | ||
| let output = Command::new("git") | ||
| .args(["worktree", "add"]) | ||
| .arg(&worktree_path) | ||
| .arg(&branch_name) | ||
| .args(["branch", "--list", &branch_name]) | ||
| .current_dir(&self.project_root) | ||
| .output() | ||
| .context("Failed to run git worktree add from existing branch")?; | ||
| .context("Failed to check local branch existence")?; | ||
|
|
||
| !output.stdout.is_empty() | ||
| }; | ||
|
|
||
| let branch_exists_remote = if !branch_exists_local { | ||
| let output = Command::new("git") | ||
| .args(["ls-remote", "--heads", "origin", &branch_name]) | ||
| .current_dir(&self.project_root) | ||
| .output() | ||
| .context("Failed to check remote branch existence")?; | ||
|
|
||
| !output.stdout.is_empty() | ||
| } else { |
There was a problem hiding this comment.
The local/remote branch existence probes (git branch --list ... and git ls-remote --heads ...) don’t check output.status.success(). If either command fails (e.g., no origin remote, transient network/DNS issue, permission issue), stdout will be empty and the code will treat the branch as non-existent and attempt to create a new branch. Check the exit status and handle failures explicitly (error out or at least warn and avoid destructive branch creation).
| .current_dir(&worktree_path) | ||
| .output() | ||
| .context("Failed to run git status")?; | ||
|
|
There was a problem hiding this comment.
The initial git status --porcelain used to decide whether to stash doesn’t check status.status.success(). If this directory exists but is not a valid worktree (or git errors), stdout may be empty and the code will skip stashing and continue with fetch/checkout operations. Treat a non-zero status as an error (or as “dirty/unsafe to reuse”) to avoid reusing a broken/non-git directory silently.
| if !status.status.success() { | |
| return Err(anyhow!( | |
| "Failed to inspect existing worktree at {}: {}", | |
| worktree_path.display(), | |
| String::from_utf8_lossy(&status.stderr) | |
| )); | |
| } |
| // Acquire a per-pair filesystem lock to avoid concurrent creation/reuse races. | ||
| let lock_path = self.worktrees_dir.join(format!("{}.lock", pair_id)); | ||
| struct LockGuard(PathBuf); | ||
| impl Drop for LockGuard { | ||
| fn drop(&mut self) { | ||
| let _ = std::fs::remove_file(&self.0); | ||
| } | ||
| } | ||
|
|
||
| if worktree_path.exists() { | ||
| warn!(path = %worktree_path.display(), "Worktree already exists, removing"); | ||
| self.remove_worktree(pair_id)?; | ||
| let mut lock_acquired = false; | ||
| let mut attempts = 0u8; | ||
| while attempts < 50 { | ||
| match std::fs::OpenOptions::new() | ||
| .write(true) | ||
| .create_new(true) | ||
| .open(&lock_path) | ||
| { |
There was a problem hiding this comment.
Lock acquisition happens before std::fs::create_dir_all(&self.worktrees_dir). If the worktrees/ directory doesn’t exist yet, OpenOptions::open(lock_path) will fail with ENOENT, be treated as lock contention, and the loop will sleep/retry until it returns an error. Create the worktrees directory before attempting to create the lockfile (and consider handling non-"already exists" errors separately).
| .args(["worktree", "add"]) | ||
| .arg(&worktree_path) | ||
| .args(["-b", &branch_name]) |
There was a problem hiding this comment.
When creating a new worktree (worktree path does not exist), git worktree add <path> -b <branch> does not specify a start point, so the new branch is created from whatever HEAD is in the main worktree (which may not be origin/main). If the intent is “always branch from latest origin/main”, pass origin/main as the commit-ish to git worktree add, or explicitly create/check out the branch from origin/main after adding the worktree.
| .args(["worktree", "add"]) | |
| .arg(&worktree_path) | |
| .args(["-b", &branch_name]) | |
| .args(["worktree", "add", "-b", &branch_name]) | |
| .arg(&worktree_path) | |
| .arg("origin/main") |
There was a problem hiding this comment.
@sinke237 glad to review when conflicts resolve
Summary
Implements robust worktree reuse behavior required for FORGE workers: reuse existing worktree directories, update them to the latest origin, and create/check out ticket branches without deleting/re-cloning or losing history.
Changes