# Git Workflow

> Use when naming or scoping a branch, writing or fixing a commit message, picking the gitmoji for a commit, untangling history (rebase versus merge versus squash), or cutting a versioned release — the portable git-convention layer for any repo. Covers gitmoji + Conventional Commits, SemVer tags, branch hygiene, force-push safety and gh pr/release mechanics. NOT the land-it decision and pre-ship checklist (that is `ship`), NOT an isolated checkout before coding (that is `worktrees`), NOT CI/CD release automation (that is `deployment`).

- Skill: `ericrisco/git-workflow` (Agent Skill, multi-file: 5 files)
- Install (CLI): `npx skillmds@latest add ericrisco/git-workflow`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ericrisco/git-workflow/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: ericrisco (https://skillmd.com/u/ericrisco)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ericrisco/git-workflow

---


# git-workflow — the grammar and hygiene of version control

Git history is a message to the next human who reads `git log`, runs `git blame` on a broken line,
or bisects a regression at 2am. That human is usually future-you. **Every rule in this skill exists
to make the next reader's job faster**, not to make this moment cheaper. A repo with legible branch
names, conventional commits, and a clean linear narrative is a repo you can reason about; a repo with
`wip`, `fix stuff`, and force-pushed shared history is one you fight.

This is the **portable convention layer**. It is independent of any SDD phase or CI platform — it is
the grammar that `../ship/SKILL.md`, `../worktrees/SKILL.md`, and `../deployment/SKILL.md` all lean on.
It does not decide *whether to land* the work (that is ship) and it does not automate releases in a
pipeline (that is deployment). It tells you how to name, commit, untangle, and tag — correctly.

## Branch naming

Name a branch from its *intent*, prefixed by its kind, as a kebab-case slug. Keep it short-lived:
hours to days, not weeks. Long branches drift from `main` and turn into merge pain.

| Prefix       | Use for                                      | Why                                              |
|--------------|----------------------------------------------|--------------------------------------------------|
| `feat/`      | a new capability                             | matches the `feat` commit type; signals a MINOR  |
| `fix/`       | a bug fix                                     | matches `fix`; signals a PATCH                    |
| `hotfix/`    | an urgent fix landing straight to production  | flags "skip the slow path" to reviewers          |
| `chore/`     | tooling, deps, config — no product behavior  | keeps non-feature noise out of the feature log   |
| `docs/`      | documentation only                            | reviewers can fast-track, no test gate needed    |
| `refactor/`  | restructure without behavior change           | sets the expectation: tests stay green, no new behavior |

Slug rules: derive it from the issue title or the one-sentence intent, lowercase, dash-separated, no
spaces or `/` inside the slug. Optionally suffix the issue number.

```text
Bad   my-stuff            (kind unknown, intent unknown)
Bad   eric-branch-2       (names the author and a counter, not the work)
Good  feat/oauth-pkce-flow
Good  fix/expired-refresh-token-401
Good  chore/bump-node-22
```

## Commit grammar — gitmoji + Conventional Commits

Write every commit to **Conventional Commits 1.0.0**, opened by a **gitmoji**. The structure:

```text
<gitmoji> type(scope)!: subject

body — what changed and why, wrapped, optional

BREAKING CHANGE: description of the incompatible change
Fixes #123
```

- The **gitmoji is mandatory** and comes first — the intention of the change, readable in one glyph
  when you scan `git log --oneline`. `type` is what tooling reads; the emoji is what humans read.
- `type` is mandatory. `scope` in parentheses is optional. `!` before the colon marks a breaking change.
- Subject: imperative mood ("add", not "added"/"adds"), ≤72 chars, no trailing period.
- Body explains *why*, not *what the diff already shows*. Separate from subject by a blank line.
- Footers go last. `Fixes #123` / `Closes #123` in the body auto-closes that issue when the PR merges.

Type → SemVer effect:

| Type                              | SemVer bump | Notes                                              |
|-----------------------------------|-------------|----------------------------------------------------|
| `feat`                            | MINOR       | a new capability                                   |
| `fix`                             | PATCH       | a bug fix                                           |
| `docs`, `chore`, `refactor`, `test`, `build`, `ci`, `perf`, `style`, `revert` | none | allowed, but no implicit version bump              |
| any type with `!` or a `BREAKING CHANGE:` footer | **MAJOR** | overrides the above regardless of type             |

`BREAKING CHANGE` **must be uppercase** in the footer; the type/scope units are case-insensitive but
write them lowercase by convention.

Type → gitmoji, the everyday set (the full 75-emoji table, and *why* the emoji never replaces the
type, are in **`references/gitmoji.md`**):

| Type       | gitmoji | Type       | gitmoji | Type      | gitmoji |
|------------|---------|------------|---------|-----------|---------|
| `feat`     | ✨      | `refactor` | ♻️      | `build`   | 📦️      |
| `fix`      | 🐛      | `test`     | ✅      | `style`   | 🎨      |
| `docs`     | 📝      | `perf`     | ⚡️      | `revert`  | ⏪️      |
| `chore`    | 🔧      | `ci`       | 👷      | breaking  | 💥      |

Pick by **intention**, not by which file changed, and prefer the specific one: 🚑️ for a production
hotfix, 🩹 for a trivial non-critical fix, 🔥 for a deletion, 🚚 for a rename, ⬆️ for a dep bump,
🔖 for a release commit.

```text
Bad   fix stuff
Bad   updates
Bad   Fixed the login bug.            (past tense, capitalized, trailing period)
Bad   fix(auth): reject expired refresh tokens        (no gitmoji)
Bad   ✨ added a search endpoint                       (gitmoji but no type → no derivable bump)
Good  🐛 fix(auth): reject expired refresh tokens
Good  ✨ feat(api): add /v2/search endpoint with cursor paging
Good  ♻️ refactor(parser): extract token scanner, no behavior change
```

A breaking change, both forms equivalent:

```text
💥 feat(api)!: drop the legacy /v1 search endpoint

BREAKING CHANGE: /v1/search is removed; callers must migrate to /v2/search.
```

If the repo runs a **strict** conventional parser (commitlint, semantic-release) it anchors the type
at position 0 and rejects the emoji prefix. Either widen its `headerPattern` — the config is in
`references/gitmoji.md` — or move the emoji behind the header (`feat(api): ✨ add cursor paging`),
which every parser accepts. Both forms satisfy this convention; dropping the gitmoji does not.

**Authorship is always Eric.** Never add a `Co-Authored-By: Claude` trailer, never a
"Generated with" footer, never any line crediting an AI tool — in a commit *or* a PR body. The work
is Eric's; the agent is a tool, like the compiler.

## History hygiene — rebase, merge, or squash?

Decide by who else has the commits. The lease rule below is non-negotiable.

| Situation                                            | Do this                                              | Why                                                        |
|------------------------------------------------------|------------------------------------------------------|------------------------------------------------------------|
| Private branch, only you have it, want linear history | `git rebase main`, then `git push --force-with-lease`| rebase rewrites hashes; safe because nobody built on them  |
| Branch others have pulled / built on                  | `git merge main` — **never** rebase it               | rebase changes every hash; collaborators' work diverges    |
| Noisy PR (many `wip` commits)                         | squash-merge into one gitmoji + conventional commit            | `main` gets one meaningful entry, not 9 scratch commits    |
| Already pushed, shared, *and* you rewrote it          | **STOP** — coordinate, or `git revert` instead       | force-pushing shared history breaks everyone downstream     |

After a rebase, push with `--force-with-lease`, never bare `--force`:

```bash
git push --force-with-lease   # refuses if the remote moved since you fetched — catches a teammate's push
git push --force              # blindly overwrites — can erase a teammate's commits
```

The interactive cleanup loop (`rebase -i`, `fixup`/`squash`/`reword`/`drop`, `--autosquash`,
the conflict→continue cycle, and recovery via `git reflog`) is a long branchy procedure — see
**`references/interactive-rebase.md`** rather than reaching for it on every commit.

## Releases

Derive the version bump from the commit log, never by guessing. Scan the commits since the last tag:

- any `BREAKING CHANGE:` / `!` → **MAJOR** (`v1.4.2` → `v2.0.0`)
- otherwise any `feat:` → **MINOR** (`v1.4.2` → `v1.5.0`)
- otherwise only `fix:`/others → **PATCH** (`v1.4.2` → `v1.4.3`)

Tag with the `vMAJOR.MINOR.PATCH` form, annotated, then create the release with auto-generated notes:

```bash
git tag -a v2.0.0 -m "v2.0.0"
git push origin v2.0.0
gh release create v2.0.0 --generate-notes              # notes via the GitHub Release Notes API
gh release create v2.0.0 --generate-notes --draft      # stage notes, publish later
gh release create v2.0.0-rc.1 --generate-notes --prerelease
```

GitHub auto-assigns the "latest" label by semver order unless you set it. With release immutability
enabled, **a published release's tag cannot be edited or deleted** — get the version right before you
publish.

```text
Bad   added a feature + a breaking config change, tagged v1.5.0   (breaking change → must be MAJOR)
Good  same changes → v2.0.0, bump derived from the BREAKING CHANGE footer in the log
```

Automating any of this on tag push (a `release.yml` workflow, OIDC to a registry) is **deployment** —
see `../deployment/SKILL.md`. This skill covers the manual/local release act.

## PR mechanics — then hand off to ship

Open the PR with autofilled title/body from the commits, against the right base:

```bash
gh pr create --fill --base main          # title/body from commits; --base falls back to repo default
```

Put `Fixes #123` in the body to link and auto-close the issue on merge. A PR body should let the
reviewer understand the change without reading every line of the diff.

The **decision to land** — direct-merge vs PR vs park, the pre-ship safety checklist, the actual
merge — belongs to `../ship/SKILL.md`. This skill only makes the branch, commits, and PR body clean
enough to hand over. Setting up the isolated checkout *before* you start coding is
`../worktrees/SKILL.md`.

## Anti-patterns

| Anti-pattern                                          | Why it hurts                                              | Instead                                              |
|-------------------------------------------------------|-----------------------------------------------------------|------------------------------------------------------|
| `git push --force` on a shared branch                 | silently erases teammates' commits                        | `--force-with-lease`, or don't rewrite shared history |
| `git commit -m "wip"` / `"fix"` / `"updates"`         | the log carries zero signal for the next reader           | `<gitmoji> type(scope): imperative subject`           |
| Commit message with no gitmoji                         | `git log --oneline` reads as a wall of undifferentiated text | pick the intention's emoji (`references/gitmoji.md`) |
| Mixing unrelated changes in one commit                | can't revert or review one concern in isolation           | one logical change per commit                         |
| Long-lived branch (weeks)                             | diverges from `main`, merge becomes a battle              | short-lived; rebase or merge `main` in often          |
| Hand-computing the semver bump                        | breaking change shipped as a MINOR → broken downstream    | derive the bump from the commit log                   |
| Rebasing a public/shared branch                       | rewrites hashes others built on                           | merge shared branches; rebase only private ones       |
| Committing generated/secret files                     | leaks credentials, bloats history irreversibly            | `.gitignore`; rotate any secret that slipped in       |
| `BREAKING CHANGE` lowercase                            | tooling won't detect it → wrong (too-low) bump            | uppercase `BREAKING CHANGE:` in the footer            |
| PR with no description                                | reviewer reverse-engineers intent from the diff           | `--fill` plus a why, link the issue                   |
| Tagging a release with no notes                       | users can't tell what changed                             | `gh release create --generate-notes`                  |
| `Co-Authored-By: Claude` / "Generated with" footer    | forges authorship onto a tool                             | author is always Eric; no AI attribution              |

## Checklist — before a PR or a release

- [ ] Working tree clean (`git status`), no stray or generated files staged.
- [ ] Branch rebased on / merged with current `main`; no avoidable conflicts.
- [ ] Every commit carries its gitmoji **and** a conventional header; `wip`/scratch commits squashed away.
- [ ] No secrets, no AI-attribution trailers.
- [ ] PR body explains the why and links the issue (`Fixes #`).
- [ ] (Release) version bump **derived from the commit log**, tag is `vX.Y.Z`, annotated.
- [ ] (Release) `gh release create vX.Y.Z --generate-notes`; version confirmed before publishing (immutable once published).

