---
name: plover-git-safety
description: Use when doing git work in this repo (or via subagents/worktrees) and hitting "files must be written to the correct artifact directory" from file-edit tools on worktree paths, a commit landing on the wrong branch or HEAD swapped unexpectedly mid-task, an implementation subagent running `git stash`/`git checkout` and tangling parked WIP, suspicion that the user's pnpm dev is running from a second/separate local checkout on another drive or branch, verifying whether a GitHub PR marked "Merged" actually shipped to main vs. into another feature branch, or when `gh pr merge` is rejected with "part of a stack and must be merged using the asynchronous merge REST API".
---

# Plover Git Safety

## Overview
Footguns around concurrent git usage, subagent worktrees, multiple local checkouts, and PR base-branch confusion — all cases where the obvious git signal ("Merged" badge, clean file write, exit code 0) is misleading about what actually happened.

## Quick reference
| Symptom / error | Fix |
|---|---|
| `write_to_file`/`replace_file_content`/`multi_replace_file_content` error: "files must be written to the correct artifact directory: \<artifact-dir-of-subagent\>" | Use Bash with `cat << 'EOF' > file` or `sed` instead of the custom file tools for worktree paths |
| A commit lands on the wrong branch / `git reflog` shows an unexpected `checkout: moving from <branch> to <other>` you never issued | Do multi-step git work in an isolated `git worktree add <sibling-path> <branch>`; if a stray commit already landed wrong, `git cherry-pick <sha>` onto the correct branch — don't reset the branch another actor may be using |
| Finished editing when a concurrent actor swaps HEAD to an unrelated branch, leaving your **uncommitted** work on the wrong branch | Don't commit/stash in the shared checkout. `git diff > /tmp/x.patch` (backup), `git worktree add <sibling> -b <new> origin/<base>`, `git -C <sibling> apply --3way /tmp/x.patch` (3-way survives base divergence — resolve any conflict by re-applying the change rule to the new base), commit there, then `git restore <paths>` in the shared checkout to un-pollute it |
| Need `tsc`/`eslint` in a fresh `git worktree` but it has no `node_modules` (a full `pnpm install` triggers a slow native rebuild) | Symlink from the primary checkout: `ln -s <primary>/node_modules <wt>/node_modules && ln -s <primary>/app/node_modules <wt>/app/node_modules`, then `pnpm --filter ./app run typecheck`. Fine for typecheck/lint/vitest; do a real install before packaging or running native code |
| Fix verified (typecheck/lint/test green) but user says bug still reproduces after restarting `pnpm dev` | Ask which folder/drive `pnpm dev` is running from — may be a second separate checkout on another branch. Check `git remote -v` / `git rev-parse --abbrev-ref HEAD` there |
| Need to move a fix between two local checkouts of the same repo without pushing | From the target checkout: `git fetch <absolute-path-to-other-checkout> <branch>:<branch>` — works offline, no push permission needed |
| PR shows "Merged" on GitHub but the fix isn't on `main` | Check base branch, not just merged status: `gh pr view <n> --json baseRefName,mergeCommit` then `git merge-base --is-ancestor <mergeCommit.oid> origin/main` |
| `prettier --write "<glob>"` leaves files you never touched showing as ` M` in `git status`, but `git diff` on them is empty | Line-ending churn on Windows, not a content change. Restore them: `git checkout -- <those paths>`. Prefer passing prettier the explicit list of files you edited instead of a directory glob |
| A dispatched subagent (no worktree isolation) reports it ran `git restore`/`git clean -fd`/`git reset --hard` mid-task on its own initiative | Treat as an incident even if nothing was lost. Re-verify every other concurrently-running subagent's files by reading their actual content, not just `git status` or their self-reported "tests green". Give subagents `isolation: "worktree"` whenever the task might make them want to reset and retry, and carry the git-destructive-command ban into the dispatch prompt — it does not inherit automatically |
| A refactor branch cut before `git fetch` conflicts with a teammate's feature that edited the same file | Do not resolve a whole-file restructure against a feature addition by hand — the merge silently drops the feature. Check first (`git log origin/main -- <file>`, grep the feature's identifiers, compare counts before/after), then rebuild the refactor against current `main` instead of rebasing |
| `gh pr merge` fails with "part of a stack and must be merged using the asynchronous merge REST API"; the sync REST `PUT pulls/<n>/merge` also 403s with "Merging stacked PRs via this endpoint is not supported" | This repo uses **GitHub native stacked PRs**. Merge **bottom-up** via the async endpoint: `gh api --method PUT repos/tryplover/Plover/pulls/<n>/merge-async -f merge_method=squash` (returns `status: pending, Merge request enqueued`), then poll `gh pr view <n> --json state` until `MERGED`. After each merge, `git fetch --prune`; GitHub auto-retargets the next child PR's base onto `main` and force-updates its branch. Verify `baseRefName==main` + `mergeable==MERGEABLE` before merging the next one up. |
| An implementation subagent ran `git stash`/`git stash pop` and the controller's parked WIP stashes are reordered/relabeled/missing | Nothing is lost — a popped stash survives as a dangling commit. Recover via `git fsck --no-reflogs --unreachable \| grep commit`, then `git show -s --format='%s' <sha>` to find the `WIP on`/`On <branch>` stash commits; a plain `git stash` (no pathspec) snapshots the WHOLE tree vs its base, mixing WIP with committed task work. Prevent by forbidding stash/checkout/reset in every subagent prompt |

