fix(cli): branch new worktrees from the fresh remote tip, not stale local HEAD (#50355)
hermes -w created the worktree branch from the standalone clone's HEAD, which lags origin when the clone isn't freshly updated (it's only refreshed by hermes update, not per session). Every worktree branch then rooted on a stale base, so the PR diff GitHub computes against current main ballooned with unrelated changes and the agent had to discover the staleness at push time and rebase. _resolve_worktree_base() now fetches and branches from the freshest available ref: the current branch's upstream if it tracks one (so a deliberate feature-branch worktree tracks its own remote), else the remote's default branch (origin/HEAD), else local HEAD as a fail-soft fallback (offline / no remote / detached). A bogus 'origin/(unknown)' default is guarded, and worktree creation retries from HEAD if branching off the remote ref fails — so this is never worse than the old behavior. Gated by worktree_sync (default true); set worktree_sync: false to keep the old branch-from-local-HEAD behavior. The resolved base is printed in the session banner. This is the follow-up to the #50319 session, where the standalone clone was 213 commits behind origin and the worktree inherited that stale base.
This commit is contained in:
parent
e217fd42e2
commit
b6d1072408
4 changed files with 254 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
118
cli.py
118
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 ... <base_ref>`` 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"]
|
||||
|
|
|
|||
124
tests/cli/test_worktree_sync_base.py
Normal file
124
tests/cli/test_worktree_sync_base.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
||||
```
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue