git-workflow · git:20260904.e3f681d · 2026-09-04 · sha256 19f54d69b413dfdc

git-workflow git:20260904.e3f681dA

Immutable. This exact content is served forever at /api/v1/blob/19f54d69b413dfdc.

---
model_tier: medium
name: git-workflow
description: "Use when working with Git — branch naming, commit messages, PR creation, rebasing, or the code review process — even when the user says 'push this' or 'merge the branch' without naming Git."
domain: process
scope:
  write: []
  verification_reason: "execution.handler is internal, so this skill spawns no subprocess — writes happen through the agent's declared allowed_tools. No command can prove a scope the skill never executes."
execution:
  type: assisted
  handler: internal
  allowed_tools: ["Bash(git:*)", "Bash(gh:*)"]
workspaces:
  - engineering
packs:
  - engineering-base
---

# git-workflow

## When to use

Use when preparing PRs, finishing branches, or following the team's Git workflow.

Do NOT use when:
- Code writing or review (use `php-coder` or `code-review` skill)
- CI/CD pipeline changes (use `github-ci` skill)

## Live remote state first — never from memory

```
BEFORE ANY MERGE / PUSH / PR / BRANCH ACTION — OR ANY CLAIM OR QUESTION
ABOUT THEIR STATE — QUERY THE LIVE REMOTE. NEVER FROM MEMORY OR
CONVERSATION HISTORY. A PR MAY ALREADY BE MERGED OR CLOSED REMOTELY.
ASKING WHAT `gh pr view` ANSWERS IS A CHEAP QUESTION — CHECK, DON'T ASK.
```

The local branch view and the conversation's memory both go stale the moment
anyone else — a maintainer, a parallel agent, an auto-merge rule — acts on the
remote. Acting or asking on stale state is the recurring failure this section
kills (canonical: asking "shall I merge these 4 PRs?" when all four were already
merged remotely). Run first, **every time**:

```bash
git fetch origin --quiet
gh pr view <number> --json number,state,mergeStateStatus,mergedAt,baseRefName
# state: OPEN | MERGED | CLOSED — act only on the live value
```

- **A state question is self-answering** — never ask the user "is it merged?",
  "is it mergeable?", "did it get pushed?", "is it still open?". `gh pr view` /
  `git fetch` answers it. Asking is a cheap question (per `no-cheap-questions`).
- **`MERGED` / `CLOSED`** → there is nothing to merge or push; report the live
  state and stop — do not attempt the action.
- **Before merging** → re-fetch and re-read `state` + `mergeStateStatus` in the
  **same turn**; never merge on a status seen earlier in the conversation.
- **"Based on main" / "current"** → prove it with
  `npx tsx node_modules/@event4u/agent-config/src/scripts/check_branch_freshness.ts`;
  exit `1` ⇒ the branch is behind and is **not** current — merge the base in,
  regenerate the derived files, then open the PR (see
  [`/create-pr`](../../commands/pr/create.md) § 1b). Exit `0` means only that
  the gate did **not refuse**: read the line. `branch is current` is the pass;
  `NOT VERIFIED` means the base could not be reached and freshness is
  **unknown** — the gate exits `0` there on purpose so an offline push is not
  blocked, which makes reporting it as "current" the one misread it cannot
  catch. Exit `0` also covers the paths with nothing to check — a no-op in CI, a
  detached HEAD, standing on the base itself — and under `--quiet` a genuine
  pass prints nothing, so run it without the flag when you need a verdict to
  read.
  Prefer it over `git rev-list --count HEAD..origin/main`, which is wrong twice
  over: it pins `main` as the base for a branch whose PR may target something
  else, and it reads the local tracking ref — a fetch from earlier in the
  session, which is memory rather than a check. The gate asks the remote and
  resolves the base from the open PR.

## Conventions

→ See guideline `docs/guidelines/php/git.md` for branch naming, commit messages, PR conventions.
→ See `commit-conventions` rule for commit format, types, and scope rules.
→ Use `conventional-commits-writing` skill for generating/reviewing commit messages.

## Procedure: Before opening a PR

1. Quality pipeline + tests — only when `quality.local_auto_run: true` (see [`quality-tools` § Execution policy](../quality-tools/SKILL.md)): type-checker → auto-fixer → linter → type-checker, then the project's test command (detect from manifest: `php artisan test` / `vendor/bin/phpunit` (PHP), `npm test` / `pnpm test` / `vitest` / `jest` (JS-TS), `pytest` (Python), `cargo test` (Rust), `go test ./...` (Go)). Under the default (`false` / missing) skip both — remote CI on the PR is the gate; say so instead of claiming they passed.
2. Rebase onto `main`.
3. Fill in PR template completely.

