Imported from tryplover/Plover (
.claude/skills/plover-git-safety/SKILL.md). Install upstream withnpx skills add tryplover/Plover --skill plover-git-safety. Copyright stays with the author.
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-testids 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:
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.