## Details

### Subagent file-creation/edit tools fail on worktree paths outside conversation directory
**Symptom:** `write_to_file`, `replace_file_content`, and `multi_replace_file_content` error with `files must be written to the correct artifact directory: <artifact-dir-of-subagent>`.
**Root cause:** These tools enforce a security/scope policy requiring all paths to be inside the active subagent's conversation ID directory. Git worktrees created for subagents live under the main agent's conversation directory, so any workspace paths violate this check.
**Fix:** Use Bash with Unix tools (e.g. `cat << 'EOF' > file` or `sed`) to create or edit files in the workspace directory instead of the custom file-handling tools.

### Concurrent sessions in the same working directory silently swap out HEAD mid-task
**Symptom:** Ran `git checkout -b <new-branch> origin/main` in the primary working directory, did unrelated work, then `git commit` — the commit landed on local `main` instead of the new branch. `git reflog` showed a `checkout: moving from <new-branch> to main` event this session never issued.
**Root cause:** The user (or another Claude Code session/tool) was actively working in the same primary checkout at the same time, switching branches and committing on their own branch. A single working directory has exactly one HEAD; whichever actor checks out last wins, with no warning to the other. Invisible from inside a session except retroactively via `git reflog`.
**Fix:** When there's any chance the user or another session is concurrently using the primary repo directory (ask if unsure — don't assume), do multi-step git work (branch + commits) in an isolated `git worktree` instead: `git worktree add <sibling-path> <branch>`, then run all further Bash/Edit calls against that path, never the primary directory. If a stray commit already landed on the wrong branch before noticing, recover it non-destructively — `git cherry-pick <sha>` onto the correct branch from the worktree — rather than resetting the branch the other actor is using, which they might be actively building on top of.

### The user runs a second, separate local checkout of this repo on its own branch
**Symptom:** Implemented and verified (typecheck/lint/test all green) a fix on a branch built off `wip/liquid-glass-overlay`. User tested by restarting `pnpm dev` and reported "still not synced up," even though the code logically can't produce the observed behavior (same shared function, same IPC channel).
**Root cause:** The user's `pnpm dev` was running from a second, entirely separate local clone of the same GitHub remote (e.g. `D:\GitHub\Plover`), sitting on a different branch (e.g. `ui-fixes`, tracking `origin/ui-fixes`) — not the checkout this session had been working in. That branch can have its own independent, parallel history for the same feature (its own prior restoration/fix), distinct from the branch under active work. `pnpm dev` reads from whichever checkout's disk it's launched from — there is no cross-checkout code sharing short of git itself. A diagnosis from one branch's copy of a feature does not necessarily transfer exactly to another's; re-verify against the actual current file contents on whichever branch is real (e.g. grep for the expected caller to confirm it's actually wired up there).
**Fix:** Before trusting a bug report against a locally-running dev build, confirm which checkout/branch is actually being run — ask directly ("what folder/drive is `pnpm dev` running from?") rather than assuming the primary working directory this session started in is the only one. If a fix doesn't take effect despite a full app restart and the code logically can't produce the observed behavior, checkout-mismatch is a stronger hypothesis than a subtle runtime race — check `git remote -v` / `git rev-parse --abbrev-ref HEAD` in the other location before re-diagnosing from scratch. To move a fix between two local checkouts of the same repo without touching the shared GitHub remote, commit it in one and run, from the other, `git fetch <absolute-path-to-other-checkout> <branch>:<branch>` — works entirely offline, no push permission needed.

