diff --git a/cli-config.yaml.example b/cli-config.yaml.example index ba4134ef73131..35f87b16c6178 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -166,6 +166,16 @@ model: # # worktree: true # Always create a worktree when in a git repo # worktree: false # Default — only create when -w flag is passed +# +# By default a new worktree branches from the freshly-fetched remote tip +# (the current branch's upstream, else the remote's default branch) so it +# starts current with the project instead of from the local clone's +# (possibly stale) HEAD. Set worktree_sync: false to branch from local HEAD +# instead — useful when offline or when you deliberately want the clone's +# exact current state as the base. +# +# worktree_sync: true # Default — branch from the fetched remote tip +# worktree_sync: false # Branch from local HEAD (offline / pinned base) # ============================================================================= # Terminal Tool Configuration diff --git a/cli.py b/cli.py index d5ac55e4136c8..e15b54b681550 100644 --- a/cli.py +++ b/cli.py @@ -1245,11 +1245,91 @@ def _path_is_within_root(path: Path, root: Path) -> bool: return False -def _setup_worktree(repo_root: str = None) -> Optional[Dict[str, str]]: +def _resolve_worktree_base(repo_root: str) -> tuple: + """Resolve the freshest base ref to branch a new worktree from. + + The standalone clone's ``HEAD`` can lag the remote by hundreds of commits + (the ``~/.hermes/hermes-agent`` clone is updated only by ``hermes update``, + not on every session). Branching a worktree from that stale ``HEAD`` roots + every new branch on an old base — so the PR diff GitHub computes against + current ``main`` balloons with unrelated changes, and the agent has to + discover the staleness via the pre-push gate and rebase. Branching from the + freshly-fetched remote tip instead means the worktree starts current. + + Strategy (each step falls back to the next on failure): + 1. If the current branch tracks an upstream, fetch and use that upstream + ref — so a deliberate feature-branch worktree tracks its own remote, + not the default branch. + 2. Else fetch the remote's default branch (``origin/HEAD`` → e.g. + ``origin/main``) and use it. + 3. Else fall back to ``HEAD`` (offline, no remote, or detached) — the + old behavior, never worse than before. + + Returns ``(base_ref, label)`` where *base_ref* is a git revision suitable + for ``git worktree add ... `` and *label* is a short + human-readable description for the session banner. + """ + import subprocess + + def _git(args, timeout=20): + return subprocess.run( + ["git", *args], + capture_output=True, text=True, timeout=timeout, cwd=repo_root, + ) + + # 1. Current branch's upstream, if it tracks one. + try: + up = _git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]) + if up.returncode == 0: + upstream = up.stdout.strip() # e.g. "origin/main" + if upstream and "/" in upstream: + remote = upstream.split("/", 1)[0] + # Fetch just that branch; fail-soft if offline. + _git(["fetch", remote, upstream.split("/", 1)[1]], timeout=30) + return upstream, f"{upstream} (fetched)" + except Exception as e: + logger.debug("worktree base: upstream resolution failed: %s", e) + + # 2. Remote default branch (origin/HEAD). + try: + # Resolve the remote's default branch symref. + head_ref = _git(["symbolic-ref", "--quiet", "refs/remotes/origin/HEAD"]) + default_ref = "" + if head_ref.returncode == 0: + default_ref = head_ref.stdout.strip().replace("refs/remotes/", "", 1) + if not default_ref: + # origin/HEAD not set locally; ask the remote. + show = _git(["remote", "show", "origin"], timeout=30) + for line in show.stdout.splitlines(): + line = line.strip() + if line.startswith("HEAD branch:"): + _branch = line.split(":", 1)[1].strip() + # A remote with no default branch reports "(unknown)"; + # don't construct a bogus "origin/(unknown)" ref from it. + if _branch and _branch != "(unknown)": + default_ref = "origin/" + _branch + break + if default_ref and "/" in default_ref: + remote, branch = default_ref.split("/", 1) + _git(["fetch", remote, branch], timeout=30) + return default_ref, f"{default_ref} (fetched)" + except Exception as e: + logger.debug("worktree base: default-branch resolution failed: %s", e) + + # 3. Fall back to local HEAD (offline / no remote / detached). + return "HEAD", "HEAD (local — could not reach remote)" + + +def _setup_worktree(repo_root: str = None, sync_base: bool = True) -> Optional[Dict[str, str]]: """Create an isolated git worktree for this CLI session. Returns a dict with worktree metadata on success, None on failure. The dict contains: path, branch, repo_root. + + When *sync_base* is True (default), the worktree branches from the + freshly-fetched remote tip rather than the (possibly stale) local ``HEAD`` + — see ``_resolve_worktree_base``. Set ``worktree_sync: false`` in config to + branch from local ``HEAD`` (the pre-#10760-followup behavior). """ import subprocess @@ -1281,15 +1361,37 @@ def _setup_worktree(repo_root: str = None) -> Optional[Dict[str, str]]: except Exception as e: logger.debug("Could not update .gitignore: %s", e) + # Resolve the base ref. By default branch from the freshly-fetched remote + # tip so the worktree starts current with the project, not from the + # (possibly stale) local HEAD of the standalone clone (#10760 follow-up). + if sync_base: + base_ref, base_label = _resolve_worktree_base(repo_root) + else: + base_ref, base_label = "HEAD", "HEAD (local — worktree_sync disabled)" + # Create the worktree try: result = subprocess.run( - ["git", "worktree", "add", str(wt_path), "-b", branch_name, "HEAD"], + ["git", "worktree", "add", str(wt_path), "-b", branch_name, base_ref], capture_output=True, text=True, timeout=30, cwd=repo_root, ) if result.returncode != 0: - print(f"\033[31m✗ Failed to create worktree: {result.stderr.strip()}\033[0m") - return None + # If branching from the resolved remote ref failed for any reason + # (e.g. a partial fetch left the ref unusable), retry from local + # HEAD so worktree creation never hard-fails on a sync hiccup. + if base_ref != "HEAD": + logger.warning( + "worktree add from %s failed (%s); retrying from local HEAD", + base_ref, result.stderr.strip(), + ) + base_ref, base_label = "HEAD", "HEAD (fallback — remote base failed)" + result = subprocess.run( + ["git", "worktree", "add", str(wt_path), "-b", branch_name, base_ref], + capture_output=True, text=True, timeout=30, cwd=repo_root, + ) + if result.returncode != 0: + print(f"\033[31m✗ Failed to create worktree: {result.stderr.strip()}\033[0m") + return None except Exception as e: print(f"\033[31m✗ Failed to create worktree: {e}\033[0m") return None @@ -1376,10 +1478,12 @@ def _setup_worktree(repo_root: str = None) -> Optional[Dict[str, str]]: "path": str(wt_path), "branch": branch_name, "repo_root": repo_root, + "base": base_ref, } print(f"\033[32m✓ Worktree created:\033[0m {wt_path}") print(f" Branch: {branch_name}") + print(f" Base: {base_label}") return info @@ -14529,7 +14633,11 @@ def main( _repo = _git_repo_root() if _repo: _prune_stale_worktrees(_repo) - wt_info = _setup_worktree() + # Branch the worktree from the freshly-fetched remote tip by + # default so it starts current with the project. Opt out with + # worktree_sync: false to branch from local HEAD instead. + _sync_base = CLI_CONFIG.get("worktree_sync", True) + wt_info = _setup_worktree(sync_base=_sync_base) if wt_info: _active_worktree = wt_info os.environ["TERMINAL_CWD"] = wt_info["path"] diff --git a/tests/cli/test_worktree_sync_base.py b/tests/cli/test_worktree_sync_base.py new file mode 100644 index 0000000000000..e7f2a53a57f86 --- /dev/null +++ b/tests/cli/test_worktree_sync_base.py @@ -0,0 +1,124 @@ +"""Tests for worktree base-ref resolution — branch from the fresh remote tip. + +A worktree created off the standalone clone's local ``HEAD`` roots the new +branch on a stale base when that clone lags the remote. ``_resolve_worktree_base`` +fetches and branches from the remote tip instead so the worktree starts current. + +These tests exercise the REAL ``cli._resolve_worktree_base`` / +``cli._setup_worktree`` against a real local "remote" repo (so ``git fetch`` +works offline in the hermetic sandbox), proving the worktree includes commits +that exist on the remote but not on the stale local HEAD. +""" + +import subprocess +from pathlib import Path + +import pytest + +import cli + + +def _run(args, cwd): + return subprocess.run(args, cwd=cwd, capture_output=True, text=True, timeout=30) + + +def _commit(repo, name, msg): + (Path(repo) / name).write_text(msg + "\n") + _run(["git", "add", "."], repo) + _run(["git", "commit", "-m", msg], repo) + + +def _head(repo): + return _run(["git", "rev-parse", "HEAD"], repo).stdout.strip() + + +@pytest.fixture +def remote_and_clone(tmp_path): + """A bare 'remote' + a clone that is intentionally BEHIND the remote. + + Returns (clone_path, remote_head_sha, stale_local_head_sha). + """ + remote = tmp_path / "remote.git" + seed = tmp_path / "seed" + seed.mkdir() + _run(["git", "init"], seed) + _run(["git", "config", "user.email", "t@t.com"], seed) + _run(["git", "config", "user.name", "T"], seed) + # Pin the seed repo's branch name so push + remote default are 'main'. + _run(["git", "checkout", "-b", "main"], seed) + _commit(seed, "README.md", "base commit") + _run(["git", "init", "--bare", str(remote)], tmp_path) + _run(["git", "remote", "add", "origin", str(remote)], seed) + _run(["git", "push", "origin", "main"], seed) + # Set the bare remote's default branch so a clone gets origin/HEAD -> + # origin/main and a tracking branch (mirrors a real GitHub remote). + _run(["git", "symbolic-ref", "HEAD", "refs/heads/main"], remote) + + # Clone it (this clone tracks origin/main). + clone = tmp_path / "clone" + _run(["git", "clone", str(remote), str(clone)], tmp_path) + _run(["git", "config", "user.email", "t@t.com"], clone) + _run(["git", "config", "user.name", "T"], clone) + stale_local_head = _head(clone) + + # Advance the REMOTE past the clone (simulating other merges landing on + # main while this clone sat stale). + _commit(seed, "feature.txt", "remote-only commit") + _run(["git", "push", "origin", "main"], seed) + remote_head = _head(seed) + + assert remote_head != stale_local_head + return clone, remote_head, stale_local_head + + +class TestResolveWorktreeBase: + def test_resolves_to_fetched_upstream(self, remote_and_clone): + clone, remote_head, stale_local_head = remote_and_clone + base_ref, label = cli._resolve_worktree_base(str(clone)) + # Should resolve to the upstream tracking ref and have fetched it. + assert base_ref == "origin/main" + assert "fetched" in label + # The fetched ref now points at the remote tip, not the stale local HEAD. + resolved = _run(["git", "rev-parse", base_ref], clone).stdout.strip() + assert resolved == remote_head + assert resolved != stale_local_head + + def test_falls_back_to_head_without_remote(self, tmp_path): + repo = tmp_path / "no-remote" + repo.mkdir() + _run(["git", "init"], repo) + _run(["git", "config", "user.email", "t@t.com"], repo) + _run(["git", "config", "user.name", "T"], repo) + _commit(repo, "README.md", "only commit") + base_ref, label = cli._resolve_worktree_base(str(repo)) + assert base_ref == "HEAD" + assert "HEAD" in label + + +class TestSetupWorktreeSyncBase: + def test_sync_true_branches_from_remote_tip(self, remote_and_clone, monkeypatch): + clone, remote_head, stale_local_head = remote_and_clone + info = cli._setup_worktree(str(clone), sync_base=True) + assert info is not None + # The new worktree's HEAD must be the REMOTE tip, not the stale local one. + wt_head = _head(info["path"]) + assert wt_head == remote_head, "worktree should start from the fetched remote tip" + assert wt_head != stale_local_head + # And it must contain the remote-only file. + assert (Path(info["path"]) / "feature.txt").exists() + + def test_sync_false_branches_from_local_head(self, remote_and_clone): + clone, remote_head, stale_local_head = remote_and_clone + info = cli._setup_worktree(str(clone), sync_base=False) + assert info is not None + # Opted out -> branch from the stale local HEAD (old behavior). + wt_head = _head(info["path"]) + assert wt_head == stale_local_head + assert not (Path(info["path"]) / "feature.txt").exists() + + def test_default_is_sync_true(self, remote_and_clone): + """The default path (no sync_base arg) branches from the remote tip.""" + clone, remote_head, _ = remote_and_clone + info = cli._setup_worktree(str(clone)) + assert info is not None + assert _head(info["path"]) == remote_head diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 0f9db9876c182..939bf36effff6 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -706,6 +706,13 @@ worktree: true # Always create a worktree (same as hermes -w) When enabled, each CLI session creates a fresh worktree under `.worktrees/` with its own branch. Agents can edit files, commit, push, and create PRs without interfering with each other. Clean worktrees are removed on exit; dirty ones are kept for manual recovery. +By default the new worktree branches from the **freshly-fetched remote tip** (the current branch's upstream, otherwise the remote's default branch) so it starts current with the project rather than from the local clone's possibly-stale `HEAD`. This keeps a PR's diff scoped to the actual change instead of inheriting whatever the local clone was behind by. Set `worktree_sync: false` to branch from local `HEAD` instead — useful offline, or when you deliberately want the clone's exact current state as the base. If the remote can't be reached, it falls back to local `HEAD` automatically. + +```yaml +worktree_sync: true # Default — branch from the fetched remote tip +# worktree_sync: false # Branch from local HEAD (offline / pinned base) +``` + You can also list gitignored files to copy into worktrees via `.worktreeinclude` in your repo root: ```