AlexanderMattTurner

git-workflow

The mechanics of committing, pushing, and resolving merge conflicts in this repo. Activate before you commit or push, when a PR reports a merge conflict or a `dirty` mergeable_state, when a `merge-conflict` label or an auto-resolve bot comment arrives, when you must audit a bot's merge resolution or answer a merge-delta finding thread, when you are about to push work that belongs to an already-open PR, and when you are tempted to re-run the suite locally after a push. The load-bearing rule is that a conflict belongs to the auto-resolve workflow, not to you.

AlexanderMattTurner 68 12 Updated 1w ago
GitHub

Install

npx skillscat add alexandermattturner/agent-glovebox/git-workflow

Install via the SkillsCat registry.

SKILL.md

Git workflow mechanics

Rules that bind before the first command

Use Conventional Commits. Never amend, rebase, squash, force-push, use --no-verify, or bypass a hook. Fix mistakes with a new commit, and resolve conflicts with a merge commit.

A commit that touches CI machinery — a workflow, a landing or queue script, the scheduler — ships its TLA+ answer in the same PR; `.claude/rules/tla-plus.md` owns it.

Push once after the complete unit of work. Before every follow-up push, query the branch PR through the REST API and branch from current origin/main if it merged or closed.

A relocation leaves NO file at the old path, and it lands on its own PR the same day. Git detects a rename only when the old path is gone, so a launcher, a shim or a re-export left behind makes every later edit main makes at that path conflict as ONE hunk spanning the whole file, which the auto-resolve workflow refuses and a human then resolves by hand. Move the body, delete the old path, and give any launcher a NEW name. Then stack the features that use it on the landed move.

Never git stash to compare your tree against HEAD — provision a detached worktree instead (git worktree add --detach <path> HEAD, which needs that destination path). git stash push -u aborts partway on a path the Bash sandbox cannot read (.claude/hooks/vendor/redactor/certifi/cacert.pem) and creates NO entry, so the paired pop takes stash@{0} — whatever foreign entry this clone already holds — into your working tree as conflicted files. Recover with git checkout HEAD -- <the foreign paths>, then delete the .git/rr-cache/ entries that pop recorded (find .git/rr-cache -maxdepth 1 -type d -mmin -20), or rerere silently re-applies "take HEAD" to a later real conflict on those same files.

Preserve at least 200 diagnostic lines. Keep the real command exit status with set -o pipefail or capture $? before printing a redirected log. The shell is zsh, so never read ${PIPESTATUS[0]}; it is empty there and a rejected push reads as exit 0. Brace a shell variable before :.

A NEW implementation of unattended automation needs a live dry-run parity period, and the later swap commit deletes the old one. This covers a new implementation of a live path and nothing else. Deleting a duplicate in favour of a copy already running is not a rewrite and takes no parity period — citing this rule to keep two copies alive is the misreading it most often gets, and it leaves the tree with two live paths instead of one.

Write the commit message

Read the change first — git status, git diff, git diff --cached and git log --oneline -5 in ONE block. Stage by name; never git add -A or git add ., and never stage a credential file. Before you commit a fix to a file outside your PR's goal, run uv run .github/scripts/open-pr-fixes.py <file>, and reuse a matching commit it lists, by stacking or a provisional copy (below), instead of writing your own. When the staged work spans unrelated areas, commit each area separately rather than asking.

The subject is <type>(<optional scope>): <imperative lowercase description>, under 72 characters, with no trailing period. @commitlint/config-conventional is what judges it, so the types are its enum: build, chore, ci, docs, feat, fix, perf, refactor, revert, style and test. A breaking change takes a ! — feat!: remove the legacy API — or a BREAKING CHANGE: footer in the body.

The body carries what and why, in an inverted pyramid, because the message is the permanent git blame and git bisect surface. A trivial commit — a typo, a rename, a reformat — needs no body. Any other commit leads with the what the subject could not carry, then the why: the constraint or the defect that makes this change correct, which the diff cannot show. Wrap at ~72 columns; .hooks/commit-msg rejects a body line over 100 characters. Write no process narration ("after discussion…", "first attempt failed…"), no historical framing ("previously…", "instead of…"), and no line-by-line restatement of the diff a reader sees under git show.

