Git workflow mechanics
SkillAI & modelsThe 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.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Git workflow mechanics skill
What this skill tells your AI
The instructions your AI receives, as published by alexandermattturner/agent-glovebox in .claude/skills/git-workflow/SKILL.md and read by ahel’s review.
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. 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, review comments, and the base-branch-recovered notice.
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 resolutioncomments that refusal, pushes nothing, and will not retry - a head carrying a fresh
auto-resolve/attemptedstatus (a per-head latch cleared by a new head commit, by a base push once the mark isAUTO_RESOLVE_ATTEMPT_FLOOR_MINUTESold — default 20 min — or byAUTO_RESOLVE_ATTEMPT_TTL_HOURS, default 2 h) - a head carrying
auto-resolve/handed-offorauto-resolve/declined— the model resolved what it could and left the rest. A new head commit re-enables the workflow, and so doessweep-conflicted-heads.pyreleasing ahanded-offmark pastAUTO_RESOLVE_HANDOFF_TTL_HOURS(default 12), which postsauto-resolve/handed-off-released— read that release before starting a hand resolution the resolver may now redo. Adeclinedmark has no such exit. The refusal comment names the files still conflicted; those are the hand resolution. (A change to the resolver's own code also re-enables ahanded-offhead, because that mark can record the harness falling short. It never re-enables adeclinedone, which carries the model's verdict on the hunks.) - a head
sweep-conflicted-heads.pymarksauto-resolve/starved— no dispatch will ever take it, and the mark's own text names the remedy: a head older thanAUTO_RESOLVE_MAX_COMMIT_AGE_HOURSasks for a merge of the base branch into it, which refreshes the commit date the window reads, 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-blockedorrequeue-exhausted - a conflict notice with no follow-up bot comment after a full CI cycle
- the PR carries no
_pr_sweep.CONSENT_LABELSmember (approved,force-queue,only-queue) and no armed auto-merge.resolver_consent_gate.pydefers such a candidate silently, so no bot is coming: hand-resolve it, or get the consent that admits it.
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.
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.
Every hand resolution is a resolver DEFECT, so fix what you can reach and RECORD the run's evidence before the turn ends. The workflow is built to land every conflict this repo produces, so a refusal, a declined or handed-off mark, or a land job that pushed nothing is a case it could not. Fix it first, on a PR this session already has open — the root CLAUDE.md no-filing bar binds here as everywhere. What you still owe either way is the EVIDENCE, and the resolver-defect evidence case in root CLAUDE.md § What gets an issue is what lets an issue hold it: the run's log ages out, and the sticky auto-resolve comment is one per PR, so the next successful run overwrites the refusal text with no other record left. File it whether or not you fixed it, naming the fix when there is one, and close it with the PR that lands. Write it as a prompt a fresh session can execute, titled for the CAUSE rather than the PR, carrying:
- the PR number, the head SHA, and the workflow run URL
- the conflicting paths and the refusal text verbatim
- the resolution you wrote, and why the resolver could not have written it
- the question to answer: which resolver change lands this class automatically next time
Search the open issues for the same cause first and edit that one instead of opening a second, so the count tracks causes and not conflicts.
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.
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 and before you push it, including a file git merged with zero conflict markers. 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 hook edit and a branch switch are both safe in the primary checkout: session-setup.sh snapshots .claude/hooks 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 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 022 && 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.
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/gitleakswalking an oversized staged set (the per-commit hook runs only lint-staged formatters + the secret scan). - A push: a cold
~/.cache/pre-commitor aninstall-hooksprewarm still in flight, thepre-pushhook running the framework suite plus the fragment gate over a widemerge-base…HEADrange, 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. Seven workflows carry cancel-in-progress: true, 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. Override with GB_PUSH_SUPERSEDE=1 git push … when the new head is what CI must judge next. 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.
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 63
- Forks
- 11
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
git-workflow-alexandermattturner- Source
- github.com/alexandermattturner/agent-glovebox