agent-cli-worktree-safety
Data-loss invariants for a CLI that runs an agent SDK in a git worktree then force-removes it. Use when building or reviewing such a CLI, or when an agent run reports "no changes" after writing files.
- License 许可
- MIT
- 最近更新
- 2026-11-13
- Stars
- ★ 60
- Forks
- 6
01 前言 Front Matter
- name
- agent-cli-worktree-safety
- model
- opus
- created
- 1788307200000
- modified
- 1790899200000
- reviewed
- 1788307200000
- description
- Data-loss invariants for a CLI that runs an agent SDK in a git worktree then force-removes it. Use when building or reviewing such a CLI, or when an agent run reports "no changes" after writing files.
- allowed-tools
- Glob, Grep, Read, Edit, Write, Bash
- compatibility
- claude-code
02 资源文件 1
- 参考文档REFERENCE.md3.6 KB
03 SKILL.md 正文
Agent-CLI Worktree Safety
Data-loss prevention for CLIs that create a git worktree, run an LLM
orchestrator (claude-agent-sdk or similar) that writes files there, and then
clean up with git worktree remove --force.
When to Use This Skill
| Use this skill when... | Use something else when... |
|---|---|
| Building or reviewing a CLI that runs an agent SDK in a worktree it later removes | Isolating a Claude Code subagent with isolation: "worktree" → .claude/rules/agent-runtime.md § Worktree Isolation |
| An agent run wrote files but reported "No changes were made" | Pruning other sessions' worktrees → .claude/rules/agent-coworker-detection.md § Cleanup |
Wiring the SDK's two-phase or create_worktree flow → REFERENCE.md |
Coordinating parallel agents → agent-patterns-plugin:agent-teams |
The core failure mode
Three independent mistakes compound into silent data loss:
- Driver phases write but never commit — "the outer orchestrator handles git state".
- The outer orchestrator ends without committing — it stalls on an interactive tool call that cannot render (see Interactive tools in SDK subprocess mode below), or simply finishes.
- The "has changes" check only counts commits (
git log base..HEAD), so it reports nothing to preserve — and cleanup force-removes the worktree.
Net result: many files are written, the user is told "No changes were made", and the work is destroyed. No error, no traceback. That user-visible message is the diagnostic tell — if a run wrote files and reported no changes, check invariant 1 before anything else.
Required invariants
1. "Has changes" means commits OR a dirty tree
Any function deciding whether a worktree is worth preserving must return true when the working tree is dirty, even with no commits beyond base:
def worktree_has_changes(worktree_path, base_branch):
commits = subprocess.run(
["git", "log", "--oneline", f"{base_branch}..HEAD"],
cwd=worktree_path, capture_output=True, text=True,
)
if commits.stdout.strip():
return True
dirty = subprocess.run(
["git", "status", "--porcelain"],
cwd=worktree_path, capture_output=True, text=True,
)
return bool(dirty.stdout.strip())
A commits-only check is a data-loss bug. --porcelain (not git diff) is
load-bearing: a brand-new file the agent created is untracked, and
git diff cannot see it.
If uncommitted files matter for the next operation — push, PR, cleanup — they matter for this check.
2. Safety-net commit before any destructive cleanup
If the agent phase was supposed to commit and didn't, capture the work anyway:
def auto_commit_if_dirty(worktree_path, message):
status = subprocess.run(
["git", "status", "--porcelain"],
cwd=worktree_path, capture_output=True, text=True, check=True,
)
if not status.stdout.strip():
return False
subprocess.run(["git", "add", "-A"], cwd=worktree_path, check=True)
subprocess.run(
["git", "commit", "-m", message],
cwd=worktree_path, check=True, capture_output=True, text=True,
)
return True
Call it from every post-run path — interactive and non-interactive — with a message naming the workflow, and print a warning so the user learns the safety net fired. A silent safety net hides the bug it is compensating for.
3. Pre-validate before git touches the path
Typer's exists=True, dir_okay=True is not enough. Call explicit guards at the
top of every command that will later run git rev-parse, git worktree add,
or a domain operation:
def _ensure_git_repo(path):
inside = subprocess.run(
["git", "rev-parse", "--is-inside-work-tree"],
cwd=path, capture_output=True, text=True,
)
if inside.returncode != 0 or inside.stdout.strip() != "true":
raise typer.Exit(code=EXIT_CONFIG_ERROR)
head = subprocess.run(
["git", "rev-parse", "--verify", "HEAD"],
cwd=path, capture_output=True, text=True,
)
if head.returncode != 0: # unborn HEAD — no commits yet
raise typer.Exit(code=EXIT_CONFIG_ERROR)
Unborn HEAD is its own failure class. A fresh git init with no commits
passes --is-inside-work-tree but fails every downstream rev-parse HEAD and
worktree add -b <br> <path> HEAD. Check both, always.
Add a domain check beside it — an Obsidian vault wants .obsidian/ or any
*.md; a code repo wants a language marker — so "pointed at the wrong
directory" is a friendly config error, not a CalledProcessError traceback.
4. Branch-name collisions are not free
Timestamp branch names (%Y-%m-%dT%H-%M) collide when two runs start in the
same minute. If create_worktree force-removes a pre-existing worktree at the
target path, the second run destroys the first run's uncommitted agent output.
Either use second granularity plus a short random suffix, or refuse:
if worktree_path.exists():
dirty = subprocess.run(
["git", "status", "--porcelain"],
cwd=worktree_path, capture_output=True, text=True,
)
if dirty.returncode == 0 and dirty.stdout.strip():
raise RuntimeError(
f"Refusing to overwrite worktree at {worktree_path}: has "
f"uncommitted changes from a concurrent or prior run."
)
An advisory lock that only guards --non-interactive runs does not cover
interactive or concurrent invocations. Those need the in-tree probe.
Interactive tools in SDK subprocess mode
When the SDK runs the CLI as a subprocess, stdin/stdout carry the SDK's JSON
protocol. An interactive tool call such as AskUserQuestion has no terminal to
reach: the call fires, nothing renders, and the model wraps up as if the user
declined. This is failure mode 2 above, and it is silent.
- Do not rely on mid-pipeline interactive tool calls in any driver flow.
- Use a two-phase pattern instead: the agent emits findings and stops → the host collects input (
console.input()) → a secondclient.query()executes the selection. - Remove the interactive tool from
allowed_toolson paths that must not fail silently, so the attempt is an error rather than a shrug.
Worked example: git-repo-agent ADR-003.
The query() vs ClaudeSDKClient split, the full two-phase snippet, and
creating the worktree in Python (ClaudeAgentOptions has no isolation
parameter) are in REFERENCE.md.
Required tests
| Test | Verifies |
|---|---|
test_worktree_has_changes_detects_untracked |
Dirty-tree detection covers "wrote files, never committed" |
test_auto_commit_if_dirty |
The safety net captures uncommitted state and leaves the tree clean |
test_create_worktree_refuses_to_overwrite_dirty |
Collision protection for same-minute runs |
test_cli_rejects_non_git_target |
Friendly config error, not a traceback |
test_cli_rejects_empty_git_repo |
Unborn-HEAD detection |
test_cli_rejects_non_domain_target |
Catches "pointed at the wrong directory" |
Reference layouts: git-repo-agent/tests/test_worktree_changes.py and vault-agent/tests/test_worktree.py (TestWorktreeCollisionSafety).
Checklist when adding a new agent CLI
- [ ]
_ensure_git_repo()+ a domain guard at the top of every write command - [ ] "Has changes" covers uncommitted and untracked state
- [ ] Safety-net commit runs before any
cleanup_worktree(--force)or push - [ ]
create_worktreerefuses to force-remove a dirty pre-existing worktree - [ ] A regression test for each of the six rows above
- [ ] Interactive flows use the two-phase pattern, not a mid-session interactive tool call
Related
python-plugin:typer-cli-completion— sibling convention for the same family of Typer CLIsagent-patterns-plugin:parallel-agent-dispatch— the orchestrator-side contract for worktree-isolated agentsgit-plugin:git-coworker-check— detecting a peer agent in a shared checkout before destructive git ops
04 同仓库更多 Skills
kitty-show-image
Show the user an image with a note on what to judge, via a file send or a labelled kitty tab. Use when the user must see an image, or icat fails with no /dev/tty.
kitty-session-persistence
Snapshot and restore kitty terminal sessions on macOS. Use when surviving WindowServer hangs without losing tabs, configuring snapshots, or restoring after a force-reboot.
kitty-remote-control
Safe kitty remote control from an agent — read panes with kitty @ ls/get-text, notify, and never mutate the live terminal unasked. Use when running inside kitty.
docs-reference-check
Dangling doc-reference gate: flag markdown naming repo paths or relative links that no longer exist. Use when docs cite moved/deleted files, or wiring a doc-drift lint.
youtube-upload
Upload a video to YouTube via the Data API v3 and edit its title, description, tags or thumbnail afterwards. Use when publishing or retitling a YouTube video.
youtube-studio-share
Share a private YouTube video with a whole Google Workspace organization by scripting YouTube Studio in the browser. Use when a video must be org-only.