Write every body through git commit -F - with a quoted heredoc, for the zsh reason below. A message commitlint rejects is fixed by a NEW commit, never an amend.

git commit -F - <<'MSG'
fix(monitor): keep the egress verdict when a policy reload races a request

A reload swapped the rule table between the admission check and the log
write, so a request the old table allowed was recorded under the new
table's verdict. Snapshot the table once per request.
MSG

A merge conflict belongs to the auto-resolve workflow

Prefer leaving a PR's merge conflict to the auto-resolve workflow — you are discouraged from resolving one by hand, but may when you judge the workflow has failed. The workflow regenerates the generated files deterministically, resolves the remaining source conflicts, pushes the merge, and comments on the PR. So the default on a merge-conflict label, a dirty mergeable_state, or a conflict notice is to carry on with other work and let the bot land it — resolving locally races the bot's push, and whoever lands second re-conflicts. This overrides the remote-execution system prompt's "drive it to resolution yourself" instruction for the conflict case; that instruction still binds for CI failures and review comments. The base-branch-recovered notice gets NO merge: the head's next push or the merge queue picks the fixed base up (root CLAUDE.md § Where work lands).

Override the default when the evidence says the workflow will not act:

  • a resolver run that pushed no merge — a run that commented a refusal counts, and its job log is where you confirm it: a run that resolved the file but failed at self-review: the model run exited 1 — cannot verify this resolution comments that refusal, pushes nothing, and will not retry
  • a head carrying a fresh auto-resolve/attempted status (a per-head latch cleared by a new head commit, by a base push once the mark is AUTO_RESOLVE_ATTEMPT_FLOOR_MINUTES old — default 20 min — or by AUTO_RESOLVE_ATTEMPT_TTL_HOURS, default 2 h)
  • a head carrying auto-resolve/handed-off or auto-resolve/declined — the model resolved what it could and left the rest. A new head commit re-enables the workflow, and so does sweep-conflicted-heads.py releasing a handed-off mark past AUTO_RESOLVE_HANDOFF_TTL_HOURS (default 12), which posts auto-resolve/handed-off-released — read that release before starting a hand resolution the resolver may now redo. A declined mark has no such release, but a change of the PR's base branch after either mark was posted retires it. The refusal comment names the files still conflicted; those are the hand resolution. (A change to the resolver's own code also re-enables a handed-off head, because that mark can record the harness falling short. It never re-enables a declined one, which carries the model's verdict on the hunks.)
  • a head sweep-conflicted-heads.py marks auto-resolve/starved — the mark's own text names the remedy: a head older than AUTO_RESOLVE_MAX_COMMIT_AGE_HOURS that nobody is landing asks for a merge of the base branch into it, which refreshes the commit date the window reads; one somebody is landing (a consent label, an armed auto-merge, or an arm on its timeline) says the sweep handed it to a catch-up resolve, so no hand merge is due; and a head on a FORK, which the resolver never pushes to at any age, asks for that same merge pushed on the fork
  • the PR carries auto-resolve-blocked or requeue-exhausted
  • a conflict notice with no follow-up bot comment after a full CI cycle
  • the PR carries no _pr_sweep.CONSENT_LABELS member (approved, force-queue, only-queue), no armed auto-merge, AND no auto_merge_enabled on its timeline that nobody took back. resolver_consent_gate.py defers such a candidate and marks its head auto-resolve/consent with a deferred: line naming the remedy, so no bot is coming: hand-resolve it, or get the consent that admits it. A head whose auto-resolve/consent mark is absent is one the gate dispatched, so read the mark rather than the label set. Only a person adding the label or arming auto-merge re-enables a deferred head — a session never writes a consent label (root CLAUDE.md § Deciding without asking).

Before resolving by hand, confirm no resolver run is in flight (GET /actions/workflows/auto-resolve-conflicts.yaml/runs?status=in_progress); say in the PR what evidence you acted on.

