Skip to content
Back to skills

Agent Cli Worktree Safety

ASecurity

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.

  • 58 stars
  • 0 votes
  • 0 copies
  • 3 views
  • Added September 3, 2026
ai-agentspythonbashgit

Works with

  • claude code
  • terminal
  • cli

Security analysis

A100/100

Scanned October 5, 2026

npx -y skills add laurigates/claude-plugins --skill agent-cli-worktree-safety --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Agent Cli Worktree Safety?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Agent Cli Worktree Safety
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/laurigates-agent-cli-worktree-safety/badge)](https://www.skillsdirectory.com/skills/laurigates-agent-cli-worktree-safety)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
created: 2026-09-02
modified: 2026-10-02
reviewed: 2026-09-02
name: agent-cli-worktree-safety
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
model: opus
compatibility: claude-code
---

# 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](REFERENCE.md) | Coordinating parallel agents → `agent-patterns-plugin:agent-teams` |

## The core failure mode

Three independent mistakes compound into silent data loss:

1. **Driver phases write but never commit** — "the outer orchestrator handles git state".
2. **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.
3. **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:

```python
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:

```python
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:

```python
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:

```python
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 second `client.query()` executes the selection.
- Remove the interactive tool from `allowed_tools` on paths that must not fail silently, so the attempt is an error rather than a shrug.

Worked example: [git-repo-agent ADR-003](https://github.com/laurigates/git-repo-agent/blob/main/docs/adr/003-switch-to-claude-sdk-client-for-interactive-workflows.md).

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](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`](https://github.com/laurigates/git-repo-agent/blob/main/tests/test_worktree_changes.py) and [`vault-agent/tests/test_worktree.py`](https://github.com/laurigates/vault-agent/blob/main/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_worktree` refuses 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 CLIs
- `agent-patterns-plugin:parallel-agent-dispatch` — the orchestrator-side contract for worktree-isolated agents
- `git-plugin:git-coworker-check` — detecting a peer agent in a shared checkout before destructive git ops

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…