feat(cli): lock hermes worktrees so concurrent processes can't clobber them

git worktree lock at creation and unlock before removal. A locked
worktree refuses 'git worktree remove' (and prune), so a second hermes
process or a stray cleanup can't silently delete an in-use isolated
worktree. Fail-soft on both paths — a lock/unlock error never blocks
the session or cleanup.

Salvaged from #47029 (Issue #46303). Unlock moved to the actual-removal
path so a preserved (unpushed-commits) worktree stays locked while in use.
This commit is contained in:
JoaoMarcos44 2026-06-18 15:55:50 -07:00 committed by Teknium
parent 62c71ebd8f
commit e48554a3e0

21
cli.py
View file

@ -1340,6 +1340,17 @@ def _setup_worktree(repo_root: str = None) -> Optional[Dict[str, str]]:
except Exception as e:
logger.debug("Error copying .worktreeinclude entries: %s", e)
# Lock the worktree so other processes (and `git worktree remove`) can see
# it is actively in use. Fail-soft: a lock failure never blocks the session.
try:
subprocess.run(
["git", "worktree", "lock", "--reason", f"hermes pid={os.getpid()}", str(wt_path)],
capture_output=True, text=True, timeout=10, cwd=repo_root,
)
logger.debug("Worktree locked: %s (pid=%s)", wt_path, os.getpid())
except Exception as e:
logger.debug("git worktree lock failed (non-fatal): %s", e)
info = {
"path": str(wt_path),
"branch": branch_name,
@ -1415,6 +1426,16 @@ def _cleanup_worktree(info: Dict[str, str] = None) -> None:
# Remove worktree (even if working tree is dirty — uncommitted
# changes without unpushed commits are just artifacts)
# Unlock first so `git worktree remove` isn't blocked by the lock we
# placed at creation time. Fail-soft — never block cleanup.
try:
subprocess.run(
["git", "worktree", "unlock", wt_path],
capture_output=True, text=True, timeout=10, cwd=repo_root,
)
except Exception as e:
logger.debug("git worktree unlock failed (non-fatal): %s", e)
try:
subprocess.run(
["git", "worktree", "remove", wt_path, "--force"],