Start a hand resolution from the merge the refused run reached, when its comment says it kept one ("The merge this run reached is kept"). Run the commands that comment prints: they download the run's auto-resolve-merge-<pr> artifact and leave your branch mid-merge with the run's files in place. Then fix only what still carries markers or what the refusal names, and audit the rest as you would the bot's own push.

Two conflicts never reach the resolver at all: a WIP DRAFT, and a PR whose base is another open PR's head. auto-resolve-conflicts.yaml's catch-up input names the whole skipped set — WIP drafts, forks, dependency-bot PRs, auto-resolve-blocked PRs and queued PRs. A skipped PR gets no auto-resolve/* mark and no comment, so it sits under a merge-conflict label nothing will clear. A plain draft is NOT in that set: the resolver takes it, so never read the draft field alone as licence to resolve by hand. discover.py drops a stacked child and posts the auto-resolve-stacked-child notice. Either is yours: merge the base branch into the head branch by hand, resolve the conflicts, and push the merge commit. The rule above bars a rebase here as everywhere.

Keep hand-resolution evidence in the merge commit message. In at most 150 words, record the head SHA, run URL when one exists, conflicting paths, the relevant refusal text, the resolution and its validation. The commit preserves evidence when logs expire and the bot rewrites its comment. If several resolutions expose the same unresolved cause, update its existing tracker with links to those commits.

A manual resolution alone does not establish a resolver defect: a skipped draft, stacked child or consent-gated run may be expected behavior. Fix a demonstrated defect under root CLAUDE.md's scope rule, and record the fix on that PR. Do not open a separate evidence issue after each resolution; an upstream defect you cannot fix follows root § What gets an issue.

Measure the conflict before you start. Run git fetch origin main first, then git merge-tree --write-tree --name-only <head> origin/main and count the conflicting files it lists — without --name-only a conflicted path prints one line per stage (up to three), and without the fetch you measure against a stale origin/main. When several conflicted PRs share overlapping files, resolve smallest-conflict-first, one at a time — never resolve two overlapping PRs by hand at the same time. Each one that lands re-conflicts the others through the shared files, so parallel hand resolution guarantees rework; serial resolution lets each later PR absorb the earlier one's content for free.

ONE merge with more than about 10 conflicted files outside .claude/, sandbox-policy/, config/ and bin/glovebox is split across sub-agents the moment the count is known, never resolved file by file in the parent. Serial resolution stalls every other PR the session drives. Do it in this order:

  1. Read both sides' intent first: git log --oneline <merge-base>..<head> and git log --oneline <merge-base>..origin/main over the conflicted paths. Write down each cross-file decision, such as "keep gb_vm_backend_available" or "the session record keeps its backend tag". Every agent prompt carries that list.
  2. Partition the conflicted paths by subsystem (shell code, Python code, tests, docs) so each agent owns the decisions inside its group. Keep every path under .claude/, sandbox-policy/, config/ or bin/glovebox in the parent. Every conflicted path lands in exactly ONE group. Dispatch the groups in ONE turn up to the fleet cap, and start each queued group as a slot frees.
  3. Spawn each agent with isolation: "worktree". In that worktree it runs git checkout --detach <head> && git merge --no-commit origin/main and resolves ONLY its paths. It copies each resolved file under /tmp/claude-worktrees/<pr>-parts/ at its repo-relative path, and lists each path it resolved by deletion in /tmp/claude-worktrees/<pr>-parts/DELETED.
  4. The parent copies the parts into its merge tree and runs git rm on each deleted path. It checks that every conflicted path has a part or a deletion and that no part carries a conflict marker. A missing or marked part STOPS the merge, because staging it records an unresolved file as resolved. The parent then stages the parts, runs node scripts/resolve-generated.mjs --heal, runs the targeted tests below over the assembled tree, and commits.

The parent keeps the cross-file decisions. It uses the wait to drive the rest of its PR set.

A hand resolution is done at merge, not at push. main can advance past your merge's base in the minutes a hand resolution takes to write and test, re-conflicting a PR you just pushed clean. For a PR carrying no consent label, add one sentence to the PR body naming that exposure — arming needs the same explicit merge instruction pr-creation already requires, so name the PR rather than arming it yourself; the exposure detail itself belongs in the PR body, not the report.

Test every touched module after a hand-resolved merge, including a file git merged with zero conflict markers — locally before the push when that run is fast, else on CI's run of the push (root CLAUDE.md § Testing). Before that push, also read git diff --no-renames --name-only -z <merge-base> <branch-parent> | xargs -0 git diff <branch-parent> <merge> --: each hunk there is a change of the branch's own that the merge dropped, unless the base parent's commit explains it. A path the branch deleted shows there as a whole re-added file: the merge reversed the PR, so delete it again. A rename inside code one side relocated wholesale is invisible to git's textual merge and to a static check (ruff, ast.parse): one side deletes the block because it moved elsewhere, the other adds a caller using the old name, and the two edits never touch the same line. Only a real targeted test run catches it.

Never resolve a conflicted file with git checkout --ours or git checkout --theirs. Both rewrite the working file to one stage whole. Both also leave the index unmerged. The file therefore carries no conflict markers, and every later reader takes it for a resolution. A dependency manifest is where it costs most. A version this branch raised reverts to the base value, and the marker scan still reports the file clean. Read both stages instead, with git show :2:<path> (ours) and git show :3:<path> (theirs). Then edit the marked file, or write the side you chose with git show :2:<path> > <path> and stage it with git add <path>. That redirect writes the same bytes the banned command does. Reading both stages first is what makes it safe.

Before redoing a stopped or killed background agent's conflict resolution, check its worktree first. Run git status --porcelain for unmerged paths and git rev-parse -q --verify MERGE_HEAD for the in-progress merge — --porcelain reports the index, never MERGE_HEAD, so it alone cannot tell a staged resolution from an ordinary dirty tree. A merge with zero unmerged paths and a full index but no commit is finished work one commit away from being rescued, not a conflict to resolve again.

Always audit the bot's resolution

The merge-resolution delta is the one channel that can introduce content present in neither parent, and the bot resolves with an LLM. Read the remerge-diff-report comment, or run git -c merge.mergiraf.driver='git merge-file --marker-size=%L -L %X -L %S -L %Y %A %O %B' show --remerge-diff <sha>. Check each hunk against what the branch was doing; the advisory "no suspicious deltas" review is a second opinion, not the verification. The live failure mode is a resolution that keeps one parent's side of a hunk the other parent deliberately changed — a stale test stub, an assertion your fix invalidated, a guard you tightened — which silently reverts the work. The direction that hides is a DELETION the other parent made, because a per-line trace for content present in neither parent sees only what the merge INVENTED: a file the base shrank and the merge restored whole passes that trace, and the restored assertions then contradict the change the merge also took. So read the merge against EACH parent as well — git diff --stat <parent> <sha> for both — and a path the merge restored is the one that shows nothing against the branch parent and the deleted block re-added against the base parent. Push the correction to the PR branch.

Keep the driver override — a bare git show --remerge-diff reads a different tree. --remerge-diff re-runs the merge at display time, so the bare command answers through the merge=mergiraf driver .gitattributes binds and hides what mergiraf resolved. The report is rendered with no driver registered, and the override reproduces it, conflict-marker labels included.

A merge-delta finding thread on your PR is YOURS to settle — it does not wait for the user. The 🔴 <!-- merge-delta-finding --> thread has no automated resolver — only its owner (a human, or the session driving the PR) clears it. Push a corrected resolution and a clean re-review clears it; when the resolution is right, reply with the parent-tracing evidence per flagged hunk (the driver-overriding --remerge-diff command above, git diff <parent>..HEAD -- <file> — name which parent every surviving line came from), then resolve. Never resolve without that reply. Regenerating a lockfile and diffing is not evidence: uv lock preserves the entries already committed, so it reproduces forged bytes faithfully; the hash is checked at uv sync. The pr-creation skill's "Resolve each review thread once you have addressed it" section owns this same reply-then-resolve pairing for every other reviewer thread.

After any merge of main onto the head — the bot's or your own — read the remaining PR diff as a NEW proposal: every hunk must still be necessary for this PR's goal under main's CURRENT design. The delta audit above judges what the merge changed; this judges what the branch still needs. Delete a hunk main has superseded, and adapt the callers to the replacement. A modify/delete conflict is where this costs most: keeping a file main deleted restores a retired mechanism, so carry the feature's behaviour on the replacement instead, and say in the PR body when you keep the file anyway.

Push to the branch the work already lives on

When fixing work that already lives on an open PR's branch, push to that branch rather than opening a second PR on the same lines — subject to the coordination limit below. Check out the head (git fetch origin <branch> && git switch <branch>) and commit there. Branch fresh only for genuinely separate work.

A fix whose only home is an open PR's own branch goes there — a defect its diff created, a reviewer thread or a red check on that PR, a merge-delta finding, a dangling reference only that branch carries. An instruction holding a PR's STATE does not fence its CONTENTS — "do not merge this", "do not mark it ready", "leave it draft" each bound one button, so such a fix still gets pushed there. Root CLAUDE.md § Where work lands sets the limit: do not push to another ACTIVE session's branch without established coordination. The claude-owned label says which branches are live. A live label with no coordination means you hand the fix to that session rather than pushing it, and one session owns each shared repair.

A branch you were given that already carries an UNRELATED open PR gets a STACKED CHILD. That is the default, not a last resort. Waiting for that PR to land stalls the work. Pushing onto its head puts your diff under a consent label a person wrote for a different one. Branch <given-branch>-<topic> and merge the given branch into it, so the child's diff is your work alone. Open the child PR with base set to the given branch, and land the layers bottom-up.

Say three costs in the child's body. The auto-resolve workflow never takes a stacked child — discover.py drops it and posts auto-resolve-stacked-child — so you merge its conflicts by hand. Each layer boots the whole check battery, so the `pr-creation` skill's three-layer cap binds. A parent that closes UNMERGED leaves its commits in the child's diff, because GitHub retargets the child to main: revert them there, or say the child now carries them.

Merge into a PR branch only its own base. Never merge a SIBLING — another open PR's head that is not your base. Its whole diff enters yours, landing yours lands it unreviewed, and each later commit on it conflicts wherever you also edited. Two siblings' changes needed? Restack them into one chain: merge the lower into the upper and set the upper's base to it. One commit needed? Take the provisional copy below.

A PROVISIONAL COPY is the other way to reuse an owner's unlanded change, and it is one commit with one job: git cherry-pick -x <owner's commit> onto your branch. -x writes the source SHA into the message, so git log --grep "cherry picked from" lists every copy your branch carries and nothing else has to track them. Take it when the owner's branch is too far from yours to stack on. Delete the copy at the next merge of the base branch that carries the owner's change — git log --grep "cherry picked from commit <sha>" finds yours, and git merge-base --is-ancestor <sha> origin/main says whether the original has landed. A copy still standing after the original lands is a duplicate implementation, so check for one at every base merge. Say in the PR body which commit you copied and whose PR owns it.

A hook edit and a branch switch are both safe in the primary checkout: session-setup.sh snapshots .claude/hooks, and the monitorlib package monitor.py imports, into .claude/.hookpin-<session id>, and safe-launch.sh runs every hook — and its own body — out of that copy. So an Edit of a hook, or a switch onto a branch whose hooks differ, arms the NEXT session and cannot change the verdict on your next tool call. No gate refuses either one. The pin does not cover a file a hook reads from the tree through ../.. — bin/lib/msg.bash, .github/scripts/render-setup-timing.py, config/session-setup-blocking-warnings.json — and none of those decides a verdict.

The two files the snapshot does NOT cover are .claude/settings.json and .claude/settings.local.json, which the harness re-reads from the checkout. They decide WHICH hooks run, with what matcher and timeout, so a switch onto a branch that changes one, or an edit of one, takes effect on this session's next tool call. Change either in a worktree: umask 002 && git worktree add --detach /tmp/claude-worktrees/<name> HEAD, then git switch -c <branch> inside it.

From a DETACHED worktree, push origin HEAD:refs/heads/<branch>, never -u origin <branch>. git push origin <branch> pushes the local ref of that NAME, not the commit you are on, so a worktree detached with git worktree add --detach — the remedy above — pushes whatever stale commit the main checkout left on that branch. The give-away is a non-fast-forward rejection saying your tip is BEHIND the remote, seconds after you committed on top of it. Name origin as well: given the refspec alone, git reads HEAD:refs/heads/<branch> as an scp-style host:path and dials ssh to a host called HEAD — the give-away is an ssh error naming that host (Could not resolve hostname HEAD where ssh exists, cannot run ssh: No such file or directory where it does not). Check with git rev-parse --abbrev-ref HEAD: an answer of HEAD means detached.

Never move a branch ref another worktree has checked out — git checkout -B <b>, git switch -C <b>/--force-create, and git update-ref refs/heads/<b> all exit 0 there, and they leave the holding worktree's HEAD on a commit its files do not match, which git status reports as a whole tree of staged changes. gate-bash-calls denies these. Do the move inside the holding worktree, remove that worktree first (git worktree remove <path>), or use a name no worktree holds.

After a worktree push, the harness stop hook can report your pushed commits as unpushed. ~/.claude/stop-hook-git-check.sh counts git rev-list origin/<branch>..HEAD in the checkout it runs in, which is the PRIMARY one. Two shapes make that count wrong:

  • the primary checkout still holds the branch where it sat before the worktree took over,
  • the commits reached the remote under another branch's name.

Test the claim in the primary checkout, with wt set to its path:

  • git ls-remote origin <branch> asks the remote itself. No other command here does.
  • git -C "$wt" fetch --prune && git -C "$wt" rev-list HEAD --not --remotes --count re-derives the hook's own count. HEAD is private to each worktree, so the same command run in yours answers about a different branch.
  • Prune first. --not --remotes subtracts every LOCAL remote-tracking ref, so a stale origin/<branch> makes the count answer 0 for a branch the remote no longer holds.
  • A 0 after that prune means nothing is missing.

Never push a redundant branch to satisfy the hook. Never move the primary checkout to satisfy it either — gate-bash-calls refuses that, because the target commit's .claude/settings.json and hooks would take charge of the running session. The hook carries no worktree awareness and no opt-out. Report its count line to the user rather than editing it: the file sits in ~/.claude/, outside every repo, so an edit there is unversioned and binds every later session on the box.

A cherry-picked branch survives the counter-check. The old SHAs stay local forever while their content lands under new ones, so confirm with git patch-id --stable rather than by SHA before you believe anything is missing.

Splitting a change out of a PR inherits the BASE branch's defects, not the PR's fixes. A branch cut from the shared parent to carry one piece of a larger PR starts from the parent's code, so every fix the original PR made — a refusal it softened, a guard it widened — is absent, and the split re-ships the old behaviour under a new number. Cut the split branch from the PR's head and drop what does not belong, or cherry-pick the fixes the split depends on and name them in its body.

Brace the variable before any : — "${sha}:refs/heads/x", never "$sha:refs/heads/x"

The Bash tool's shell is zsh, and zsh applies a :x history modifier after an UNBRACED parameter even inside double quotes. refs starts with r, so "$sha:refs/heads/x" expands to abc123efs/heads/x — the :r is eaten. The push then fails with src refspec abc123efs/heads/x does not match any, which names neither zsh nor the modifier. Braces stop the parse: "${sha}:refs/heads/x" is correct, as is a refspec assembled by printf '%s:refs/heads/%s'.

The trap is the :, not the refspec, so brace every "$var:…" a git argument carries. git show "$sha^$p:tests/x.py" loses the :t and reports ambiguous argument '…^1ests/x.py', naming a path you never typed. The eaten letters include a e g h l p q r s t u, which covers most of what a git path or ref starts with.

The second zsh trap eats a COMMIT MESSAGE: a backtick pair inside git commit -m "…" is a command, not a quote. This commits as an read as in the parser — both quoted words gone, and this repo forbids amending, so the message stands:

git commit -m "an `or` read as `and` in the parser"

The only warning is a command not found line, and the hook output scrolls it away. A dense body carries backticks by default here, so write every body through git commit -F - with a QUOTED heredoc (<<'MSG'), which passes a backtick, a $ and a ! through untouched.

Time every commit and push; complain past ~60 s

Run them so the elapsed time lands in the tool output — start=$SECONDS; git push -u origin <branch>; echo "elapsed=$((SECONDS - start))s" — because the tool result reads identically at 3 s and at 4 minutes, so a stall is otherwise absorbed into "committed and pushed" and the user never learns the hook suite has rotted. Over the threshold, tell the user the command, the elapsed seconds, and the cause you diagnosed; the causes are enumerable, so "git was slow" is never acceptable.

Never append that timing tail to a HEREDOC command — a heredoc followed by ; or | does not parse, and gate-bash-calls then STALLS the session on a permission prompt nobody is watching. git commit -F - <<'MSG'; echo "elapsed=…" yields an ERROR node from tree_sitter_bash, so the gate can place no word, reads every one as a program that might run, stops on $SECONDS, and asks. Leave the heredoc as the whole command and read the tool result's own duration, or write the message to a file and time git commit -F <file>. The same trap catches git commit -F - <<'MSG' | tee log.

  • A commit: prettier/ruff/gitleaks walking an oversized staged set (the per-commit hook runs the lint-staged formatters, the generators whose inputs are staged, and the secret scan).
  • A push: a cold ~/.cache/pre-commit or an install-hooks prewarm still in flight, the pre-push hook running the framework suite plus the fragment gate over a wide merge-base…HEAD range, or network retry/backoff.

A repeat offender is a defect to root-cause, and the fix (warm the cache, narrow the hook's files:, split the commit) belongs in the same change.

The push hook damps a supersede and reports every gate

The hook refuses a push to a branch this clone pushed under 10 minutes ago. The workflows that carry cancel-in-progress: true include the check battery, so the push cancels the round in flight and discards every check that had not reported yet. Read the round to the end, then push every fix at once — the `ci-triage` skill owns that rule. The main session may override with GLOVEBOX_PUSH_SUPERSEDE=1 git push … when the new head is what CI must judge next. A sub-agent may not: gate-bash-calls.mjs refuses it, so hand the refusal back instead. The signal is this clone's own update by push reflog entries, so another machine's push never damps you and a push a gate refused writes no entry.

Both hooks run every gate and name all the failures. A commit or push that trips three gates reports three, so one round of fixes clears it.

scripts/background-lint.sh runs the whole-tree lints — .github/scripts/run-repo-lints.py --blocking, beside pyright and pylint. Run it ONLY to clear a .hooks/pre-push refusal, on its own, after the tree is already green locally — never in the same command as a push, per `development-workflow.md` § Commands, which owns this. background-coverage.sh is the coverage checker for a push that changes a .py, a .claude/hooks/*.mjs or a shell file; read its verdict before you call the PR done. A refusal can come from any language, not only Python: this script is the only local route to that lint class, and the required "Repo-local whole-tree lints" CI job is what reports it otherwise, a round later. The script takes no arguments and always passes --blocking, so the three advisory members print nothing; run uv run --extra dev python .github/scripts/run-repo-lints.py yourself to read their findings. A red there is a red on the head you just pushed, and it records a verdict .hooks/pre-push reads: the next push carrying that code is refused until you fix what the findings name. A finding from the whole-tree lints alone clears itself: when the push carries later commits, the hook re-runs exactly the checks that failed on the tree being pushed, and lets the push through when they pass. A pyright or pylint finding stands until you run the script again, and starting the run does not clear the last verdict — only a run that finishes replaces it — so a push made while the fix is still being measured is still refused. GLOVEBOX_PUSH_SUPERSEDE=1 does not clear that refusal — it damps a different gate — so the fix push takes both flags only when you have judged the findings another way, with GLOVEBOX_PUSH_UNVERIFIED=1.

Push each commit before the next edit

A commit is not delivered until it is pushed. Push it before you start the next edit. Work that arrives while a commit sits unpushed is the NEXT push: a red check, a review comment, a defect you just spotted. None of them is a reason to grow this commit.

Holding it costs two things. CI never judges the code you already finished. And the remote head keeps moving, so the held commit becomes a merge and often a duplicated fix — on #5081 a commit held ~15 minutes came back to a remote 4 commits ahead, one of which had already done the held work a second way.

The damper above delays the PUSH, never the commit. Read the round to the end, keep working while it holds, then push both commits at once.

Background every push, and a commit whenever other work is waiting

The hook suites make these the longest local operations. Use a background Bash call with the timing wrapper above. Every main-session git push takes run_in_background: true, and gate-bash-calls refuses one without it; only a sub-agent pushes in the foreground.

Check the background result before anything that depends on it (a follow-up push, a PR open, a "pushed" claim): a backgrounded failure you never read is a silent lie, and that task result is the ONLY signal the push finished — never re-run one to find out. That bans a re-run whose purpose is DISCOVERING the status. It never bans a NEW push you already know is needed: a result reading non-fast-forward is a finished push with a known remedy, so merge the remote and push again in that same turn. The output file stays EMPTY until the command exits and git status -sb reads ahead 1 throughout, so in-flight and failed look identical from outside; re-running in the foreground races the first call, and the loser prints Everything up-to-date — a success message for work the OTHER call did, evidence you double-ran and never confirmation.

Do not poll it at all — the task-completion notification is the only signal you need, and it always arrives. Banned in every dress: a sleep before the read, a loop over git status, a re-read "just to check", and pgrep. A read is free, so polling hides behind one: the sleep in front of the read is the whole cost, and it buys nothing the notification does not deliver. pgrep is no escape — pgrep -x git cannot tell your push from any other git the tree is running (a hook, another worktree, a second session), so a match proves nothing, and pgrep -f 'git push' matches this session's own claude process, whose cmdline carries a system prompt containing that literal text, so it reports a push in flight forever; the [g]it push bracket trick still matches that plain text.

Never end a TURN with the push unread, and never claim "pushed" before you have read it. Backgrounding buys you the rest of the turn, not a later one: keep working while it runs, then read the result and confirm the remote head before the turn ends — git ls-remote origin refs/heads/<branch>, or gh api repos/{owner}/{repo}/pulls/{n} --jq .head.sha once the PR exists — and quote that SHA. A main-session push stays a background task even when nothing is left to overlap it: wait for its notification, then read it. A SUB-AGENT pushes in the foreground always, since its turn end is its session end. A landed push and a lost one are told apart by nothing but that read.

Reading a push result is one LINE of a turn, never the whole turn. The turn the notification reopens starts with that read and then keeps working: it does the next unfinished item, and it does the remedy first when the result names one. Correct shape: read non-fast-forward, merge origin/<branch>, push again in the background, then go on to the next task item in the same turn. A turn that reads the result and stops is the same idle this section bans, wearing the notification as its excuse.

Post-push verification is discouraged, and you never wait on one

Re-running the suite locally against a merged origin/main buys at most a few hours' notice on one failure class (a convention landing on main after your merge-base) and costs ~15 minutes of wall-clock plus a worktree provision. CI already runs it. The default is to skip it.

If you do start one, it is fire-and-forget: background it and go do other work. Provision its worktree through the setup hook (CLAUDE_PROJECT_DIR="$wt" bash .claude/hooks/session-setup.sh), never a hand-rolled uv sync/pnpm install — a bare uv sync leaves node_modules absent, the .mjs guardrail hooks then fail CLOSED, and ~24 hook/sanitizer tests red with agent-control-plane-core is unavailable, a false red indistinguishable from a real regression. Never sleep on it, never poll it, never treat it as a gate on ending the session — an unread result is an acceptable outcome for THIS run, which records nothing. background-lint is the other case: its verdict is durable and the next push reads it. Read this one only if it lands while you are still working; if it lands red on something your diff caused, fix and push.