Skip to content

pair-harness: robust worktree reuse, remote-branch tracking, and pair locking#35

Open
sinke237 wants to merge 1 commit into
mainfrom
20-worktree-reuse-pull-originmain-and-create-new-branch-instead-of-deleting-previous-worktree
Open

pair-harness: robust worktree reuse, remote-branch tracking, and pair locking#35
sinke237 wants to merge 1 commit into
mainfrom
20-worktree-reuse-pull-originmain-and-create-new-branch-instead-of-deleting-previous-worktree

Conversation

@sinke237

Copy link
Copy Markdown
Collaborator

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

  • Locking: add a simple per-pair lockfile to serialize worktree creation/reuse and avoid races (create_worktree locking). See worktree.rs:49-80.
  • Remote branch detection: check local branch first, then git ls-remote --heads origin and, if present, create a local tracking branch from origin/ instead of creating a new branch from origin/main. See worktree.rs:162-206.
  • Fetch behavior: keep a single project-root fetch and ensure a per-worktree git fetch origin before branch operations to keep refs fresh and avoid redundant network calls. See worktree.rs:42-48 and worktree.rs:115-116.
  • Branch naming: canonicalize branch_name to prefer forge- prefix so branch naming is deterministic and matches existing tests (branch_name implementation). See worktree.rs:570-574.
  • Cleanliness check: treat git status failures as non-clean state and log accordingly. See worktree.rs:262-268.
  • Single-file change: all edits are in worktree.rs.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (via git 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_worktree updates an existing worktree when worktree_path.exists(), but then unconditionally runs git worktree add for 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 calling git worktree add when 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.

Comment on lines +162 to +180
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 {

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
.current_dir(&worktree_path)
.output()
.context("Failed to run git status")?;

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
if !status.status.success() {
return Err(anyhow!(
"Failed to inspect existing worktree at {}: {}",
worktree_path.display(),
String::from_utf8_lossy(&status.stderr)
));
}

Copilot uses AI. Check for mistakes.
Comment on lines +49 to +65
// 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)
{

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +235 to +237
.args(["worktree", "add"])
.arg(&worktree_path)
.args(["-b", &branch_name])

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
.args(["worktree", "add"])
.arg(&worktree_path)
.args(["-b", &branch_name])
.args(["worktree", "add", "-b", &branch_name])
.arg(&worktree_path)
.arg("origin/main")

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sinke237 glad to review when conflicts resolve

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Worktree reuse: pull origin/main and create new branch instead of deleting previous worktree

3 participants