## Procedure: Finish a branch

When implementation is complete and all tests pass:

```
Work complete. What would you like to do?

1. Push and create a Pull Request
2. Keep the branch as-is (I'll handle it later)
3. Discard this work
```

### Option 1: Push and create PR

1. Run quality pipeline + tests (only when `quality.local_auto_run: true`; default `false` → skip, remote CI is the gate).
2. **`task push-ready`** — fetch → integrate the base SET → regenerate → verify
   → re-check freshness. Not optional housekeeping: see § A push closes its own
   loop below. The pre-push hook refuses a stale push, so skipping this buys the
   refusal rather than avoiding the work.
3. `git push -u origin <branch>`.
4. `gh pr create` using PR template.
5. **Settle it** — `./scripts-run src/scripts/ci_settle <pr>`. The turn is not
   over at step 4.

## A push closes its own loop

```
A PUSH IS NOT A DELIVERY. THE EVIDENCE FOR A PUSH IS THE CI VERDICT,
NEVER THE PUSH'S OWN EXIT CODE.
BEHIND THE BASE → INTEGRATE BEFORE PUSHING, NEVER AFTER THE PR IS RED.
RED AFTER PUSHING → FIX IT IN THE SAME TURN, OR SAY PLAINLY THAT YOU DID NOT.
NEVER HAND THE USER A RED PR WITH ITS CAUSE NAMED AND UNFIXED.
```

Two halves, failing differently, over the 30 sessions and 50 PRs before
2026-09-04. **Stale base**: 25 of 50 PRs carried a `Merge branch 'main' into …`
commit (52 in total), and the three most-failing workflows are the base-relative
ones — a branch pushed behind its base was verified against a base it no longer
merges into. **Unsettled push**: 22 of 30 sessions ran `gh run view
--log-failed`, and 20 of 50 PRs carried a follow-up `fix(ci|gates|budget)`
commit. Only 19 of 50 landed with neither. Each half now has a deterministic
carrier — the pre-push hook refuses a verified-behind branch and points at
`task push-ready`; the `push-settle` PostToolUse concern fires the moment git
reports a ref advanced. Neither replaces the discipline: the hook is skippable
with `AGENT_CONFIG_SKIP_PREPUSH_FRESHNESS=1` for a genuine WIP push, and the
settle reminder is advisory, because leaving a push deliberately unsettled is
legitimate — **ending the turn silently on one is not**.

`ci_settle` non-zero → read only the failing part
(`gh run view --job <id> --log-failed | grep -E '×|FAIL|Error'`), fix, push
again; the author of the red is irrelevant (`fix-what-you-see`). Three failed
attempts on one target → stop and surface them (`autonomous-execution` N=3).
Exit `2` is **not** a verdict — the wait timed out or the API could not be read.
Counts, carrier limits and the honest cost:
[`references/push-closes-its-loop.md`](references/push-closes-its-loop.md).

## PR template

The project uses `.github/pull_request_template.md`:
1. Jira ticket link (badge)
2. Description — what and why
3. Type of change
4. Checklist (docs, rebase, quality, review, tests, QA)
5. Links + screenshots

## Default branch

- `main` is default/production branch.
- Merge strategy: merge commits (not squash).

## Procedure: Safe squash-after-push

Use ONLY when the user explicitly authorized a squash on a branch that
is already on origin. The whole sequence runs in **one turn** — never
end the session between rewrite and push.

Trigger context: `git-history-discipline` rule routed here.

### 1. Snapshot before touching anything

```bash
BRANCH=$(git branch --show-current)
DATE=$(date +%F)
git fetch origin
git tag "safe-squash-pre/${BRANCH}/${DATE}" HEAD
git tag "safe-squash-origin/${BRANCH}/${DATE}" "@{u}"
```

Two tags = two recoveries (local tip + origin tip). Do not skip the
tags — `git reflog` is TTL-bounded and unreliable across sessions.

### 2. Verify aligned starting state

```bash
git rev-list --left-right --count HEAD...@{u}
```

- `0  0` → aligned, proceed.
- `N  0` (local ahead) → unpushed work, proceed.
- `0  N` (origin ahead) → `git pull --ff-only` first, then re-check.
- `M  N` (both non-zero) → **divergent**. Abandon the squash and run
  § Divergent-State Recovery below.

### 3. Perform the squash

Default — soft-reset path (single token-cheap rewrite):