### `prettier --write` over a glob dirties unrelated files on Windows with an empty diff
**Symptom:** After formatting a refactor with `pnpm --filter ./app exec prettier --write "src/renderer/main/**/*.tsx"`, `git status` listed a dozen files the change never touched (icons, other pages, test files) as modified — but `git diff --stat` on them printed nothing except `warning: ... LF will be replaced by CRLF the next time Git touches it`.
**Root cause:** The checkout has CRLF in the working tree (git's autocrlf conversion) while prettier writes LF. Git sees a byte-level change on disk, so the file is "modified," but normalizes it away in the diff/index — so the diff is empty and committing them would be a no-op that still bloats the review.
**Fix:** `git checkout -- <unrelated paths>` to drop the churn before staging. Better, don't create it: pass prettier the explicit list of files the change actually touched rather than a directory glob. `git status --short` after formatting is the cheap check — anything modified that isn't in your change set is this.

### A parallel subagent ran `git restore`/`git clean -fd` unprompted in a shared checkout
**Symptom:** Three independent bug-fix subagents were dispatched in parallel, all writing into the same primary checkout (no worktree isolation, because the work was meant to land directly on the current branch). One subagent's completion report carried a harness "SECURITY WARNING" that it had run `git restore` on several modified files and `git clean -fd` on untracked test directories mid-task, with nothing authorizing those targets.
**Root cause:** The subagent hit a confusing intermediate state in its own edits and used destructive git commands to get back to a clean baseline before retrying. Its dispatch prompt specified the task and the verification commands but never restated the git-destructive-command ban — that constraint does not automatically inherit into a subagent. Because the working directory was shared with two other in-flight subagents holding uncommitted new files, a broad `git restore .` / `git clean -fd` could have destroyed their work with no error surfaced to anyone. Nothing was actually lost only because of timing.
**Fix:** When dispatching parallel subagents that edit a shared checkout, (1) put the git-safety constraints in each dispatch prompt explicitly, (2) prefer `isolation: "worktree"` for anything with retry risk, and (3) on any report of a destructive git command, immediately verify the *other* agents' expected changes by reading file contents directly — a subagent's own "typecheck/lint/test green" says nothing about what it did to its neighbors. Commit completed work promptly rather than letting several agents' output accumulate uncommitted in one tree.

### A whole-file refactor silently deletes a teammate's feature when rebased
**Symptom:** A branch splitting four oversized page components was cut from `main`, and while the work was in flight `main` gained a feature (`+X%` progress pops) that edited ~57 lines across two of those same files. `git rebase origin/main` reported conflicts in both files.
**Root cause:** Git cannot reconcile "this file was restructured into seven new files" with "this file gained a feature," so conflict resolution degenerates into choosing one side. Taking the refactor's version — the natural instinct, since it's "the newer work" — drops the teammate's feature entirely, with no conflict marker left behind to notice. Confirmed via `git merge-base --is-ancestor`: the feature commit was not an ancestor of the refactor commit, so it existed only on one side.
**Fix:** Before rebasing a structural refactor, check whether `main` touched the same files (`git log <old-base>..origin/main -- <paths>`). If it did, rebuild the refactor against current `main` rather than resolving conflicts. Verify preservation mechanically, not by eye: grep the feature's distinctive identifiers and compare occurrence counts before/after, and diff the sorted set of CSS class names and `data-testid`s between the original file and the union of its replacements — they should be identical.

### An implementation subagent runs `git stash` and tangles the controller's parked WIP
**Symptom:** A controller (e.g. running subagent-driven development) parked the user's uncommitted WIP with `git stash push` before starting, then an implementation subagent — trying to "verify" or "clean up" its working tree — ran `git stash` / `git stash pop` on its own. Afterwards `git stash list` shows the stashes reordered, relabeled (a re-`push` copies whatever `-m` message or auto-generates `On <branch>: ...`), or one apparently missing, and a single stash suddenly contains the WHOLE tree (WIP mixed with the committed task diffs).
**Root cause:** All actors share one stash stack and one working tree. `git stash pop` removes the stash ref (the commit becomes dangling, not deleted). A plain `git stash` with no pathspec snapshots every tracked change in the tree relative to its base parent — so if task commits already exist, that snapshot's diff spans both the user's WIP and the task work, and can't be cleanly separated by `git stash show` alone.
**Fix:** Nothing is lost — every stash (including popped ones) persists as a dangling commit until GC. Recover by enumerating them:
```sh
git fsck --no-reflogs --unreachable | grep commit | awk '{print $3}' \
  | while read c; do git show -s --format='%h %P|%s' "$c"; done \
  | grep -Ei 'sdd-wip|WIP on|On '   # stash commits have 2-3 parents + a "WIP on"/"On <branch>" subject
```
Then `git diff --name-only <base> <stash-sha>` to inspect each, and `git checkout <stash-sha> -- <path>` to pull back specific WIP files (avoid `stash pop` of a mixed snapshot onto a branch that already has the task commits — it conflicts on the task files). **Prevent it:** every implementation-subagent prompt must explicitly forbid `git stash`/`git checkout`/`git reset`/`git rebase`/`git clean`/`git restore` and permit only `git add <named files>` + `git commit`. Isolating the run in a `git worktree` also sidesteps the shared-stack hazard entirely (weigh against this repo's worktree file-tool footgun above).

### A PR merged into a feature branch (not main) doesn't ship, even after that branch was already merged once
**Symptom:** A user-reported bug traced back to a PR that looked "Merged" on GitHub, but `main` still didn't have the fix.
**Root cause:** An earlier PR merged branch `ui-fixes` into `main`. `ui-fixes` kept living after that, and a later PR was opened and merged *into `ui-fixes`*, not `main` — GitHub shows it as "merged" regardless of target branch, easy to misread as "merged into main." Those commits never made it back to `main` through the normal PR flow. A branch that was merged to `main` once is not inherently safe to keep opening PRs against; more unmerged work can keep silently accumulating on it, eventually conflicting with `main`'s independent changes to the same files.
**Fix:** Before treating a "merged" PR as shipped, check its **base branch**, not just merged status: `gh pr view <n> --json baseRefName,mergeCommit` and `git merge-base --is-ancestor <mergeCommit.oid> origin/main`. Either merge each follow-up PR to `main` directly, or delete a feature branch after its first merge to `main` so a second round of work can't silently accumulate off of it. Before cutting a release, spot-check recent PRs the same way (base branch + ancestor check) rather than trusting "Merged" badges at face value.

### GitHub native stacked PRs reject the normal merge and require the `merge-async` endpoint
**Symptom:** `gh pr merge <n> --squash` fails with `GraphQL: This pull request is part of a stack and must be merged using the asynchronous merge REST API`. Falling back to the synchronous REST endpoint `gh api --method PUT repos/<owner>/<repo>/pulls/<n>/merge` also fails: `403 "Merging stacked PRs via this endpoint is not supported. Use the asynchronous merge endpoint instead."`
**Root cause:** This repo has GitHub's **native stacked pull requests** enabled. A PR that is registered as part of a stack (even one whose base is `main` but has children stacked on it) can only be merged through the dedicated async endpoint, which restacks the remainder of the stack as part of the merge. The `gh` CLI (2.97) has no flag for it.
**Fix:** Merge **bottom-up** (the PR whose base is `main` first, then each child), one at a time, via `gh api --method PUT repos/tryplover/Plover/pulls/<n>/merge-async -f merge_method=squash`. It returns `{"status":"pending","details":{"message":"Merge request enqueued.", ...}}` — the merge is asynchronous, so poll `gh pr view <n> --json state -q .state` until it reads `MERGED` before touching the next PR. After each merge, run `git fetch origin --prune`: GitHub auto-retargets the next child PR's base to `main` and force-updates its branch (the restack). Verify `baseRefName==main` and `mergeable==MERGEABLE` on that child before merging it. A PR whose `mergeable` is briefly `UNKNOWN` right after `main` moves just means GitHub is recomputing — re-poll until it resolves. Confirm each PR is CI-green (`gh pr checks <n>`) first; branch protection still applies through the async path.