```bash
git reset --soft "$(git merge-base HEAD <base>)"
git commit -m "<conventional commit message>"
```

Interactive rebase only when the user wants per-commit control — it
replays derived files (`dist/agent-src/`, router projections)
per commit and conflicts on every replay.

### 4. Re-push in the SAME turn

```bash
FETCHED_SHA=$(git rev-parse "@{u}")
git push --force-with-lease="${BRANCH}:${FETCHED_SHA}" origin "${BRANCH}"
git fetch origin
[ "$(git rev-parse HEAD)" = "$(git rev-parse @{u})" ] \
  && echo "OK: origin matches HEAD" \
  || echo "MISMATCH — do not end session"
```

If the push fails (pre-push hook, network, token budget):
- Fix the underlying cause **now**.
- Re-push immediately.
- Do not commit new work on top of the squashed-but-unpushed tip.
- Do not end the session until `HEAD == @{u}`.

### 5. Hand off only with verified parity

Report exactly:
- pre-squash tip SHA (from step 1)
- pre-squash tag name (for recovery)
- post-squash tip SHA == origin SHA (verified in step 4)
- PR number, if any, and confirm it picked up the new tip

## Procedure: Divergent-State Recovery

Fires when `git rev-list --left-right --count HEAD...@{u}` shows
**both** sides non-zero on the current branch.

### 1. Stop. Do not pull.

A blind `git pull --rebase` here replays remote commits on top of a
local history that may already represent the same work in a different
shape — guaranteed conflict storm in derived files, possible
double-application of the same change. This is the documented failure
mode behind `git-history-discipline`.

### 2. Tag both sides immediately

```bash
TS=$(date +%FT%H%M)
git tag "diverged-local/${TS}" HEAD
git tag "diverged-origin/${TS}" "@{u}"
```

### 3. Diagnose: which side is the correct future?

```bash
git log --oneline @{u}..HEAD   # local-only commits
git log --oneline HEAD..@{u}   # origin-only commits
git diff @{u}..HEAD --stat     # shape of local-ahead work
```

Decision matrix:

| Pattern | Future | Action |
|---|---|---|
| Local has the same logical work as origin, just reshaped (squash/rebase) | **Local** | After PR-review check (step 4), `git push --force-with-lease=<branch>:<origin-sha>` |
| Origin has commits local does not reflect (another contributor pushed) | **Origin** | Tag any local-ahead work for cherry-pick, then `git reset --hard @{u}` |
| Both sides have genuine independent work | **ask user** | Never decide silently — surface the two commit lists and let the user pick |

### 4. PR review-comment check (mandatory before any force-push)

If a PR is open on this branch:
```bash
gh pr view --json reviews,comments
# or via GitHub API: /repos/<owner>/<repo>/pulls/<num>/{reviews,comments}
```

If review comments are anchored to commits that the force-push will
erase → STOP, ask the user how to preserve them. A force-push that
destroys live review feedback is unrecoverable from the agent side.

### 5. Recover or proceed

Use the tags from step 2 to restore either side if step 4 surfaces a
problem. After resolution, verify `HEAD == @{u}` and report both
SHAs plus the tags created.

## Hard prohibitions on a pushed branch

- No `git pull --rebase` after detecting divergent state.
- No `git push --force` without `--force-with-lease=<branch>:<sha>`.
- No squash-then-end-session — the push must complete in the same turn.
- No reflog-only recovery — always tag the state explicitly first.

## Shared-branch & inherited commits — ask-before-drop protocol

Depth for the [`git-history-discipline`](../../rules/git-history-discipline.md)
Iron Law on inherited & shared-branch commits (migrated here per P4 of
`road-to-kernel-and-router.md`).

The user often works in parallel with the agent, and multiple agents may
share one PR branch. A commit that looks "unrelated" or "stray" may be
deliberate in-flight work the user expects to keep. Reseating a branch onto a
different base, `git reset --hard`-ing away inherited commits, force-pushing
over a branch you did not create, or branching from a base with unexpected
commits and then "cleaning" them out all **silently discard work** — the exact
failure that law prevents.

Before ANY of these, STOP and ask (one numbered-options prompt per
[`user-interaction`](../../rules/user-interaction.md)):

- reseating a branch's base (`git rebase --onto`, `git reset --hard <other-base>`)
  in a way that drops commits already on the branch;
- excluding / not-carrying-forward commits that were on the branch when you
  started this session;
- force-pushing (or `push <local>:<remote>`-replacing) a branch that carries
  commits you did not author;
- branching from a base with unexpected commits, then resetting them away.

**Preserve-first is necessary but not sufficient.** Even when you keep the
commits reachable (a save-branch / tag), you still **ask before** the branch
the user sees loses them — "I preserved them locally" is not a substitute for
the question, because the user may be mid-edit on the shared branch and a
force-push would clobber their in-flight work regardless of your local backup.

## Two protective stops (for the protocol phase)

1. **Pre-rewrite stop.** Before any squash / amend / rebase on a branch that is on origin: `git fetch && git rev-list --left-right --count HEAD...@{u}`. If **either** side is non-zero — STOP and run § Divergent-State Recovery. A blind `git pull --rebase` in this state is the documented failure mode. (§ Safe squash-after-push steps 1–2 implement this stop.)

2. **Post-rewrite stop.** After the rewrite, push in the **same turn** with `--force-with-lease=<branch>:<fetched-sha>` and verify `git rev-parse origin/<branch>` equals `git rev-parse HEAD`. If the push fails (hook, network, token budget) — fix the cause and re-push **before** ending the session, committing new work, or handing off. (§ Safe squash-after-push step 4 implements this stop.)

If either stop fires and resolution is not immediate → tag the state (`git tag local-rewritten-tip-<ISO-date>`) and hand control back to the user. Do not let a new session inherit a dirty divergence.

## Equivalents that are also forbidden by default

- `git rebase -i` (interactive)
- `git rebase --autosquash`
- `git commit --fixup` / `--squash` (helpers that feed autosquash)
- `git commit --amend` on already-pushed commits
- `git push --force` / `--force-with-lease` (unless paired with the protocol)
- `git reset --hard` past unpushed work the user might want
- Squash-merge of a PR via API or CLI when the user has not picked the merge strategy
- Cherry-pick rewriting that drops or reorders commits

`--amend` on the *current local* commit before the first push is the narrow exception (treated as continuing to compose the commit, not rewriting history).

## Amend-after-hook-failure trap (data-loss)

When a **pre-commit hook fails, the commit did NOT happen** — no new commit
object was created. A reflexive `git commit --amend` at that point does not
"retry the commit"; it rewrites the **previous, already-good** commit,
destroying that work. This is the one place the narrow `--amend` exception
above turns into data loss.

Recovery — never amend after a hook failure:

1. Read the hook output and **fix the cause** (the lint/test/format failure).
2. **Re-stage** the fix (`git add`).
3. Create a **NEW commit** (`git commit`, not `--amend`) — the prior commit was
   never overwritten and must stay intact.

(Migrated here from `git-history-discipline` — recovery now lives next to the
mechanism it protects.)

## Why history discipline exists

Interactive rebase + fixup loops generate disproportionate token cost on every iteration: re-running CI per replayed commit, resolving the same content conflict in two derived files (`dist/router.json`, `.windsurfrules`), losing the working tree to a stash that silently re-introduces older state. A single conflict can burn the budget of an entire feature.

A previous session squashed a pushed branch, the push hook failed at the token boundary, the session ended — and the next session saw local and origin pointing at different SHAs for the same logical work. A blind `git pull --rebase` cascaded into conflicts across every derived file. Recovery required forensic SHA-archaeology. The pre/post-rewrite stops make that sequence structurally impossible.

## When you'd be tempted

- "I want commit 3 to come before commit 2 because the topic flows better." → don't. Reviewers read the PR diff.
- "There are two `chore: regenerate` commits, ugly." → don't. They are honest checkpoints.
- "A linter caught an issue in commit 2 — let me fold the fix in." → don't. Add `fix(scope): …` on top.
- "I want to drop the WIP commit before pushing." → ask the user first.
- "Squash-merge when I open the PR will clean it anyway." → also true, also irrelevant — let the merge strategy do that work, not you.
- "My branch inherited some unrelated commits — I'll reseat it on `origin/main` so my PR is clean." → **don't, ask first.** They may be the user's parallel work or another agent's. Preserve them and ask which base the user wants.
- "The remote branch has commits I didn't author and no PR — I'll just force-push over it." → don't. No-PR is not no-owner; ask before replacing a branch you did not create.

## Output format

1. Commits following conventional commit format
2. PR description with structured sections (if creating PR)

## Gotcha

- Never commit/push/merge without explicit user permission.
- Keep subject line under 72 chars.
- Don't rebase shared branches.
- `git stash` can lose work — prefer WIP commits.

## Do NOT

- Do NOT commit directly to `main`.
- Do NOT push without running quality tools first.
- Do NOT force-push to shared branches.

## Auto-trigger keywords

- Git workflow
- branch naming
- commit message
- PR convention