
Git Workflow
- 509 installs
- 37 repo stars
- Updated August 4, 2026
- netresearch/git-workflow-skill
git-workflow is a Claude Code skill that enforces team branching, commit, rebase, and pull-request conventions so agents merge safely without breaking history or skipping review gates.
About
git-workflow is a netresearch/git-workflow-skill agent skill codifying expert Git patterns for branching strategies, Conventional Commits, pull request workflows, and CI/CD integration. The skill bundles five reference documents covering branching models, commit conventions, pull request processes, CI/CD automation, and advanced Git operations, plus a /pr-finish slash command for rebasing, resolving review threads, and merging. It supports Git Flow, GitHub Flow, and trunk-based development with semantic versioning, signed commits, branch protection, and GitHub Actions or GitLab CI patterns. Developers reach for git-workflow when agents need standardized merge strategies, atomic Conventional Commits, or PR templates instead of ad hoc git commands. The repository also ships git hook templates and a verify-harness.sh consistency checker for workflow validation.
- Branch naming conventions
- Atomic commit guidance
- Rebase and merge workflows
- Conflict resolution patterns
Git Workflow by the numbers
- 509 all-time installs (skills.sh)
- +32 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #106 of 733 Git & Pull Requests skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/netresearch/git-workflow-skill --skill git-workflowAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 509 |
|---|---|
| repo stars | ★ 37 |
| Last updated | August 4, 2026 |
| Repository | netresearch/git-workflow-skill ↗ |
How do agents follow team Git and PR conventions?
Enforce team branching, commit, rebase, and pull-request conventions so agents merge safely without breaking history or skipping review gates.
Who is it for?
Development teams onboarding AI agents to Git Flow or GitHub Flow with Conventional Commits, signed merges, and CI-gated pull requests.
Skip if: Solo experiments without branching standards or repositories that do not use pull requests, code review, or CI merge gates.
When should I use this skill?
A developer asks to create a feature branch, write Conventional Commits, open or finish a PR, resolve merge conflicts, or integrate Git with CI/CD safely.
What you get
Conventional Commits, branch-aligned PRs, resolved review threads, CI-verified merges, and documented merge strategy decisions.
- Conventional Commit messages
- PR descriptions
- merge-ready branches
By the numbers
- Bundles 5 Git workflow reference documents plus a /pr-finish slash command
- Covers Git Flow, GitHub Flow, and trunk-based branching models
Files
Git Workflow Skill
Expert patterns for Git: branching, commits, collaboration, CI/CD.
Critical Rules (Non-Negotiable)
1. No direct push to main — always open a PR. 2. No merge before all threads resolved — see references/pull-request-workflow.md. 3. No squash unless asked — preserves atomic commits, signatures, bisection. 4. No "tested/verified/working" without pasted command output — else say so. 5. No edits to installed skill/plugin cache paths (~/.claude/skills/, ~/.claude/plugins/cache/, **/.bare/**) — always the repo worktree, verified by pwd. 6. Force-push only with `--force-with-lease` — never plain --force. 7. Commit before rebase — add → commit → fetch → rebase → push. Dirty tree aborts rebase.
See references/pull-request-workflow.md for merge-gate and atomic-commit patterns.
Reference Files
Load references on demand:
| Reference | Content Triggers |
|---|---|
references/branching-strategies.md | Branching model, Git Flow, GitHub Flow, trunk-based, branch protection |
references/commit-conventions.md | Commit messages, conventional commits, DCO sign-off, semantic versioning, commitlint |
references/pull-request-workflow.md | PR create/review/merge, thread resolution, merge strategies, CODEOWNERS, signed commits + rebase |
references/ci-cd-integration.md | GitHub Actions, GitLab CI, semantic release, deployment |
references/advanced-git.md | Rebase, cherry-pick, bisect, stash, worktrees, reflog, submodules, recovery |
references/github-releases.md | Release management, immutable releases, --latest=false, multi-branch |
references/git-hooks-setup.md | Hook frameworks, detection, recommended hooks per stage |
references/claude-code-hooks.md | Claude Code settings.json hooks — merge gate, cache-path rejection, auto-lint |
references/code-quality-tools.md | shellcheck, shfmt, git-absorb, difftastic |
references/merge-gate-watcher.md | Merge-driver loop, hard/soft check taxonomy, rerun stale-SHA, review-bot rounds |
references/spec-cleanup.md | Keep planning artifacts off the base branch; guard + capture-to-ADR |
Conventional Commits
<type>[scope]: <description>Types: feat (MINOR), fix (PATCH), docs, style, refactor, perf, test, build, ci, chore, revert
Breaking change: Add ! after type or BREAKING CHANGE: in footer.
Branch Naming
feature/TICKET-123-description
fix/TICKET-456-bug-name
release/1.2.0
hotfix/1.2.1-security-patchHook Detection
Detect hooks before first commit:
ls lefthook.yml .lefthook.yml captainhook.json .pre-commit-config.yaml .husky/pre-commit 2>/dev/null || echo "No hooks"Install: lefthook install | composer install | npm install | pre-commit install
Critical Release Rules
1. Immutable releases: deleted releases block tag reuse; bump version. 2. Multi-branch releases: Use --latest=false from non-default branches. 3. Pre-release: Version bumped, CI green, CHANGELOG updated, git pull BEFORE gh release create.
PR Merge Requirements
Before merging: threads resolved, CI green (incl. annotations), rebased, signed. Rebase-only + signed: git merge --ff-only.
Verification
./scripts/verify-git-workflow.sh /path/to/repository---
Contributing: <https://github.com/netresearch/git-workflow-skill>
# Checkpoints for git-workflow skill
# Focuses on git repository hygiene and workflow best practices
version: 1
skill_id: git-workflow
mechanical:
# === GITIGNORE ===
- id: GW-01
type: file_exists
target: .gitignore
severity: error
desc: ".gitignore must exist to prevent committing unwanted files"
- id: GW-02
type: contains
target: .gitignore
pattern: ".env"
severity: warning
desc: ".gitignore should exclude .env files"
- id: GW-03
type: contains
target: .gitignore
pattern: "vendor"
severity: warning
desc: ".gitignore should exclude vendor directory"
- id: GW-04
type: contains
target: .gitignore
pattern: "node_modules"
severity: info
desc: ".gitignore should exclude node_modules if using npm"
# === PR TEMPLATE ===
- id: GW-05
type: file_exists
target: "{.github/PULL_REQUEST_TEMPLATE.md,.github/pull_request_template.md}"
severity: warning
desc: "PR template should exist for consistent pull requests"
# === CODEOWNERS ===
# GitHub looks for CODEOWNERS in this order: .github/CODEOWNERS, root,
# docs/CODEOWNERS. Either of the first two satisfies the requirement;
# `.github/CODEOWNERS` is the Netresearch standard. The previous GW-07
# ("root CODEOWNERS as fallback") was redundant info noise — collapsed
# into GW-06's brace-target.
- id: GW-06
type: file_exists
target: "{.github/CODEOWNERS,CODEOWNERS,docs/CODEOWNERS}"
severity: warning
desc: >-
CODEOWNERS should exist for automatic review assignments.
Netresearch standard: .github/CODEOWNERS (GitHub's primary lookup
location). Root and docs/ are fallbacks per GitHub docs.
# === BRANCH PROTECTION INDICATORS ===
- id: GW-08
type: gh_api
endpoint: repos/{owner}/{repo}/branches/main/protection
json_path: ".required_status_checks"
severity: warning
desc: "Main branch should have status check protection enabled"
- id: GW-09
type: gh_api
endpoint: repos/{owner}/{repo}/branches/main/protection
json_path: ".required_pull_request_reviews"
severity: warning
desc: "Main branch should require PR reviews before merging"
- id: GW-10
type: gh_api
endpoint: repos/{owner}/{repo}/branches/main/protection
json_path: ".enforce_admins.enabled"
severity: info
desc: >-
Branch protection may apply to admins too. Org default at Netresearch
leaves this off so admins keep bypass for emergency response; tighten
to true only if your policy requires admin enforcement.
# === SIGNED COMMITS REQUIREMENT ===
- id: GW-11
type: gh_api
endpoint: repos/{owner}/{repo}/branches/main/protection
json_path: ".required_signatures.enabled"
severity: info
desc: "Main branch should require signed commits"
# === EDITORCONFIG ===
- id: GW-12
type: file_exists
target: .editorconfig
severity: info
desc: ".editorconfig should exist for consistent formatting across editors"
# === CONTRIBUTING GUIDE ===
- id: GW-13
type: file_exists
target: CONTRIBUTING.md
severity: info
desc: "CONTRIBUTING.md should exist with workflow guidelines"
# === GIT HOOKS ===
# Accept all common git-hook framework locations including the Netresearch
# standard `Build/captainhook.json` (configured via composer.json
# extra.captainhook.config — the official captainhook/hook-installer
# plugin pattern). Same brace target as TT-71 in typo3-testing-skill.
- id: GW-14
type: file_exists
target: "{Build/captainhook.json,captainhook.json,.captainhook/captainhook.json,lefthook.yml,.lefthook.yml,.husky,.pre-commit-config.yaml}"
severity: warning
desc: >-
A git hook framework config should exist. Netresearch standard for
TYPO3 extensions: Build/captainhook.json (declared in composer.json
extra.captainhook.config). Also accepts root captainhook.json,
lefthook, husky, pre-commit.
- id: GW-14a
type: contains
target: composer.json
pattern: "captainhook/hook-installer"
severity: info
desc: >-
If captainhook/hook-installer is in composer.json, document the
git-worktree workaround (composer install --no-plugins) in README —
see references/git-hooks-setup.md 'CaptainHook + git worktrees'
# === UNRELEASED COMMITS ===
- id: GW-15
type: command
pattern: 'tag=$(git describe --tags --abbrev=0 2>/dev/null); [ -z "$tag" ] || test "$(git rev-list ${tag}..HEAD --count)" -le 20'
severity: warning
desc: "Main branch should not accumulate >20 unreleased commits since last tag"
# === INTERMEDIATE PLANNING ARTIFACTS ===
- id: GW-16
type: command
pattern: 'test -z "$(git ls-files -- docs/superpowers/ claudedocs/ docs/working/ 2>/dev/null)"'
severity: warning
desc: >-
Intermediate planning artifacts (superpowers specs/plans, claudedocs,
working notes) should not be tracked on the base branch — convert durable
decisions to an ADR and remove the raw files. See
references/spec-cleanup.md and spec-cleanup-guard.sh.
llm_reviews:
# === CONVENTIONAL COMMITS ===
- id: GW-20
domain: git-workflow
prompt: |
Analyze the recent commit history (last 20 commits) for conventional commit format usage.
Conventional commits follow the pattern: <type>(<scope>): <description>
Types include: feat, fix, docs, style, refactor, perf, test, chore, ci, build
Check:
1. What percentage of commits follow conventional commit format?
2. Are commit messages descriptive (not just "fix", "update", etc.)?
3. Are there any empty or single-word commit messages?
Use `git log --oneline -20` to analyze.
Report:
- Pass if >80% follow conventional format
- Warn if 50-80% follow format
- Fail if <50% follow format
severity: warning
desc: "Recent commits should follow conventional commit format"
- id: GW-21
domain: git-workflow
prompt: |
Check if the repository has signed commits.
Use `git log --show-signature -5` to check recent commits for GPG/SSH signatures.
Report:
- How many of the last 5 commits are signed?
- Are all commits from the same author signed?
- Pass if all commits are signed, warn if some, fail if none
severity: info
desc: "Commits should be GPG/SSH signed for authenticity"
- id: GW-22
domain: git-workflow
prompt: |
Analyze commit message quality in the last 10 commits.
Good commit messages:
- Start with a verb in imperative mood (Add, Fix, Update, etc.)
- Are 50-72 characters for the subject line
- Explain "why" not just "what"
- Reference issues when applicable (Fixes #123, Closes #456)
Use `git log --format="%s" -10` to get subject lines.
Report on overall quality and specific improvements needed.
severity: info
desc: "Commit messages should be clear and follow best practices"
- id: GW-23
domain: git-workflow
prompt: |
Review the CODEOWNERS file if it exists.
Check:
1. Does it cover critical paths (src/, .github/, etc.)?
2. Are the owners valid GitHub usernames or teams?
3. Is the syntax correct (path followed by @owner)?
4. Are there any overly broad patterns like "* @owner"?
Report issues or confirm proper setup.
severity: warning
desc: "CODEOWNERS should properly cover critical code paths"
- id: GW-24
domain: git-workflow
prompt: |
Review the PR template (.github/PULL_REQUEST_TEMPLATE.md) if it exists.
A good PR template should include:
- Description/Summary section
- Type of change (feature, fix, breaking, etc.)
- Testing checklist
- Related issues/tickets
- Review guidelines or checklist
Report on completeness and suggest improvements.
severity: info
desc: "PR template should guide contributors effectively"
- id: GW-25
domain: git-workflow
prompt: |
Check if the repository has git hooks configured with a hook framework
(lefthook, captainhook, husky, or pre-commit).
Look for config files: lefthook.yml, .lefthook.yml, captainhook.json,
.husky/ directory, .pre-commit-config.yaml.
If hooks are configured, verify they cover at minimum:
1. pre-commit stage (formatting, linting, or validation)
2. commit-msg stage (message format validation)
Report:
- Pass if both pre-commit and commit-msg hooks are configured
- Warn if only one stage is covered
- Fail if no hook framework is found
severity: warning
desc: "Git hooks should cover pre-commit and commit-msg stages at minimum"
# === RELEASE HYGIENE ===
- id: GW-26
domain: release
severity: warning
desc: "Feature and fix commits on main should be released promptly"
prompt: |
Audit unreleased commits on main:
1. Run `git describe --tags --abbrev=0 2>/dev/null` to find latest tag.
If git describe fails (no tags), the check passes — skip remaining steps.
2. Run `git log <tag>..HEAD --oneline` to see unreleased commits
3. How many days since the last tag? (use `git log -1 --format=%ci <tag>`)
4. Do unreleased commits contain feat: or fix: types?
5. Flag as warning if feat/fix unreleased >7 days, error if >30 days
- id: GW-27
domain: release
severity: warning
desc: "GitHub releases should have non-duplicated content and meaningful descriptions"
prompt: |
Audit the 3 most recent GitHub releases:
1. Run `gh release list --limit 3 --json body` to fetch release bodies in one call
2. Check for DUPLICATE CONTENT (body repeated twice - common with generate_release_notes + body)
3. Check QUALITY: narrative summary vs just auto-generated lists
4. Flag duplicate content as error, missing narrative as info
- id: GW-28
domain: release
severity: warning
desc: "Release workflows should include workflow_dispatch for manual re-triggering"
prompt: |
Scan all workflow files in .github/workflows/.
Identify release workflows by filename (release, publish) or content (gh release create, semantic-release).
For each, check for workflow_dispatch trigger.
Flag missing workflow_dispatch as warning.
# === BARE-WORKTREE LAYOUT ===
- id: GW-29
domain: git-workflow
severity: warning
desc: "Bare-worktree layout: never switch branches in an existing worktree — use `git worktree add`"
prompt: |
Detect bare-worktree layout: a `.bare/` directory at the repo root
AND at least one sibling worktree directory (e.g. `main/`, `feat-*/`).
Standard discovery: `ls -d .bare/ 2>/dev/null && git -C .bare worktree list`.
If the layout is in use, verify the working pattern:
1. Recent reflog of any worktree should NOT show in-place branch switch
events such as `checkout: moving from <a> to <b>` or
`switch: moving from <a> to <b>` (these are recorded by `git checkout`
and `git switch`, including the `-b`/`-c` create-and-switch variants).
2. New branches should appear as new worktree directories, registered via
`git worktree add <path> -b <branch>`.
Use `git reflog show HEAD -n 50` inside each worktree to inspect entries.
Note: the reflog records the resulting move, not the CLI flags — so
`checkout -b feat/x` and `switch feat/x` both surface as
`checkout:`/`switch: moving from … to …` lines.
Pass when no in-place branch switching is found in the last ~50 reflog
entries across worktrees. Warn when in-place switch events exist —
they violate the convention and produce branches that are confusing to
reason about (working tree vs branch tip diverge for the original
worktree's intended branch).
Skip cleanly (pass) when the repo is a regular clone (`.git/` is a
directory, no `.bare/` sibling) — the convention does not apply.
# === STAGING BRANCH COMPOSER.LOCK MERGE STRATEGY ===
- id: GW-30
domain: git-workflow
severity: warning
desc: >-
When merging into a staging branch that pins dev-branch Composer packages,
take --ours on composer.lock then re-run `composer update <packages>` to
apply only the intended upgrades. Taking --theirs overwrites staging's
dev-* pins and may deploy wrong package versions.
prompt: |
Check if the repository has a `staging` branch and a `composer.lock`
containing `dev-` versioned packages (e.g. `dev-staging`, `dev-main`).
Run:
git fetch origin staging:refs/remotes/origin/staging --depth=1 2>/dev/null || true
(git show origin/staging:composer.lock || git show staging:composer.lock) 2>/dev/null | grep '"version": "dev-' | head -5
If dev-versioned packages exist, verify that the project's contributing
guide or AGENTS.md documents the staging merge strategy:
- On merge conflict in composer.lock: take `--ours` (staging base) first
- Then run `composer update <targeted-packages>` to apply only the
intended version changes
- Do NOT take `--theirs` — it overwrites all dev-* pins
Report as warning if dev-* packages exist but the strategy is not documented.
[
{
"name": "setup_conventional_commits",
"prompt": "Set up conventional commits for this project with commit message validation",
"assertions": [
{
"type": "content",
"pattern": "(conventional commit|commitlint|commit-msg|feat:|fix:)"
},
{
"type": "content",
"pattern": "(hook|husky|pre-commit|lefthook|captainhook|validation)"
}
]
},
{
"name": "create_and_merge_pr",
"prompt": "Create and merge a PR for the current branch changes",
"assertions": [
{
"type": "tool_use",
"tool": "Bash",
"pattern": "gh pr (create|merge)"
},
{
"type": "content",
"pattern": "(pull request|PR|merge)"
}
]
},
{
"name": "choose_branching_strategy",
"prompt": "We're a small team doing continuous deployment of a web app. What branching strategy should we use?",
"assertions": [
{
"type": "content",
"pattern": "(GitHub Flow|trunk.based)"
},
{
"type": "content",
"pattern": "(continuous deploy|small team|feature branch)"
}
]
},
{
"name": "write_breaking_change_commit",
"prompt": "Write a commit message for removing the deprecated /api/v1 endpoints. This is a breaking change.",
"assertions": [
{
"type": "content",
"pattern": "(feat!|fix!|BREAKING CHANGE)"
},
{
"type": "content",
"pattern": "(v1|deprecated|endpoint|API)"
}
]
},
{
"name": "resolve_pr_review_threads",
"prompt": "A reviewer left 3 comments on my PR #42. I've fixed all the issues. How do I reply to and resolve the review threads?",
"assertions": [
{
"type": "content",
"pattern": "(gh api|graphql|resolveReviewThread|addPullRequestReviewThreadReply)"
},
{
"type": "content",
"pattern": "(thread|resolve|reply)"
}
]
},
{
"name": "setup_github_actions_ci",
"prompt": "Set up a basic CI pipeline with GitHub Actions for a Node.js project that runs lint, test, and build.",
"assertions": [
{
"type": "content",
"pattern": "(github.com/actions|actions/checkout|actions/setup-node|workflows)"
},
{
"type": "content",
"pattern": "(lint|test|build)"
}
]
},
{
"name": "cherry_pick_hotfix",
"prompt": "I need to backport a bug fix commit abc1234 from main to the release/1.0 branch. How?",
"assertions": [
{
"type": "content",
"pattern": "cherry-pick"
},
{
"type": "content",
"pattern": "(release|backport|branch)"
}
]
},
{
"name": "recover_deleted_branch",
"prompt": "I accidentally deleted my feature branch. How do I recover it?",
"assertions": [
{
"type": "content",
"pattern": "reflog"
},
{
"type": "content",
"pattern": "(recover|checkout|branch)"
}
]
},
{
"name": "immutable_release_recovery",
"prompt": "I created a GitHub release v1.2.3 but the TER publish failed. I deleted the release to recreate it and now I can't. What happened and how do I fix it?",
"assertions": [
{
"type": "content",
"pattern": "(immutable|permanently blocked|cannot.*reuse)"
},
{
"type": "content",
"pattern": "(v?1\\.2\\.4|next version|skip|bump)"
}
]
},
{
"name": "detect_git_hooks_framework",
"prompt": "How do I detect which git hooks framework is configured in a project?",
"assertions": [
{
"type": "content",
"pattern": "(lefthook\\.yml|captainhook\\.json|pre-commit-config\\.yaml|\\.husky)"
},
{
"type": "content",
"pattern": "(ls |detect|check|find)"
}
]
},
{
"name": "recommend_hooks_go",
"prompt": "What git hooks should I set up for a Go project? Give me a ready-to-use config.",
"assertions": [
{
"type": "content",
"pattern": "(lefthook|pre-commit)"
},
{
"type": "content",
"pattern": "(gofmt|golangci-lint|go vet)"
}
]
},
{
"name": "recommend_hooks_php",
"prompt": "What git hooks framework should I use for a TYPO3 PHP extension?",
"assertions": [
{
"type": "content",
"pattern": "captainhook"
},
{
"type": "content",
"pattern": "(php-cs-fixer|phpstan|composer)"
}
]
},
{
"name": "squash_vs_rebase_merge",
"prompt": "When should I use squash merge vs rebase merge for PRs?",
"assertions": [
{
"type": "content",
"pattern": "(squash|--squash)"
},
{
"type": "content",
"pattern": "(rebase|--rebase|linear history|messy|clean)"
}
]
},
{
"name": "signed_commits_rebase_merge",
"prompt": "My repo requires signed commits and only allows rebase merge. gh pr merge --rebase fails with 'Base branch requires signed commits'. How do I merge?",
"assertions": [
{
"type": "content",
"pattern": "(fast-forward|--ff-only|local.*merge)"
},
{
"type": "content",
"pattern": "(sign|signature|GPG|SSH)"
}
]
},
{
"name": "setup_codeowners",
"prompt": "Set up a CODEOWNERS file for our project. Frontend is in src/components/, backend in src/api/, and DevOps owns .github/ and docker/.",
"assertions": [
{
"type": "content",
"pattern": "CODEOWNERS"
},
{
"type": "content",
"pattern": "(src/components|src/api|.github|docker)"
}
]
},
{
"name": "git_bisect_find_bug",
"prompt": "Tests pass on v2.0.0 but fail on current HEAD. How do I find the commit that introduced the bug?",
"assertions": [
{
"type": "content",
"pattern": "bisect"
},
{
"type": "content",
"pattern": "(good|bad|start|run)"
}
]
},
{
"name": "multi_branch_release_latest",
"prompt": "I maintain TYPO3 v12 and v13 branches. When I release v12.0.5 after v13.5.0, the Latest badge moves to v12. How do I prevent this?",
"assertions": [
{
"type": "content",
"pattern": "--latest=false"
},
{
"type": "content",
"pattern": "(maintenance|non-default|badge)"
}
]
},
{
"name": "setup_shellcheck_shfmt",
"prompt": "Add shellcheck and shfmt to our CI and pre-commit hooks for shell script quality.",
"assertions": [
{
"type": "content",
"pattern": "shellcheck"
},
{
"type": "content",
"pattern": "shfmt"
}
]
},
{
"name": "git_absorb_workflow",
"prompt": "I have review feedback requiring changes across 3 different commits in my branch. How do I fix each commit cleanly without manual interactive rebase?",
"assertions": [
{
"type": "content",
"pattern": "(git.absorb|git absorb|--fixup)"
},
{
"type": "content",
"pattern": "(autosquash|rebase|fixup)"
}
]
},
{
"name": "pr_merge_checklist",
"prompt": "What should I verify before merging a PR? Give me the complete checklist.",
"assertions": [
{
"type": "content",
"pattern": "(CI|checks|status|pass)"
},
{
"type": "content",
"pattern": "(review|thread|resolved|approved)"
},
{
"type": "content",
"pattern": "(rebase|signed|annotation)"
},
{
"type": "content",
"pattern": "(in.?flight review|pending review|review in progress|review still running|wait for the review|review to land|in-flight or pending review)"
}
]
},
{
"name": "merge_blocked_by_inflight_review",
"prompt": "gh pr view says mergeStateStatus is CLEAN and reviewRequests is empty, but I just re-requested the Copilot reviewer a moment ago. Is it safe to merge now?",
"assertions": [
{
"type": "content",
"pattern": "(do not merge|don.t merge|not safe to merge|should not merge|hold off|don.t merge yet|do not merge yet|wait for the review|wait until the review)"
},
{
"type": "content",
"pattern": "(in.?flight|still reviewing|pending review|review in progress|hasn.t submitted|has not submitted|not yet submitted|announced review|review you just requested|review you requested)"
},
{
"type": "content",
"pattern": "(CLEAN is not sufficient|CLEAN isn.t sufficient|CLEAN alone|CLEAN can be stale|CLEAN doesn.t mean|not sufficient to merge|review_on_push|empty reviewRequests doesn|reviewRequests is not)"
}
]
},
{
"name": "old_head_review_clean_timeline_check",
"prompt": "PR has all checks green and mergeStateStatus CLEAN, but the only Copilot review is on the previous commit; I pushed a docs-only commit afterwards. Can I merge?",
"assertions": [
{
"type": "content",
"pattern": "(?i)timeline"
},
{
"type": "content",
"pattern": "(?i)review.?request"
},
{
"type": "content",
"pattern": "(?i)(after|since|following).{0,50}(push|commit|head)"
},
{
"type": "content",
"pattern": "(?i)(mergeable|safe to merge|can merge|merge it).{0,160}(no (new |further )?review|nothing (was )?announced|not.{0,20}announced)|((no (new |further )?review|nothing (was )?announced).{0,160}(mergeable|safe to merge|can merge))"
}
]
},
{
"name": "merge_gate_separate_invocation",
"prompt": "PR #42 has green CI and mergeStateStatus CLEAN, but its review threads may still be open. Run the merge gate check and merge it in one go.",
"assertions": [
{
"type": "content",
"pattern": "(?i)(separate|two|distinct).{0,40}(invocation|command|step)|(read|inspect|check).{0,60}output.{0,80}(then|before).{0,40}merge"
},
{
"type": "content",
"pattern": "(?i)(never|don.t|do not|won.t|refus|can.t|cannot|should not|shouldn.t|must not|mustn.t).{0,60}(chain|&&|one (go|command|compound)|same command|single command)"
},
{
"type": "content",
"pattern": "(?i)(exit.{0,12}(code|status|0|zero)|CLEAN (does not|doesn.t).{0,40}(unresolved|thread|conversation)|unresolved (review )?(thread|conversation))"
}
]
},
{
"name": "spec_cleanup_guard_before_merge",
"prompt": "I'm finishing a PR with /pr-finish. My feature branch committed the superpowers plan and spec under docs/superpowers/ while I was working. What should happen before this merges into the base branch?",
"assertions": [
{
"type": "content",
"pattern": "(?i)(spec.?cleanup|intermediate|working).{0,40}(artifact|spec|plan|file)|docs/superpowers"
},
{
"type": "content",
"pattern": "(?i)(not (reach|land|merge)|keep .{0,20}out of|must not).{0,40}(base|main|develop)|(remove|delete|clean).{0,40}before.{0,20}merge"
},
{
"type": "content",
"pattern": "(?i)(ADR|capture|convert|durable doc|documentation).{0,40}(then|before).{0,40}(remove|delete|clean)|guard"
}
]
},
{
"name": "spec_cleanup_recoverable_removal",
"prompt": "My branch has an untracked docs/superpowers/plan.md I no longer need before merging. How do I get rid of it safely?",
"assertions": [
{
"type": "content",
"pattern": "(?i)(never|don.t|do not|avoid).{0,30}(bare )?(rm|delete).{0,30}untracked|untracked.{0,40}(irrecoverable|not in git|cannot.{0,10}recover|unrecoverable)"
},
{
"type": "content",
"pattern": "(?i)(stage|git add|commit).{0,60}(then|before).{0,20}(git rm|remove|delete)|recoverable.{0,30}(removal|history)|enters? .{0,10}history"
}
]
}
]
Advanced Git Operations
Rewriting History
Interactive Rebase
# Rebase last N commits
git rebase -i HEAD~5
# Rebase from a specific commit
git rebase -i abc1234^
# Commands available:
# p, pick - use commit
# r, reword - edit commit message
# e, edit - stop for amending
# s, squash - combine with previous (keep message)
# f, fixup - combine with previous (discard message)
# d, drop - remove commit
# x, exec - run shell commandSquashing Commits
# Squash last 3 commits
git rebase -i HEAD~3
# Change 'pick' to 'squash' for commits to combine
# Squash into a specific commit
git rebase -i <commit-before-first-to-squash>^
# Auto-squash fixup commits
git commit --fixup=<commit-hash>
git rebase -i --autosquash mainSplitting Commits
# Start interactive rebase
git rebase -i HEAD~3
# Mark commit to split with 'edit'
# When stopped at that commit:
git reset HEAD^
git add file1.js
git commit -m "feat: first change"
git add file2.js
git commit -m "feat: second change"
git rebase --continueReordering Commits
# Interactive rebase
git rebase -i HEAD~5
# In editor, reorder lines to reorder commits
# Example:
# pick abc1234 feat: feature A
# pick def5678 feat: feature B
# Changes to:
# pick def5678 feat: feature B
# pick abc1234 feat: feature ACherry-Picking
Basic Cherry-Pick
# Pick a single commit
git cherry-pick abc1234
# Pick multiple commits
git cherry-pick abc1234 def5678 ghi9012
# Pick a range
git cherry-pick abc1234^..def5678
# Cherry-pick without committing
git cherry-pick -n abc1234Cherry-Pick Options
# Keep original author
git cherry-pick -x abc1234
# Sign off
git cherry-pick -s abc1234
# Edit commit message
git cherry-pick -e abc1234
# Continue after conflict
git cherry-pick --continue
# Abort cherry-pick
git cherry-pick --abortCherry-Pick Workflow
# Backport fix to release branch
git checkout release/1.0
git cherry-pick abc1234 # Fix from main
git push origin release/1.0
# Apply multiple fixes
git cherry-pick abc1234 def5678
# Or create a cherry-pick branch
git checkout -b cherry-pick-fixes release/1.0
git cherry-pick abc1234 def5678
git checkout release/1.0
git merge --no-ff cherry-pick-fixesStashing
Basic Stash Operations
# Stash current changes
git stash
# Stash with message
git stash save "Work in progress on feature X"
# List stashes
git stash list
# Apply latest stash (keep in stash list)
git stash apply
# Apply and remove from stash list
git stash pop
# Apply specific stash
git stash apply stash@{2}
# Drop a stash
git stash drop stash@{1}
# Clear all stashes
git stash clearAdvanced Stashing
# Stash including untracked files
git stash -u
# Stash including ignored files
git stash -a
# Stash specific files
git stash push -m "message" file1.js file2.js
# Create branch from stash
git stash branch new-branch stash@{0}
# Show stash contents
git stash show stash@{0}
git stash show -p stash@{0} # With diff
# Partial stash (interactive)
git stash -pBisecting
Finding Bug Introduction
# Start bisect
git bisect start
# Mark current as bad
git bisect bad
# Mark known good commit
git bisect good v1.0.0
# Git will checkout middle commit
# Test, then mark:
git bisect good # If bug not present
git bisect bad # If bug present
# Continue until found
# Git reports: "abc1234 is the first bad commit"
# End bisect
git bisect resetAutomated Bisect
# Run script at each step
git bisect start HEAD v1.0.0
git bisect run npm test
# With custom script
git bisect run ./test-for-bug.sh
# Exit codes:
# 0 - good
# 1-124 - bad
# 125 - skip (can't test this commit)
# 126+ - abort bisectBisect Log
# Show bisect log
git bisect log
# Save bisect log
git bisect log > bisect.log
# Replay bisect
git bisect replay bisect.logReflog
Understanding Reflog
# Show reflog
git reflog
# Show reflog for specific ref
git reflog show main
git reflog show HEAD
# Output:
# abc1234 HEAD@{0}: commit: feat: add feature
# def5678 HEAD@{1}: checkout: moving from main to feature
# ghi9012 HEAD@{2}: commit: fix: bug fixRecovering Lost Commits
# Find lost commit in reflog
git reflog
# Recover commit
git checkout abc1234
git checkout -b recovered-branch
# Or cherry-pick
git cherry-pick abc1234
# Recover after bad reset
git reflog
git reset --hard HEAD@{2}Reflog Expiration
# Default: 90 days for reachable, 30 for unreachable
git config gc.reflogExpire 90.days
git config gc.reflogExpireUnreachable 30.days
# Expire reflog manually
git reflog expire --expire=now --all
git gc --prune=nowWorktrees
Multiple Working Directories
# Add worktree
git worktree add ../project-feature feature-branch
# Add worktree with new branch
git worktree add -b new-feature ../project-new-feature main
# List worktrees
git worktree list
# Remove worktree
git worktree remove ../project-feature
# Prune stale worktree info
git worktree pruneBare-Worktree Project Layout (Recommended)
One directory per branch; never switch branches in the same folder.
Rationale: IDEs that index the tree (gopls, IntelliJ, VS Code) choke on in-place branch switches, and running parallel work on feature branches without losing the main-branch state is painful. Using a bare repo with per-branch subdirectories gives you parallel checkouts, cheap hotfix spin-ups, and a main checkout that's never "dirty because I was exploring".
/projects/<repo>/
├── .bare/ # bare git repository (clone --bare)
├── main/ # main branch worktree
├── feature-x/ # optional feature branch worktree
└── bugfix-y/ # optional bugfix branch worktreeSet up a new project this way:
cd ~/projects
mkdir <repo> && cd <repo>
git clone --bare <repository-url> .bare
# Make the bare clone behave like a regular origin fetch target.
cd .bare && git config remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*" && cd ..
# Check out main into a named subdirectory.
git -C .bare worktree add ../main mainWork on a new branch = create a new folder:
git -C .bare worktree add ../feature-x feature-x # or -b for a new branch
cd feature-x
# ... edit, commit, push ...
cd ..
git -C .bare worktree list # audit trail of what's checked out
git -C .bare worktree remove ../feature-x # clean up when the PR mergesAny relative path argument is resolved relative to `.bare/`, not your shell's current directory — git -C <dir> makes <dir> git's working directory for the whole command, including how it interprets the <path> argument to worktree add. This applies to every form of the command, regardless of whether -b comes before or after the path:
# WRONG — both of these land INSIDE the bare repo
git -C .bare worktree add -b feature-x feature-x main
git -C .bare worktree add feature-x -b feature-x main
# → creates .bare/feature-x as a worktree of the bare repo — the
# worktree is functional (it has a .git file pointing at .bare),
# but it violates the sibling-layout convention and confuses any
# tooling that walks up looking for the repository rootNote on the branch argument: plain worktree add <path> <branch> requires the branch to already exist. To create a fresh branch at the same time, use worktree add -b <branch> <path> <start> as shown above, or create the branch separately first. Both forms have the same path-resolution behaviour.
Prefer absolute paths. They're unambiguous regardless of where the command runs from — important when scripts, agents, or /loop-style sessions construct the command without a fixed cwd. Sibling-relative ../ works for humans typing from the repo parent but is brittle anywhere else.
# RIGHT — absolute path (preferred; works from any cwd)
git -C /projects/<repo>/.bare worktree add -b feature-x /projects/<repo>/feature-x main
# Also fine when you're certain of cwd — sibling-relative resolves
# against .bare/, so '..' lands next to it.
git -C .bare worktree add -b feature-x ../feature-x mainRecovery if you already created the worktree in the wrong place:
# Use absolute paths for BOTH source and destination. The -C .bare
# flag makes `worktree move` resolve relative paths against .bare/,
# so `.bare/feature-x` would be interpreted as `.bare/.bare/feature-x`
# and wouldn't find the misplaced worktree.
git -C /projects/<repo>/.bare worktree move \
/projects/<repo>/.bare/feature-x \
/projects/<repo>/feature-x(Alternatively, drop -C .bare and run from the repo parent; then the source .bare/feature-x resolves against that parent rather than against .bare/.)
When removing a worktree leaves a dangling branch reference (e.g., after deleting the physical directory manually), git worktree prune in .bare/ cleans up the metadata.
Batch cleanup after a session of PRs:
# For each branch whose PR landed, delete the worktree + local branch:
for wt in feature-x bugfix-y sync/template-foo; do
git -C /projects/<repo>/.bare worktree remove --force /projects/<repo>/$wt 2>&1 | tail -1
git -C /projects/<repo>/main branch -D "$wt" 2>&1 | tail -1
done
# Remote-side pruning (delete stale remote-tracking refs):
git -C /projects/<repo>/main fetch --prune originUse Cases
# Work on hotfix while keeping feature work
git worktree add ../project-hotfix hotfix/critical-bug
cd ../project-hotfix
# Fix bug
git commit -am "fix: critical bug"
cd ../project-main
# Review PR without stashing
git worktree add ../pr-review origin/feature-branch
cd ../pr-review
# Review codePushing to Fork Remotes (Multiple Remotes Pitfall)
When using worktrees with multiple remotes (e.g., origin = upstream, fork = your fork), git push fork main can silently say "Everything up-to-date" even when the fork is behind.
Why it fails:
- Local
maintracksorigin/main(upstream), notfork/main git push fork mainresolves the tracking ref, which may already match what git considers current- The fork remote never receives the new commits
Fix: Use explicit refspec with `HEAD:main`
# WRONG - may silently do nothing
git push fork main
# CORRECT - explicitly pushes current HEAD to fork's main
git push fork HEAD:mainFull pattern for syncing a fork:
# In a worktree where origin=upstream, fork=your-fork
git fetch origin
git merge --ff-only origin/main # Update local main from upstream
git push fork HEAD:main # Explicitly push to forkRule: When pushing to a non-tracking remote, always use explicit refspec (HEAD:<branch> or <local-branch>:<remote-branch>) to avoid silent no-ops.
Submodules
Adding Submodules
# Add submodule
git submodule add https://github.com/org/repo.git libs/repo
# Add at specific branch
git submodule add -b main https://github.com/org/repo.git libs/repo
# Initialize submodules after clone
git submodule init
git submodule update
# Clone with submodules
git clone --recurse-submodules https://github.com/org/main-repo.gitUpdating Submodules
# Update all submodules to latest
git submodule update --remote
# Update specific submodule
git submodule update --remote libs/repo
# Update and merge
git submodule update --remote --merge
# Pull in main repo and submodules
git pull --recurse-submodulesSubmodule Commands
# Run command in all submodules
git submodule foreach 'git pull origin main'
# Check status
git submodule status
# Remove submodule
git submodule deinit libs/repo
git rm libs/repo
rm -rf .git/modules/libs/repoGit Hooks
Comprehensive guide: See `git-hooks-setup.md` for hook framework
comparison (lefthook, captainhook, husky, pre-commit), detection logic, and agent rules.
Client-Side Hooks
# .git/hooks/pre-commit
#!/bin/bash
npm run lint
npm run test
# .git/hooks/commit-msg
#!/bin/bash
# Validate commit message format
# .git/hooks/pre-push
#!/bin/bash
npm run test:e2eServer-Side Hooks
# hooks/pre-receive
#!/bin/bash
# Validate pushes before accepting
# hooks/post-receive
#!/bin/bash
# Deploy after push accepted
# hooks/update
#!/bin/bash
# Per-branch validationHook Management with Husky (Node.js)
// package.json
{
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS",
"pre-push": "npm test"
}
},
"lint-staged": {
"*.{js,ts}": ["eslint --fix", "prettier --write"]
}
}Other frameworks: lefthook (Go, lefthook.yml), captainhook (PHP, captainhook.json), pre-commit (Python, .pre-commit-config.yaml). See `git-hooks-setup.md`.
Advanced Merging
Merge Strategies
# Recursive (default)
git merge feature
# Ours (keep our changes)
git merge -s ours feature
# Subtree (merge into subdirectory)
git merge -s subtree --allow-unrelated-histories other-repo/main
# Octopus (merge multiple branches)
git merge feature1 feature2 feature3Merge Options
# No fast-forward
git merge --no-ff feature
# Squash merge
git merge --squash feature
# Merge with message
git merge -m "Merge feature X" feature
# Abort merge
git merge --abortRerere (Reuse Recorded Resolution)
# Enable rerere
git config rerere.enabled true
# After resolving conflict, it's recorded
# Next time same conflict occurs, auto-resolved
# View recorded resolutions
git rerere status
# Forget resolution
git rerere forget path/to/fileGit Attributes
Line Endings
# .gitattributes
* text=auto
*.sh text eol=lf
*.bat text eol=crlf
*.png binaryDiff and Merge
# .gitattributes
*.min.js binary
*.lock -diff
*.pdf diff=pdf
# Custom diff driver
[diff "pdf"]
textconv = pdftotext -layoutExport Ignore
# .gitattributes
.gitignore export-ignore
.github export-ignore
tests/ export-ignorePerformance Optimization
Large Repositories
# Shallow clone
git clone --depth 1 https://github.com/org/repo.git
# Sparse checkout
git clone --filter=blob:none --sparse https://github.com/org/repo.git
cd repo
git sparse-checkout set src/
# Partial clone
git clone --filter=blob:none https://github.com/org/repo.gitGit LFS
# Install LFS
git lfs install
# Track large files
git lfs track "*.psd"
git lfs track "*.zip"
# View tracked patterns
git lfs track
# View LFS files
git lfs ls-files
# Pull LFS files
git lfs pullRepository Maintenance
# Garbage collection
git gc
# Aggressive gc
git gc --aggressive
# Prune unreachable objects
git prune
# Verify repository
git fsck
# Repack
git repack -a -dTroubleshooting
Common Issues
# Fix "detached HEAD"
git checkout -b new-branch # If you want to keep changes
git checkout main # If you want to discard
# Fix "refusing to merge unrelated histories"
git merge --allow-unrelated-histories other-branch
# Fix corrupted repository
git fsck --full
git gc --prune=now
# Remove file from all history
git filter-branch --force --index-filter \
'git rm --cached --ignore-unmatch path/to/file' \
--prune-empty --tag-name-filter cat -- --allRecovery Operations
# Recover deleted branch
git reflog
git checkout -b recovered abc1234
# Recover deleted file
git checkout HEAD~1 -- path/to/file
# Undo hard reset
git reflog
git reset --hard HEAD@{1}
# Recover stash
git fsck --unreachable | grep commit | cut -d' ' -f3 | \
xargs git log --merges --no-walk --grep=WIPGit Branching Strategies
Git Flow
Overview
Git Flow is a branching model designed for projects with scheduled releases.
main ─────●─────────────────●─────────────────●─────── (production)
│ │ │
│ release/1.0 │ release/1.1 │
│ ┌───●───●────┤ ┌───●───●────┤
│ │ │ │ │
develop ──●────●────●───●───●────●────●───●───●─────── (integration)
│ │ │ │ │
│ │ │ │ └── feature/C
│ └────────┴────┴─────── feature/B
└─────────────────────────── feature/ABranch Types
| Branch | Purpose | Created From | Merges To |
|---|---|---|---|
main | Production code | - | - |
develop | Integration | main | release |
feature/* | New features | develop | develop |
release/* | Release prep | develop | main, develop |
hotfix/* | Emergency fixes | main | main, develop |
Commands
# Initialize
git flow init
# Feature
git flow feature start user-auth
git flow feature publish user-auth # Push to remote
git flow feature finish user-auth
# Release
git flow release start 1.2.0
git flow release publish 1.2.0
git flow release finish 1.2.0
# Hotfix
git flow hotfix start 1.2.1
git flow hotfix finish 1.2.1When to Use
Good for:
- Scheduled release cycles
- Long-lived feature branches
- Multiple versions in production
- Teams with dedicated release managers
Avoid when:
- Continuous deployment
- Small teams
- Rapid iteration needed
GitHub Flow
Overview
Simplified workflow ideal for continuous deployment.
main ───●───●───────────●───────●───●─── (always deployable)
│ │ │ │ │
│ └── PR #2 ──┘ │ └── PR #4
│ │
└───── PR #1 ───────────┴─────── PR #3Rules
1. main is always deployable 2. Create descriptive feature branches from main 3. Push commits regularly 4. Open PR for discussion/review 5. Merge after review and CI passes 6. Deploy immediately after merge
Workflow
# 1. Start feature
git checkout main
git pull origin main
git checkout -b add-user-notifications
# 2. Develop with regular commits
git add .
git commit -m "feat: add notification service"
git push -u origin add-user-notifications
# 3. Create PR
gh pr create --title "Add user notifications" \
--body "Implements email and push notifications for users"
# 4. Address review feedback
git add .
git commit -m "fix: address review comments"
git push
# 5. Merge (after approval and CI)
gh pr merge --squash --delete-branch
# 6. Deploy (automatic via CI/CD)When to Use
Good for:
- Continuous deployment
- Web applications
- Small to medium teams
- Fast iteration cycles
Avoid when:
- Multiple versions in production
- Scheduled releases required
Trunk-Based Development
Overview
All developers work on a single branch with short-lived feature branches.
main ───●───●───●───●───●───●───●───●───●───●─── (trunk)
│ │ │ │ │ │
└─┬─┘ └───┬───┘ └───┬───┘
│ │ │
small small small
feature feature feature
(< 1 day) (< 1 day) (< 1 day)Principles
1. Single branch: All code goes to main/trunk 2. Short-lived branches: Max 1-2 days 3. Feature flags: Hide incomplete features 4. Continuous integration: Merge multiple times per day 5. No long-running branches: Avoid merge conflicts
Workflow
# Start small feature (should complete today)
git checkout main
git pull
git checkout -b small-feature
# Work in small increments
git add .
git commit -m "feat: add basic structure"
git push
# Merge quickly (within hours/day)
gh pr create --title "Small feature"
gh pr merge --rebase
# Feature flags for incomplete work
if (featureFlags.isEnabled('new-checkout')) {
// New checkout flow
} else {
// Existing checkout flow
}Release Strategies
# Option 1: Release from trunk
git tag v1.2.0
git push origin v1.2.0
# Option 2: Release branches (for fixes)
git checkout -b release/1.2 main
# Cherry-pick fixes if needed
git cherry-pick <fix-commit>
git tag v1.2.1When to Use
Good for:
- Mature CI/CD pipelines
- High test coverage
- Experienced teams
- Microservices
Avoid when:
- Junior-heavy teams
- Low test coverage
- Multiple long-term versions
GitLab Flow
Overview
Combines feature branches with environment branches.
main ─────●─────●─────●─────●─────●───── (development)
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
staging ──●─────●─────●─────●─────●───── (staging env)
│ │ │
▼ ▼ ▼
production ─────●───────────●─────●───── (production env)Environment Branches
# Feature development
git checkout -b feature/new-api main
# ... develop ...
git checkout main
git merge feature/new-api
# Promote to staging
git checkout staging
git merge main
# Promote to production (after staging verification)
git checkout production
git merge staging
git tag v1.2.0With Release Branches
# Support multiple versions
main
├── release/1.x
│ ├── 1.0.0
│ ├── 1.0.1
│ └── 1.1.0
└── release/2.x
├── 2.0.0
└── 2.0.1Choosing a Strategy
Decision Matrix
| Factor | Git Flow | GitHub Flow | Trunk-Based | GitLab Flow |
|---|---|---|---|---|
| Release frequency | Scheduled | Continuous | Continuous | Environment-based |
| Team size | Large | Small-Medium | Any | Any |
| Version support | Multiple | Single | Single | Multiple |
| Branch complexity | High | Low | Very Low | Medium |
| CI/CD maturity | Any | Medium | High | Medium |
| Merge conflicts | More | Less | Least | Medium |
Quick Guide
Need scheduled releases?
├── Yes → Multiple versions in production?
│ ├── Yes → Git Flow
│ └── No → GitLab Flow (with releases)
└── No → Continuous deployment ready?
├── Yes → High test coverage?
│ ├── Yes → Trunk-Based
│ └── No → GitHub Flow
└── No → GitHub FlowBranch Protection
GitHub Branch Protection Rules
# Settings > Branches > Branch protection rules
main:
require_pull_request:
required_approving_reviews: 2
dismiss_stale_reviews: true
require_code_owner_reviews: true
require_status_checks:
strict: true
contexts:
- "ci/lint"
- "ci/test"
- "ci/build"
require_conversation_resolution: true
require_signed_commits: false
include_administrators: true
allow_force_pushes: false
allow_deletions: falseGitLab Protected Branches
# Settings > Repository > Protected branches
main:
allowed_to_push:
- role: maintainer
allowed_to_merge:
- role: developer
allowed_to_force_push: false
code_owner_approval_required: trueCheck the Default Branch Before Operating
Not every repo uses main — older repos often use master, and some use develop or trunk. Before pushing, opening a PR, or scripting across many repos, resolve the actual default branch instead of assuming:
gh repo view OWNER/REPO --json defaultBranchRef --jq '.defaultBranchRef.name'Assuming the wrong name silently pushes to (or creates) the wrong branch, or targets a PR at a branch that isn't the integration branch.
Prefer the gh CLI / GitHub MCP Over Raw API or Web UI
For GitHub operations (PRs, issues, reviews, releases), reach for gh or the GitHub MCP tools before hand-rolling curl/REST calls or clicking through the web UI: consistent authentication, structured --json output, and clearer errors. Drop to raw gh api only for endpoints the porcelain commands don't cover yet.
Migration Between Strategies
Git Flow → GitHub Flow
# 1. Merge develop to main
git checkout main
git merge develop
# 2. Delete develop branch
git branch -d develop
git push origin --delete develop
# 3. Update CI/CD to deploy from main
# 4. Communicate new workflow to team
# 5. Update branch protection rulesGitHub Flow → Trunk-Based
# 1. Implement feature flags
# 2. Increase test coverage
# 3. Set up continuous deployment
# 4. Shorten PR review cycle
# 5. Enforce small, frequent mergesCI/CD Integration
GitHub Actions
Basic Workflow Structure
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
workflow_dispatch: # Manual trigger
env:
NODE_VERSION: '20'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npm run buildComplete CI Pipeline
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run lint
test:
runs-on: ubuntu-latest
needs: lint
strategy:
matrix:
node: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'npm'
- run: npm ci
- run: npm test -- --coverage
- uses: codecov/codecov-action@v4
if: matrix.node == 20
build:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: build
path: dist/
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run security audit
run: npm audit --audit-level=high
- name: Run Snyk
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
deploy-staging:
runs-on: ubuntu-latest
needs: [build, security]
if: github.ref == 'refs/heads/develop'
environment: staging
steps:
- uses: actions/download-artifact@v4
with:
name: build
path: dist/
- name: Deploy to staging
run: |
# Deploy commands here
deploy-production:
runs-on: ubuntu-latest
needs: [build, security]
if: github.ref == 'refs/heads/main'
environment: production
steps:
- uses: actions/download-artifact@v4
with:
name: build
path: dist/
- name: Deploy to production
run: |
# Deploy commands hereReusable Workflows
# .github/workflows/reusable-test.yml
name: Reusable Test Workflow
on:
workflow_call:
inputs:
node-version:
required: true
type: string
secrets:
npm-token:
required: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- run: npm ci
- run: npm test# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
call-test:
uses: ./.github/workflows/reusable-test.yml
with:
node-version: '20'
secrets:
npm-token: ${{ secrets.NPM_TOKEN }}Matrix Builds
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [18, 20, 22]
exclude:
- os: windows-latest
node: 18
fail-fast: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm testGitLab CI
Basic Pipeline
# .gitlab-ci.yml
stages:
- build
- test
- deploy
variables:
NODE_VERSION: "20"
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
build:
stage: build
image: node:${NODE_VERSION}
script:
- npm ci
- npm run build
artifacts:
paths:
- dist/
expire_in: 1 hour
test:
stage: test
image: node:${NODE_VERSION}
script:
- npm ci
- npm test
coverage: '/All files[^|]*\|[^|]*\s+([\d\.]+)/'
deploy_staging:
stage: deploy
script:
- ./deploy.sh staging
environment:
name: staging
url: https://staging.example.com
only:
- develop
deploy_production:
stage: deploy
script:
- ./deploy.sh production
environment:
name: production
url: https://example.com
only:
- main
when: manualMulti-Stage Pipeline
stages:
- prepare
- build
- test
- security
- deploy
.node-base:
image: node:20
cache:
key: ${CI_COMMIT_REF_SLUG}
paths:
- node_modules/
install:
extends: .node-base
stage: prepare
script:
- npm ci
artifacts:
paths:
- node_modules/
expire_in: 1 hour
lint:
extends: .node-base
stage: build
needs: [install]
script:
- npm run lint
build:
extends: .node-base
stage: build
needs: [install]
script:
- npm run build
artifacts:
paths:
- dist/
expire_in: 1 day
unit-test:
extends: .node-base
stage: test
needs: [install]
script:
- npm run test:unit
coverage: '/Statements\s*:\s*(\d+\.?\d*)%/'
integration-test:
extends: .node-base
stage: test
needs: [build]
services:
- postgres:15
variables:
POSTGRES_DB: test
POSTGRES_USER: test
POSTGRES_PASSWORD: test
script:
- npm run test:integration
security-audit:
stage: security
needs: [install]
script:
- npm audit --audit-level=high
allow_failure: true
sast:
stage: security
include:
- template: Security/SAST.gitlab-ci.ymlSemantic Release
Configuration
// .releaserc.json
{
"branches": [
"main",
{"name": "beta", "prerelease": true},
{"name": "alpha", "prerelease": true}
],
"plugins": [
["@semantic-release/commit-analyzer", {
"preset": "conventionalcommits",
"releaseRules": [
{"type": "feat", "release": "minor"},
{"type": "fix", "release": "patch"},
{"type": "perf", "release": "patch"},
{"breaking": true, "release": "major"}
]
}],
["@semantic-release/release-notes-generator", {
"preset": "conventionalcommits",
"presetConfig": {
"types": [
{"type": "feat", "section": "Features"},
{"type": "fix", "section": "Bug Fixes"},
{"type": "perf", "section": "Performance"},
{"type": "revert", "section": "Reverts"}
]
}
}],
["@semantic-release/changelog", {
"changelogFile": "CHANGELOG.md"
}],
["@semantic-release/npm", {
"npmPublish": true
}],
["@semantic-release/git", {
"assets": ["CHANGELOG.md", "package.json"],
"message": "chore(release): ${nextRelease.version} [skip ci]"
}],
"@semantic-release/github"
]
}GitHub Actions Integration
name: Release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npx semantic-releaseBranch Protection
GitHub Settings
# Via GitHub API or settings UI
branch_protection:
main:
required_status_checks:
strict: true
contexts:
- lint
- test
- build
- security
required_pull_request_reviews:
required_approving_review_count: 2
dismiss_stale_reviews: true
require_code_owner_reviews: true
restrictions:
users: []
teams: [core-team]
enforce_admins: true
require_linear_history: true
allow_force_pushes: false
allow_deletions: falseGitLab Settings
# Via GitLab API or settings UI
protected_branches:
main:
push_access_level: maintainer
merge_access_level: developer
unprotect_access_level: admin
code_owner_approval_required: true
merge_request_approvals:
approvals_before_merge: 2
reset_approvals_on_push: trueAutomated Testing
Pre-merge Checks
# .github/workflows/pr-checks.yml
name: PR Checks
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
analyze:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check PR size
run: |
LINES=$(git diff --numstat origin/main...HEAD | awk '{sum += $1 + $2} END {print sum}')
if [ "$LINES" -gt 1000 ]; then
echo "::warning::Large PR ($LINES lines). Consider splitting."
fi
- name: Check commit messages
run: |
git log origin/main..HEAD --pretty=format:"%s" | while read msg; do
if ! echo "$msg" | grep -qE "^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)"; then
echo "::error::Invalid commit message: $msg"
exit 1
fi
done
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm test -- --coverage --changedSince=origin/main
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm run lint -- --max-warnings 0E2E Testing
name: E2E Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
e2e:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_DB: test
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: Start application
run: npm start &
env:
DATABASE_URL: postgres://test:test@localhost:5432/test
- name: Wait for app
run: npx wait-on http://localhost:3000
- name: Run E2E tests
run: npm run test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: e2e-screenshots
path: cypress/screenshots/Watching CI from the CLI
When waiting on PR CI from the command line (or an agent), use the native watchers — do not hand-roll a poll loop:
# Wait for all PR checks; exits non-zero if any required check fails.
gh pr checks <pr> --repo <owner/repo> --watch --fail-fast
# Watch a single workflow run by ID (when you have the run, not the PR).
gh run watch <run-id> --repo <owner/repo> --exit-statusGate on the exit code, not on parsed output. gh pr checks and gh run watch already handle pending-state representation, the appearance of newly-triggered runs, and refresh.
Hand-rolled gh pr checks | jq poll loops re-derive those semantics from undocumented field shapes (a running check's conclusion may be "", null, or absent) and are a recurring source of bugs. The sharpest one: a poll run immediately after a push, reopen, or re-trigger reads "0 pending" before the freshly-queued run has registered, so the loop reports a false "all green" and you act prematurely. A bare [ "$pending" -eq 0 ] && break snapshot is true both before runs start and after they finish — it cannot tell the two apart.
If you must hand-roll (e.g. watching something with no native watcher), gate on a named required check reaching a terminal `pass`/`fail` state, never on a zero-pending count, and confirm the run belongs to the current head SHA first.
Deployment Strategies
Blue-Green Deployment
name: Blue-Green Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to green
run: |
kubectl apply -f k8s/green-deployment.yaml
kubectl rollout status deployment/app-green
- name: Run smoke tests
run: ./scripts/smoke-test.sh green
- name: Switch traffic
run: |
kubectl patch service app -p '{"spec":{"selector":{"version":"green"}}}'
- name: Cleanup blue
run: |
kubectl delete deployment app-blue || trueCanary Deployment
name: Canary Deploy
on:
push:
branches: [main]
jobs:
canary:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy canary (10%)
run: |
kubectl apply -f k8s/canary-deployment.yaml
kubectl set image deployment/app-canary app=${{ env.IMAGE }}
- name: Monitor canary
run: |
sleep 300 # 5 minutes
ERROR_RATE=$(./scripts/get-error-rate.sh canary)
if [ "$ERROR_RATE" -gt 5 ]; then
echo "High error rate, rolling back"
kubectl rollout undo deployment/app-canary
exit 1
fi
- name: Full rollout
run: |
kubectl set image deployment/app app=${{ env.IMAGE }}
kubectl rollout status deployment/appNotifications
Slack Integration
name: CI with Notifications
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test
- name: Notify Slack on failure
if: failure()
uses: slackapi/slack-github-action@v1
with:
payload: |
{
"text": "Build failed on ${{ github.ref }}",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Build Failed* :x:\n*Branch:* ${{ github.ref }}\n*Commit:* ${{ github.sha }}\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Run>"
}
}
]
}
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}Git Mirror Repositories
Use git clone --mirror + git push --mirror to keep a target repository in sync with an upstream source — for example, mirroring a public TYPO3 repository into a private GitLab instance or creating read-only forks for controlled distribution.
git clone --mirror "$SOURCE_URL" repo.git
cd repo.git
git push --mirror "$TARGET_URL"Default Branch Requirement
git push --mirror pushes all refs from the source and deletes any ref at the target that no longer exists in the source. GitLab and GitHub will refuse to delete their repository's default branch, causing the push to fail with an error like:
remote: GitLab: You can only delete protected branches using the web interface.
error: failed to push some refs to 'git@gitlab.example.com:org/repo.git'Root cause: the target was initialised with a default branch (e.g. main) that does not exist in the upstream (e.g. source uses 12.4). Every mirror run tries to delete main and GitLab refuses.
Fix — preferred: create the target as an empty project (no README, no initial commit). The default branch is then set automatically when the first git push --mirror runs.
Fix — existing repo: change the default branch in Settings before mirroring. On GitLab: Settings → Repository → Default branch. On GitHub: Settings → Branches → Default branch.
Notes-Ref Gotcha
git push --mirror deletes any ref at the target that does not exist in the upstream. If you store cache data (e.g. split commit maps) in refs/notes/* on the mirror target, those refs will be wiped on every sync run because they are absent from the upstream.
Do not rely on refs/notes/* for persistent caching in mirror repositories. Store such state in a separate repository, a file in object storage, or a CI/CD cache artifact.
Example CI Job (GitLab)
mirror-sync:
image: alpine/git:2.43.0
script:
- git clone --mirror "$SOURCE_URL" repo.git
- cd repo.git
- git push --mirror "$TARGET_URL"
only:
- schedulesBest Practices
1. Fast Feedback: Keep CI under 10 minutes 2. Parallel Jobs: Run independent jobs concurrently 3. Caching: Cache dependencies and build artifacts 4. Fail Fast: Stop on first failure in PR checks 5. Environment Parity: Match CI environment to production 6. Secrets Management: Use encrypted secrets, rotate regularly 7. Artifact Retention: Clean up old artifacts 8. Status Checks: Require all checks to pass before merge
Claude Code Hooks for Workflow Enforcement
Ready-to-drop settings.json hook recipes that enforce the critical rules from SKILL.md at tool-invocation time. These run in the Claude Code harness, not in git — they catch violations before the command executes.
For git-side hooks (pre-commit, pre-push), see references/git-hooks-setup.md instead.
Where to put these
| Scope | File | When to use |
|---|---|---|
| Personal, all projects | ~/.claude/settings.json | Enforce your own rules everywhere |
| Team, committed to repo | .claude/settings.json | Enforce team rules for this project |
| Personal, one project | .claude/settings.local.json | Overrides for this project only |
Merge carefully — read the existing hooks: block, add to arrays, never replace wholesale.
Recipe 1: Block gh pr merge When Review Threads Are Open
Blocks any gh pr merge invocation that would merge a PR with unresolved threads or missing approval.
Because the merge-gate logic is non-trivial, keep it as an external script rather than inline JSON. Install scripts/merge-gate.sh and reference it:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(gh pr merge *)",
"command": "~/.claude/hooks/merge-gate.sh"
}
]
}
]
}
}~/.claude/hooks/merge-gate.sh:
#!/usr/bin/env bash
# Reads the Claude Code hook payload on stdin; emits a PreToolUse deny if the
# target PR has unresolved review threads, pending/rejected review, or a
# non-CLEAN merge state.
set -euo pipefail
CMD=$(jq -r '.tool_input.command // ""')
# Extract PR identifier. Supports the three forms that cover all real usage:
# gh pr merge 123
# gh pr merge --auto 123
# gh pr merge https://github.com/owner/repo/pull/123
# gh pr merge owner/repo#123
PR=""; REPO_FLAG=()
if [[ "$CMD" =~ gh[[:space:]]+pr[[:space:]]+merge[[:space:]]+(--[a-z-]+[[:space:]]+)*([0-9]+)([[:space:]]|$) ]]; then
PR="${BASH_REMATCH[2]}"
elif [[ "$CMD" =~ gh[[:space:]]+pr[[:space:]]+merge[[:space:]]+(--[a-z-]+[[:space:]]+)*https?://github\.com/([^/]+/[^/]+)/pull/([0-9]+) ]]; then
PR="${BASH_REMATCH[3]}"; REPO_FLAG=(--repo "${BASH_REMATCH[2]}")
elif [[ "$CMD" =~ gh[[:space:]]+pr[[:space:]]+merge[[:space:]]+(--[a-z-]+[[:space:]]+)*([^/[:space:]]+/[^#[:space:]]+)#([0-9]+) ]]; then
PR="${BASH_REMATCH[3]}"; REPO_FLAG=(--repo "${BASH_REMATCH[2]}")
fi
# Could not parse a PR id — let the call through rather than false-positive block.
[[ -z "$PR" ]] && exit 0
# NB: `reviewThreads` is NOT a valid `gh pr view --json` field — gh errors
# "Unknown JSON field: reviewThreads" (its whitelist has reviews /
# reviewRequests / reviewDecision, not reviewThreads). Fetch mergeStateStatus +
# url via `gh pr view`, then get thread resolution via GraphQL (owner / repo /
# number parsed from the url). Passing reviewThreads to --json makes the whole
# call fail → `|| exit 0` → the gate silently allows every merge.
INFO=$(gh pr view "$PR" "${REPO_FLAG[@]}" --json mergeStateStatus,url 2>/dev/null) || exit 0
MSS=$(echo "$INFO" | jq -r '.mergeStateStatus // "null"')
URL=$(echo "$INFO" | jq -r '.url // ""')
[[ "$URL" =~ github\.com/([^/]+)/([^/]+)/pull/([0-9]+) ]] || exit 0
UNRES=$(gh api graphql -f query="{repository(owner:\"${BASH_REMATCH[1]}\",name:\"${BASH_REMATCH[2]}\"){pullRequest(number:${BASH_REMATCH[3]}){reviewThreads(first:100){nodes{isResolved}}}}}" \
--jq '[.data.repository.pullRequest?.reviewThreads?.nodes[]? | select(.isResolved==false)] | length' 2>/dev/null) || UNRES=0
# Gate on unresolved threads + merge state only. Deliberately NOT on
# reviewDecision: repos with no required-approval rule report reviewDecision ""
# and merge fine when CLEAN, and mergeStateStatus==CLEAN already encodes the
# required-approval gate — so gating on reviewDecision!=APPROVED would
# false-positive-block every such repo.
if [[ "${UNRES:-0}" -gt 0 || "$MSS" != "CLEAN" ]]; then
jq -cn \
--arg r "merge-gate: unresolved-threads=$UNRES, mergeState=$MSS — resolve threads / clear the block before merging" \
'{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $r}}'
fiThe hook denies gh pr merge when review threads are unresolved or mergeStateStatus != CLEAN. It parses the three PR-reference forms gh pr merge accepts (plain number, full URL, owner/repo#N); if parsing fails, the hook allows the call rather than producing false-positive denies.
Recipe 2: Reject Edits to Installed Cache Paths
Prevents Write / Edit / MultiEdit from targeting ~/.claude/skills/..., ~/.claude/plugins/cache/..., or any .bare/ path — which would be silently clobbered on the next update.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path // \"\"' | { read -r P; case \"$P\" in */.claude/skills/*|*/.claude/plugins/cache/*|*/.claude/plugins/marketplaces/*|*/.bare/*) echo \"{\\\"hookSpecificOutput\\\":{\\\"hookEventName\\\":\\\"PreToolUse\\\",\\\"permissionDecision\\\":\\\"deny\\\",\\\"permissionDecisionReason\\\":\\\"cache path rejected: $P — edit the source worktree instead\\\"}}\";; esac; }"
}
]
}
]
}
}Recipe 3: Warn on Unauthorized Squash
Does not block — just emits a warning. Squash is legitimate on repos with a squash policy; full blocking would be too noisy. The warning is enough to prompt the user to confirm intent.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(gh pr merge *)",
"command": "jq -r '.tool_input.command' | grep -qE -- '--squash\\b' && echo '{\"systemMessage\":\"⚠ squash merge requested — confirm the repo uses squash policy; default is atomic commits\"}' || true"
}
]
}
]
}
}Recipe 4: Auto-Lint Go Files After Write/Edit
Runs golangci-lint on the file's directory after any write. Silent-success; logs only on failure.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path // .tool_response.filePath // \"\"' | { read -r F; [ -z \"$F\" ] && exit 0; case \"$F\" in *.go) cd \"$(dirname \"$F\")\" && golangci-lint run --fast 2>&1 | head -40 || true;; esac; } 2>/dev/null"
}
]
}
]
}
}Swap golangci-lint for your project's linter of choice. For PHP: vendor/bin/php-cs-fixer fix --dry-run -- "$F". For JS/TS: bunx eslint "$F".
Recipe 5: Sentinel on "Verified" Claims Without Tool Output
Experimental — uses a prompt hook to audit assistant messages declaring pass/verified. Only runs on Stop events (end of assistant turn).
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "prompt",
"prompt": "Check the just-ended assistant turn. If it contains any of: 'verified', 'tested', 'all green', 'tests pass', 'should work now', 'try again' — AND no Bash/Read tool result in the same turn shows actual command output substantiating the claim — emit a systemMessage warning. Otherwise stay silent.\n\n$ARGUMENTS"
}
]
}
]
}
}This is a soft guardrail — the hook can't block a past message, only flag it to the user. Useful as a "you said tested but didn't run anything" reminder.
Deploying Hooks
After editing settings.json:
# Validate JSON syntax first — broken JSON silently disables ALL settings
jq -e '.hooks' .claude/settings.json
# Reload config — open and close the /hooks menu in Claude Code, or restartThe settings watcher only picks up new hook files if .claude/ existed at session start. If you created .claude/settings.json during a session, open /hooks once to reload.
Anti-Patterns
| Anti-pattern | Why wrong | Fix |
|---|---|---|
Using xargs on stdin JSON | xargs splits on spaces; breaks paths with spaces | { read -r F; ... "$F"; } pattern |
| Forgetting `2>/dev/null \ | \ | true` on PostToolUse |
| `Write\ | Edit` matcher without file-path extraction | Hook runs on wrong files |
| Blocking hooks that hit flaky services | One GitHub-API outage blocks all merges | Soft-fail: warn instead of deny for infra-dependent gates |
| Per-hook large shell scripts inline in JSON | Unreadable, un-testable | Keep inline ≤3 lines; call external script for more |
Code Quality Tools
Shell linting, formatting, smart fixup commits, and structural diffs.
shellcheck - Shell Script Linter
Rule Categories
| Prefix | Category | Examples |
|---|---|---|
| SC1xxx | Syntax errors | SC1009 (missing $ on variable), SC1073 (couldn't parse) |
| SC2xxx | Suggestions/warnings | SC2086 (double quote to prevent globbing), SC2046 (quote to prevent splitting) |
| SC3xxx | Portability | SC3010 ([[ ]] not POSIX), SC3030 (arrays not POSIX) |
| SC4xxx | Deprecation | Deprecated features that should be avoided |
.shellcheckrc Configuration
Place .shellcheckrc in the project root:
# Default shell dialect
shell=bash
# Globally disabled rules
disable=SC1091 # Don't follow sourced files
disable=SC2154 # Referenced but not assigned (common with env vars)
# Enable optional checks
enable=require-variable-braces
enable=check-unassigned-uppercaseCommon Rules and Fixes
# SC2086: Double quote to prevent globbing and word splitting
# Bad:
echo $var
# Good:
echo "$var"
# SC2046: Quote command substitution to prevent splitting
# Bad:
files=$(find . -name "*.sh")
# Good:
files="$(find . -name "*.sh")"
# SC2155: Declare and assign separately to avoid masking return values
# Bad:
local foo="$(mycmd)"
# Good:
local foo
foo="$(mycmd)"
# SC2164: Use cd ... || exit to handle cd failure
# Bad:
cd /some/dir
# Good:
cd /some/dir || exit 1Gotcha: Command Substitution with Empty Output
set -e fires when var=$(failing_cmd) — the assignment propagates the non-zero exit code and aborts the script. However, set -e cannot detect a command that exits 0 but produces no output. An empty variable is a valid result from the shell's point of view. No shellcheck rule covers this case for regular (non-local) variables.
This means downstream code that uses $var without checking can silently misbehave. A common consequence: an empty variable interpolated into a refspec becomes an accidental delete operation.
# Silent failure — git commit-tree succeeds but produces no output
# (e.g. tree already exists, permission issue, or wrong arguments).
# set -e does NOT fire here. commit is empty string "".
commit=$(git commit-tree "$tree" -p "$parent" -m "msg")
# Downstream: the empty commit SHA becomes a delete refspec.
# This pushes ":refs/tags/v1.0" — which DELETES the tag on the remote.
git push origin "${commit}:refs/tags/v1.0"Always add an explicit empty-value guard after command substitution whose output is load-bearing:
tag="v1.0"
commit=$(git commit-tree "$tree" -p "$parent" -m "msg")
if [ -z "${commit}" ]; then
echo "ERROR: git commit-tree produced no output for tag ${tag}" >&2
exit 1 # or: continue, if inside a loop
fi
git push origin "${commit}:refs/tags/${tag}"Note: SC2155 (Declare and assign separately to avoid masking return values) only applies to local var=$(cmd) — where the local builtin masks the exit code. For plain var=$(cmd), the exit code is propagated and set -e works normally. The empty-output case is distinct and not covered by any shellcheck rule.
CI Integration
# GitHub Actions - shellcheck
- name: Run shellcheck
uses: ludeeus/action-shellcheck@00cae500b08a931fb5698e11e79bfbd38e612a38 # v2.0.0
with:
scandir: './scripts'
severity: warning---
shfmt - Shell Script Formatter
Flag Reference
| Flag | Description | Example |
|---|---|---|
-w | Write result to file (in-place) | shfmt -w script.sh |
-d | Display diff (error if not formatted) | shfmt -d script.sh |
-i N | Indent with N spaces (0 = tabs) | shfmt -i 2 script.sh |
-bn | Binary ops like && and `\ | ` may start a line |
-ci | Indent switch cases | shfmt -ci script.sh |
-sr | Redirect operators followed by a space | shfmt -sr script.sh |
-fn | Function opening brace on a separate line | shfmt -fn script.sh |
-ln | Language variant: bash, posix, mksh, bats | shfmt -ln bash script.sh |
EditorConfig Integration
shfmt reads .editorconfig settings automatically:
# .editorconfig
[*.sh]
indent_style = space
indent_size = 2
shell_variant = bash
binary_next_line = true
switch_case_indent = true
space_redirects = trueWhen an .editorconfig is present, run shfmt -w . without explicit flags.
CI Integration
# GitHub Actions - shfmt
- name: Check shell formatting
uses: mvdan/sh@8202166b7d1e3473a7c65eeac53ddbdb55d5b808 # v3.12.0
with:
sh-version: latest
args: '-d -i 2 .'---
git-absorb - Smart Fixup Commits
How It Works
1. You stage changes with git add (the fixes you want to absorb) 2. git absorb analyzes the staged hunks 3. For each hunk, it finds the most recent commit that modified those exact lines 4. It creates fixup! commits targeting those parent commits 5. git rebase --autosquash folds the fixup commits into their targets
Typical Workflow
# 1. You have a feature branch with 5 commits
# 2. Code review requests changes to lines in commits 2 and 4
# 3. Make the fixes in your working tree
# 4. Stage them
git add -p
# 5. Let git-absorb figure out which commits they belong to
git absorb
# 6. Verify the fixup commits look correct
git log --oneline main..HEAD
# 7. Squash fixups into their parent commits
git rebase --autosquash main
# 8. Force-push the cleaned-up branch
git push --force-with-leaseComparison with Manual Fixup Workflow
| Step | Manual | git-absorb |
|---|---|---|
| Identify target commit | git log --oneline, find SHA | Automatic |
| Create fixup commit | git commit --fixup=<SHA> | git absorb |
| Apply fixups | git rebase --autosquash main | git absorb --and-rebase or same |
| Multiple fixes | Repeat per commit | Single command handles all |
Limitations
- Only works with staged changes (use
git add -pfor partial staging) - Cannot absorb changes to lines that were not in any prior commit (new code)
- Ambiguous hunks (lines modified in multiple commits) are skipped with a warning
- Requires a clean working tree for
--and-rebase
Installation
# macOS
brew install git-absorb
# Arch Linux
pacman -S git-absorb
# Cargo (any platform)
cargo install git-absorb
# Ubuntu/Debian (via cargo or from releases)
cargo install git-absorb---
difft (difftastic) - Structural Diff
Language Support
difft supports 50+ languages including: Bash, C, C++, C#, CSS, Dart, Elixir, Elm, Go, Haskell, HTML, Java, JavaScript, JSON, Kotlin, Lua, Nix, OCaml, PHP, Python, Ruby, Rust, Scala, SQL, Swift, TOML, TypeScript, YAML, and more.
Configuration Options
# Set as default git diff tool (persistent)
git config --global diff.external difft
# Set display width
git config --global difftool.difftastic.cmd 'difft --width 80 "$LOCAL" "$REMOTE"'
# Color output
export DFT_COLOR=always # always | never | auto
# Display mode
export DFT_DISPLAY=side-by-side-show-both # side-by-side | side-by-side-show-both | inline
# Syntax highlighting
export DFT_SYNTAX_HIGHLIGHT=on # on | off
# Context lines around changes
export DFT_CONTEXT=3Integration with Git
# One-off usage with git diff
GIT_EXTERNAL_DIFF=difft git diff
# With git log
GIT_EXTERNAL_DIFF=difft git log -p --ext-diff
# With git show
GIT_EXTERNAL_DIFF=difft git show --ext-diff HEAD
# As a difftool (interactive)
git config --global difftool.difftastic.cmd 'difft "$LOCAL" "$REMOTE"'
git difftool --tool=difftasticIntegration with delta
If you use delta as your git pager, you can combine them:
# Use difft for structural diff, delta for everything else
# In .gitconfig:
[diff]
external = difft
[pager]
diff = delta
log = delta
show = deltaNote: When diff.external is set, delta won't process diff output since difft handles it directly. Use --no-ext-diff to bypass difft and use delta instead when needed:
git diff --no-ext-diff # Uses delta pager instead of difftInstallation
# macOS
brew install difftastic
# Arch Linux
pacman -S difftastic
# Cargo
cargo install --locked difftastic
# Ubuntu/Debian (from GitHub releases)
curl -Lo difft.tar.gz https://github.com/Wilfred/difftastic/releases/latest/download/difft-x86_64-unknown-linux-gnu.tar.gz
tar xf difft.tar.gz && sudo mv difft /usr/local/bin/---
Pre-commit Hook Integration
Integrate all tools into a pre-commit hook:
#!/usr/bin/env bash
# .git/hooks/pre-commit or via pre-commit framework
set -euo pipefail
# Find staged shell scripts
staged_scripts=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(sh|bash)$' || true)
if [[ -n "$staged_scripts" ]]; then
echo "Running shellcheck..."
echo "$staged_scripts" | xargs shellcheck || {
echo "shellcheck failed. Fix issues before committing."
exit 1
}
echo "Running shfmt..."
echo "$staged_scripts" | xargs shfmt -d -i 2 || {
echo "shfmt: formatting issues found. Run 'shfmt -w -i 2' on the files above."
exit 1
}
fiUsing pre-commit Framework
# .pre-commit-config.yaml
repos:
- repo: https://github.com/koalaman/shellcheck-precommit
rev: v0.10.0
hooks:
- id: shellcheck
- repo: https://github.com/scop/pre-commit-shfmt
rev: v3.12.0-2
hooks:
- id: shfmt
args: ['-i', '2', '-w']---
GitHub Actions Workflow
Complete workflow integrating all shell quality tools:
name: Shell Quality
on:
pull_request:
paths:
- '**/*.sh'
- '**/*.bash'
- '.shellcheckrc'
- '.editorconfig'
jobs:
shell-quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install tools
run: |
sudo apt-get update
sudo apt-get install -y shellcheck
go install mvdan.cc/sh/v3/cmd/shfmt@latest
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
- name: shellcheck
run: |
git ls-files '*.sh' '*.bash' | xargs --no-run-if-empty shellcheck
- name: shfmt
run: |
shfmt -d -i 2 .Commit Conventions
Conventional Commits
Specification
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]Commit Types
| Type | Description | Version Bump |
|---|---|---|
feat | New feature | MINOR |
fix | Bug fix | PATCH |
docs | Documentation only | - |
style | Code style (formatting) | - |
refactor | Code refactoring | - |
perf | Performance improvement | PATCH |
test | Adding/updating tests | - |
build | Build system changes | - |
ci | CI configuration | - |
chore | Maintenance tasks | - |
revert | Reverting changes | - |
Examples
# Simple feature
feat: add user authentication
# Feature with scope
feat(auth): add OAuth2 login support
# Bug fix
fix: resolve null pointer in user service
# Bug fix with issue reference
fix(api): handle empty response from external service
Fixes #123
# Breaking change
feat!: remove deprecated v1 API endpoints
BREAKING CHANGE: The /api/v1/* endpoints have been removed.
Migrate to /api/v2/* before upgrading.
# Multiple footers
fix(security): patch XSS vulnerability in comment parser
Reviewed-by: John Doe
Refs: #456Scope Guidelines
Scopes should be consistent across the project:
# By feature area
feat(auth): ...
feat(payment): ...
feat(notification): ...
# By layer
fix(api): ...
fix(db): ...
fix(ui): ...
# By component
style(button): ...
refactor(modal): ...Commit Message Best Practices
Subject Line
# ✅ Good: Imperative mood, present tense
feat: add password reset functionality
fix: prevent duplicate form submissions
# ❌ Bad: Past tense, not imperative
feat: added password reset functionality
fix: fixed duplicate form submissions
# ✅ Good: Specific and concise
feat: implement rate limiting for API endpoints
# ❌ Bad: Vague
feat: improve API
# ✅ Good: Under 72 characters
fix: resolve race condition in cache invalidation
# ❌ Bad: Too long
fix: resolve the race condition that was occurring in the cache invalidation process when multiple users were accessing the same resource simultaneouslyBody
# When to include a body:
# - Complex changes needing explanation
# - Non-obvious implementation choices
# - Context for future readers
fix: prevent race condition in order processing
The previous implementation allowed concurrent modifications to the
same order, leading to inconsistent state.
This change introduces optimistic locking using version numbers.
When a conflict is detected, the operation is retried with fresh data.
The retry limit is set to 3 attempts to prevent infinite loops.Write multi-line or special-char bodies to a file, not inline `-m`. The shell parses a double-quoted -m "..." before git sees it: an unescaped " in the body closes the string early, after which a bare & backgrounds the fragment and the message silently truncates (a tell-tale ... command not found may scroll past). Even with the quotes balanced, the shell still expands $var, ` cmd /$(...), and backslashes inside double quotes, so a body containing those is altered. A file or a *single-quoted* heredoc (<<'EOF'`) sidesteps all of it — the text is passed verbatim:
git commit -S --signoff -F - <<'EOF'
fix: prevent race condition in order processing
Body may contain "quotes", & ampersands, `backticks` — all literal.
EOFFooter
# Issue references
fix: resolve login timeout
Fixes #123
Closes #456
# Breaking changes
feat!: update authentication API
BREAKING CHANGE: The `authenticate()` method now returns a Promise
instead of using callbacks. Update all call sites to use async/await.
# Co-authors
feat: implement new dashboard
Co-authored-by: Jane Doe <jane@example.com>
Co-authored-by: John Smith <john@example.com>
# Review references
fix: patch security vulnerability
Reviewed-by: Security Team
Approved-by: @security-leadAtomic Commits
Principles
1. One logical change per commit 2. Each commit should compile/pass tests 3. Related changes grouped together 4. Unrelated changes in separate commits
Examples
# ❌ Bad: Multiple unrelated changes
git add .
git commit -m "feat: add login page and fix typo in readme and update deps"
# ✅ Good: Separate commits
git add src/pages/Login.tsx src/components/LoginForm.tsx
git commit -m "feat(auth): add login page with form validation"
git add README.md
git commit -m "docs: fix typo in installation instructions"
git add package.json package-lock.json
git commit -m "build: update dependencies to latest versions"Interactive Staging
# Stage specific hunks
git add -p
# Options:
# y - stage this hunk
# n - skip this hunk
# s - split into smaller hunks
# e - manually edit hunk
# q - quit
# Stage specific files
git add src/feature/
git commit -m "feat: add feature files"
git add tests/feature/
git commit -m "test: add tests for feature"Commit Templates
Setup
# Create template file
cat > ~/.gitmessage << 'EOF'
# <type>(<scope>): <subject>
# |<---- Using a Maximum Of 50 Characters ---->|
# Explain why this change is being made
# |<---- Try To Limit Each Line to a Maximum Of 72 Characters ---->|
# Provide links or keys to any relevant tickets, articles or other resources
# Example: Fixes #23
# --- COMMIT END ---
# Type can be:
# feat (new feature)
# fix (bug fix)
# docs (changes to documentation)
# style (formatting, missing semi colons, etc; no code change)
# refactor (refactoring production code)
# test (adding missing tests, refactoring tests; no production code change)
# chore (updating grunt tasks etc; no production code change)
# perf (performance improvements)
# ci (CI configuration)
# build (build system changes)
# --------------------
EOF
# Configure git to use template
git config --global commit.template ~/.gitmessageProject-Specific Template
# .gitmessage in project root
# <type>(<scope>): <subject>
# Body: Explain the motivation for the change
# Footer:
# Fixes #issue
# BREAKING CHANGE: description
# ---
# Remember:
# - Use present tense ("add" not "added")
# - Use imperative mood ("move" not "moves")
# - First line max 50 chars, body wrap at 72
# - Reference issues and PRs at the bottomCommit Message Validation
Git Hook (commit-msg)
#!/bin/bash
# .git/hooks/commit-msg
commit_msg_file=$1
commit_msg=$(cat "$commit_msg_file")
# Skip merge commits
if echo "$commit_msg" | grep -qE "^Merge"; then
exit 0
fi
# Conventional commit pattern
pattern="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9-]+\))?(!)?: .{1,50}"
if ! echo "$commit_msg" | head -1 | grep -qE "$pattern"; then
echo "ERROR: Invalid commit message format"
echo ""
echo "Expected: <type>(<scope>): <subject>"
echo " type: feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert"
echo " scope: optional, lowercase with hyphens"
echo " subject: max 50 chars, imperative mood"
echo ""
echo "Your message:"
echo " $(head -1 "$commit_msg_file")"
exit 1
fi
# Check subject line length
subject=$(echo "$commit_msg" | head -1)
if [ ${#subject} -gt 72 ]; then
echo "ERROR: Subject line too long (${#subject} > 72 chars)"
exit 1
fi
# Check for trailing period
if echo "$subject" | grep -qE "\.$"; then
echo "ERROR: Subject line should not end with a period"
exit 1
fi
exit 0commitlint Configuration
// commitlint.config.js
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [2, 'always', [
'feat', 'fix', 'docs', 'style', 'refactor',
'perf', 'test', 'build', 'ci', 'chore', 'revert'
]],
'scope-case': [2, 'always', 'kebab-case'],
'subject-case': [2, 'always', 'lower-case'],
'subject-max-length': [2, 'always', 72],
'body-max-line-length': [2, 'always', 100],
},
};// package.json
{
"husky": {
"hooks": {
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
}
}Semantic Release Integration
Configuration
// .releaserc
{
"branches": ["main"],
"plugins": [
["@semantic-release/commit-analyzer", {
"preset": "conventionalcommits",
"releaseRules": [
{"type": "feat", "release": "minor"},
{"type": "fix", "release": "patch"},
{"type": "perf", "release": "patch"},
{"type": "revert", "release": "patch"},
{"breaking": true, "release": "major"}
]
}],
["@semantic-release/release-notes-generator", {
"preset": "conventionalcommits"
}],
"@semantic-release/changelog",
"@semantic-release/npm",
"@semantic-release/github"
]
}Version Bumping
# These commits determine version bumps:
# PATCH (1.0.x)
fix: correct typo in error message
perf: optimize database query
# MINOR (1.x.0)
feat: add user profile page
feat(api): implement caching layer
# MAJOR (x.0.0)
feat!: redesign authentication system
fix!: change API response format
BREAKING CHANGE: Response format changed from XML to JSONRewriting History
Amending Commits
# Fix last commit message
git commit --amend -m "feat: correct commit message"
# Add files to last commit
git add forgotten-file.js
git commit --amend --no-edit
# Change author
git commit --amend --author="Name <email@example.com>"Interactive Rebase
# Rewrite last 5 commits
git rebase -i HEAD~5
# Commands in editor:
# pick - use commit
# reword - edit message
# edit - stop and amend
# squash - combine with previous
# fixup - combine, discard message
# drop - remove commit
# Example: Squash fixup commits
pick abc1234 feat: add user API
fixup def5678 fixup! feat: add user API
pick ghi9012 feat: add admin APIFixup Commits
# Create fixup commit
git add .
git commit --fixup=abc1234
# Auto-squash during rebase
git rebase -i --autosquash mainBest Practices Summary
1. Write meaningful messages: Future you will thank present you 2. Use conventional commits: Enable automated versioning 3. Keep commits atomic: One logical change per commit 4. Reference issues: Link commits to project management 5. Use scopes consistently: Help with changelog generation 6. Don't include generated files: Keep commits focused on source changes 7. Always sign + signoff: git commit -S --signoff — see next section
Signed Commits + DCO Sign-Off (Required)
Run every commit with both flags explicit:
git commit -S --signoff -m "feat: add login endpoint"Why explicit `-S`. Git honors commit.gpgsign=true only when the configuration is actually loaded. Subprocess environments (CI runners, some IDEs, tools that set their own $HOME or scrub env) can miss the global config — and without the config, git doesn't even try to sign. Git records the commit as unsigned with no error, because from its perspective signing was never requested. Explicit -S pins the requirement to the invocation: now git always attempts to sign, and if the signing agent (gpg-agent, or ssh-agent when using gpg.format=ssh) or its pinentry prompt is unreachable, the commit aborts noisily. You find out now, not when branch protection rejects the push later.
Why `--signoff`. Adds the Signed-off-by: trailer. Required for DCO compliance on any repo that has the DCO check enabled (most netresearch repos do).
Sign-off identity must match `git config user.{name,email}`. Mismatched identities fail the DCO check with an unhelpful "signoff required" error. Validate before the first commit in a new worktree — and specifically check that the values are not swapped (an email address in user.name is a silent misconfiguration that produces a malformed Signed-off-by: trailer):
git config user.name # must look like "Firstname Lastname", NOT an email address
git config user.email # must contain "@", NOT a plain name
# Fix if swapped:
git config --global user.name "Firstname Lastname"
git config --global user.email "you@example.com"SSH signing keys on GitHub: auth keys ≠ signing keys. An SSH key registered under Settings → SSH and GPG keys → Authentication Key cannot verify commits. It must also be added as a Signing Key (same page, different Key type dropdown). GitHub reports unsigned-with-known-key commits as reason: unknown_key in the commits API — identical to an unregistered key. Check before the first push to a repo with verified-signature branch protection:
# Verify the key is registered as a signing key (requires admin:ssh_signing_key token scope):
gh auth refresh -h github.com -s admin:ssh_signing_key
gh api /user/ssh_signing_keys --jq '.[].key'
# Or check commit verification after pushing one commit:
gh api /repos/{owner}/{repo}/commits/HEAD --jq '.commit.verification | {verified, reason}'
# "reason":"valid" → OK
# "reason":"unknown_key" → key not registered as signing key
# "reason":"unsigned" → -S flag not used or signing config missingNever amend a commit with pre-commit-hook failures. If the pre-commit hook fails, the commit did not happen. Running git commit --amend then modifies the PREVIOUS commit, which can destroy work. Fix the hook issue, re-stage, and create a new commit.
Never skip hooks unless explicitly told to. --no-verify bypasses hook enforcement that exists for good reasons. If a hook fails, diagnose the root cause.
Never bypass signing unless explicitly told to. --no-gpg-sign and -c commit.gpgsign=false disable commit signing; the result will fail branch-protection or policy checks that require signed commits later.
Verify signing capability without committing on `main`. To check that signing actually works (right key, agent reachable), do not create a probe commit on the default branch — even an immediately-reset git commit --allow-empty -S on main is a transient commit on a protected branch and violates "no direct commits to main". Inspect configuration instead, no commit required:
git config commit.gpgsign # expect: true
git config gpg.format # ssh (SSH signing) or empty (GPG)
git config user.signingkey # the key/path that will be usedIf you must actually exercise the signing path, do it on a throwaway branch and discard it:
git switch -c tmp/sign-probe
git commit --allow-empty -S -m "chore: signing probe" && git log -1 --format='%G?' # conventional msg so a commit-msg hook won't reject it; expect: G
git switch - && git branch -D tmp/sign-probeAtomic Commits
Each commit should be a single, self-contained logical change that builds and passes tests independently.
Good:
feat: add user authentication endpoint(one feature, complete)fix: correct SAML attribute name mapping(one bug, fixed)chore(deps): bump go-ldap/ldap/v3 from 3.4.8 to 3.4.11(one bump)
Bad:
feat: add auth + fix typo + update deps(three unrelated concerns)wip/fixup(leftover scratch commits)
Rewrite messy history before opening the PR:
git rebase -i main # interactive, squash / reword / reorder
git rebase -i --autosquash main # auto-pick fixup!/squash! commitsPush Upstream on First Push
When pushing a new branch for the first time, set upstream tracking with -u:
git push -u origin feature-branchThis makes subsequent git pull / git push work without specifying remote+branch. Without -u, everyone who clones the branch later has to set it up themselves.
Git Hooks Setup
Why Hooks Matter
Git hooks catch issues before they reach CI — faster feedback, fewer wasted CI runs. For autonomous agents, hooks are essential: they enforce commit message format, prevent secrets, and ensure code quality without requiring the agent to "remember" rules.
Hook Frameworks
| Framework | Language | Config File | Install |
|---|---|---|---|
| lefthook | Go binary | lefthook.yml | go install github.com/evilmartians/lefthook@latest && lefthook install |
| captainhook | PHP | captainhook.json | composer install (auto via plugin) |
| husky | Node.js | .husky/ | npm install (auto via prepare) |
| pre-commit | Python | .pre-commit-config.yaml | pip install pre-commit && pre-commit install |
Detection — One Command
ls lefthook.yml .lefthook.yml captainhook.json .pre-commit-config.yaml .husky/pre-commit 2>/dev/null || echo "No hook framework configured"Then install based on what's found:
lefthook.yml→lefthook install(ormake setup)captainhook.json→composer install(auto).husky/→npm install(auto).pre-commit-config.yaml→pre-commit install- Nothing → suggest adding one based on project language
Recommended Hooks by Stage
pre-commit (fast, <5s)
- Code formatting (gofmt, php-cs-fixer, prettier)
- Import sorting
- YAML/JSON validation
- Secret detection
commit-msg
- Conventional commits validation
- DCO sign-off enforcement
- Minimum message length
pre-push (can be slower)
- Full linting (golangci-lint, phpstan)
- Smoke tests
- Security scanning
Rules for Agents
- NEVER skip hooks with
--no-verify - If a hook fails, fix the underlying issue
- If hooks aren't installed, install them before first commit
- If no hook framework exists, suggest adding one in the PR
Troubleshooting
CaptainHook + git worktrees (FAQ)
- Symptom:
composer installfails with
Shiver me timbers! CaptainHook could not install yer git hooks! (invalid .git path) when run in a secondary git worktree.
- Cause: Git worktrees use a
.gitpointer file (e.g.gitdir: /path/to/bare/worktrees/NAME),
not a directory. captainhook/hook-installer ≤ 1.x does not resolve the pointer correctly and aborts.
- Fix (recommended):
mkdir -p "$(git rev-parse --git-path hooks)" && composer install—
creates the hooks dir at the effective hooks path (honors core.hooksPath if configured, falls back to <git-dir>/hooks otherwise). Works with captainhook's plugin in place, so other Composer plugins (phpstan/extension-installer, TYPO3 composer installers, etc.) continue to auto-register normally.
- Fix (last-resort fallback):
composer install --no-plugins— only if the hooks-dir
workaround above doesn't resolve it. Be aware this disables all Composer plugins for that install, which has broader side effects: phpstan extensions won't auto-register, TYPO3 composer installers won't place extensions, and captainhook itself won't install hooks. Hooks still work in the primary worktree where .git is a real directory.
- When this matters: Repos using a bare-repo + worktrees layout (see
git-worktree(1)) hit this on every composer install in a secondary worktree, since .git is a pointer file rather than a directory.
- Cross-reference: The
netresearch/typo3-ci-workflowsmeta-package bundles
captainhook/hook-installer; its README section "Git Worktree + captainhook Workaround" is the canonical source.
Hooks fail in worktrees / hang on host-unreachable services (FAQ)
- Symptom A:
git commitin a secondary worktree fails with
./bin/captainhook: not found — even though --no-verify was passed to git commit.
- Cause A: hooks installed by the primary checkout run with the worktree as
CWD and reference ./bin/captainhook relatively; the worktree has no vendor//bin/. The commit-level --no-verify has a blind spot: it skips only pre-commit and commit-msg — `prepare-commit-msg` always runs, so a broken hook of that type still fails the commit. (git push has its own --no-verify, which does skip pre-push entirely; the commit-level flag simply does not cover it.)
- Symptom B: a pre-commit hook that runs the test suite hangs forever on
the host because the tests need a docker-only service (e.g. a test DB only resolvable inside the compose network). Killing the runner can leave a zombie process holding the index lock. Note that in a worktree .git is a pointer file, not a directory — the lock lives at $(git rev-parse --git-dir)/index.lock. First confirm no git or hook process is still alive (pgrep -fl 'git|captainhook'); only then remove the stale lock and retry — deleting it under a live process corrupts the index.
- Controlled bypass (the only sanctioned exception to "never skip hooks"):
first run the hook's checks manually via equivalent commands (linters and static analysis on the changed files, the test suite inside its docker environment), then bypass the broken hook explicitly and disclose it: git -c core.hooksPath="$(mktemp -d)" commit -s ... (an empty hooks directory disables all hook types for that one command and — unlike /dev/null — is portable to Windows) and git push --no-verify. Never make the bypass the default; fix the hook environment or commit from the primary checkout when possible.
GitHub Releases Reference
Purpose: Document GitHub release management, immutable releases security feature, and release sequence patterns.
---
Immutable Releases Security Feature
Overview
GitHub Immutable Releases (GA October 2024) is a permanent security feature that prevents tag name reuse after a release is deleted.
Security Purpose: Prevents supply chain attacks where an attacker could: 1. Delete a legitimate release 2. Create a new release with the same version containing malicious code 3. Users downloading "v1.2.3" would get the malicious version
Behavior
| Action | Result |
|---|---|
| Create release v1.2.3 | ✅ Success |
| Delete release v1.2.3 | ✅ Allowed |
| Create NEW release v1.2.3 | ❌ PERMANENTLY BLOCKED |
| Create release v1.2.4 | ✅ Success (new version) |
Key Facts
- Cannot be disabled: No repository setting, API call, or GitHub support request can bypass this
- Permanent: Once blocked, a tag name stays blocked forever
- Per-repository: Each repository has its own blocked tag list
- Applies to: Published releases only (not draft releases)
Detection
# Attempt to create release - if blocked, you'll see:
# "tag_name was used by an immutable release"
gh release create v1.2.3 --notes "test" 2>&1 | grep -i "immutable"---
Release Sequence Patterns
TYPO3 Extension Release
Correct Sequence:
# 1. Create release branch
git checkout -b release/v1.2.3
# 2. Update version in source files
# ext_emconf.php
sed -i "s/'version' => '.*'/'version' => '1.2.3'/" ext_emconf.php
# CHANGELOG.md - add new version section
# 3. Commit version bump
git add ext_emconf.php CHANGELOG.md
git commit -m "chore: bump version to 1.2.3"
# 4. Create PR and merge
git push -u origin release/v1.2.3
gh pr create --title "chore: bump version to 1.2.3"
# Wait for CI to pass
gh pr merge --squash
# 5. Switch to main and verify
git checkout main && git pull
grep "'version'" ext_emconf.php # MUST show 1.2.3
# 6. Create release ONLY after verification
gh release create v1.2.3 \
--title "v1.2.3" \
--notes "Release notes here"Common Mistakes:
| Mistake | Consequence |
|---|---|
| Create release before updating version | Version mismatch, TER/npm publish fails |
| Create release before merging PR | Tag points to wrong commit |
| Delete release to "fix" something | Tag name permanently blocked |
| Rush without verification | Multiple blocked versions |
NPM Package Release
# 1. Update package.json version
npm version patch # or minor/major
# 2. Verify version
grep '"version"' package.json
# 3. Push changes
git push && git push --tags
# 4. Create GitHub release (if using GitHub releases)
gh release create v$(node -p "require('./package.json').version")Python Package Release
# 1. Update version in pyproject.toml or setup.py
# 2. Update CHANGELOG
# 3. Commit and push
git add pyproject.toml CHANGELOG.md
git commit -m "chore: bump version to 1.2.3"
git push
# 4. Create and push tag
git tag v1.2.3
git push --tags
# 5. Create release
gh release create v1.2.3---
Pre-Release Validation
Automated Checks
Add to CI workflow:
- name: Version Consistency Check
run: |
# Extract versions from different sources
EMCONF_VERSION=$(grep -oP "'version' => '\K[0-9]+\.[0-9]+\.[0-9]+" ext_emconf.php)
# For tagged builds, verify tag matches
if [[ "${GITHUB_REF}" =~ ^refs/tags/v ]]; then
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
if [[ "${TAG_VERSION}" != "${EMCONF_VERSION}" ]]; then
echo "::error::Version mismatch! Tag: ${TAG_VERSION}, ext_emconf.php: ${EMCONF_VERSION}"
exit 1
fi
fi
echo "Version check passed: ${EMCONF_VERSION}"Manual Checklist
Before creating ANY release:
[ ] All code changes merged to main/master
[ ] CI pipeline passes on main branch
[ ] Version updated in ALL source files:
[ ] ext_emconf.php (TYPO3)
[ ] package.json (Node)
[ ] pyproject.toml / setup.py (Python)
[ ] composer.json (if version is tracked there)
[ ] CHANGELOG.md updated with new version
[ ] Local main is up to date: git pull origin main
[ ] Version verification: grep -r "version" | grep "1.2.3"
[ ] READY - No second chances after release creation!---
Recovery Procedures
Scenario: TER/npm/PyPI Publish Failed After Release
DO NOT DELETE THE RELEASE!
Instead: 1. Identify the root cause of publish failure 2. Fix the issue in a new commit 3. Update version to NEXT number (skipping the broken version) 4. Create new release with new version
Example:
# v1.2.3 release created but TER publish failed
# DO NOT: gh release delete v1.2.3
# Fix the issue
vim ext_emconf.php # remove strict_types or fix other issues
# Bump to NEXT version
sed -i "s/'version' => '1.2.3'/'version' => '1.2.4'/" ext_emconf.php
# Update CHANGELOG
cat >> CHANGELOG.md << 'EOF'
## [1.2.4] - 2025-01-15
### Fixed
- Fixed TER publishing issue (strict_types in ext_emconf.php)
Note: v1.2.3 was skipped due to publish failure.
EOF
# Commit, merge, then create new release
git add -A && git commit -m "fix: resolve TER publish issue, bump to 1.2.4"
git push && gh pr create && gh pr merge
gh release create v1.2.4 --notes "..."Scenario: Multiple Versions Blocked
If you've blocked v1.2.3, v1.2.4, v1.2.5 through repeated failures:
1. Stop and think - don't create more releases 2. List what went wrong each time 3. Fix ALL issues before next attempt 4. Use next available version (v1.2.6) 5. Document skipped versions in CHANGELOG
## [1.2.6] - 2025-01-15
Note: Versions 1.2.3-1.2.5 are unavailable due to GitHub's immutable
releases feature. These versions were blocked after release deletion
attempts during troubleshooting.
### Fixed
- Resolved TER compatibility issue with ext_emconf.php---
Multi-Branch Releases ("Latest" Badge)
Problem
GitHub assigns the "Latest" badge by creation timestamp, NOT by semantic version. Creating a v11.0.17 release after v13.5.0 will steal the "Latest" badge from v13.5.0.
Rule
ALWAYS use `--latest=false` when releasing from non-default branches (maintenance, LTS, hotfix):
# Releasing from a maintenance branch (e.g., TYPO3_12, TYPO3_11)
gh release create v12.0.5 --latest=false --title "v12.0.5" --notes "..."
# Releasing from the default branch (e.g., main) — omit the flag
gh release create v13.6.0 --title "v13.6.0" --notes "..."When to use --latest=false
| Scenario | Flag |
|---|---|
Release from main / default branch | Omit (default: --latest=true) |
| Release from LTS/maintenance branch | --latest=false |
| Hotfix for older version | --latest=false |
| Pre-release (alpha, beta, rc) | --prerelease (auto-excludes from Latest) |
Recovery
If a maintenance release accidentally became "Latest":
# Manually reassign the "Latest" badge to the correct release
gh release edit v13.5.0 --latest---
Best Practices
Do
- ✅ Update version files BEFORE creating release
- ✅ Verify version with grep before release
- ✅ Use CI checks for version consistency
- ✅ Keep releases - never delete published releases
- ✅ Test publish process in staging first (if possible)
- ✅ Use
--latest=falsefor non-default branch releases
Don't
- ❌ Create release before version is updated in source
- ❌ Delete releases to "fix" issues
- ❌ Rush releases without verification
- ❌ Assume you can recreate a deleted release
- ❌ Create multiple releases hoping one will work
- ❌ Omit
--latest=falsewhen releasing from maintenance branches
---
Resources
- GitHub Blog: Immutable Releases announcement
- GitHub Docs: Managing releases in a repository
- TYPO3 TER: Extension publishing requirements
Merge-Gate Watcher
Canonical polling loop to drive a PR to merge once review threads are handled. Hand-rolling this per PR invites classification bugs (a soft check counted as hard ⇒ false HOLD; a missed one ⇒ premature merge).
Check taxonomy
Classify every failing check BEFORE reacting:
| Class | Examples | Reaction |
|---|---|---|
| Hard | unit/integration/E2E tests, lint, build | HOLD and fix — except known infra flakes (Docker Hub pull timeout, buildx setup): one gh run rerun <id> --failed |
| Soft, self-healing | codecov/* while sibling jobs still run (partial uploads) | Ignore while pending > 0; if persisting after completion: one full gh run rerun <id> |
| Soft, structural | SonarCloud PR gate on refactor PRs | Introspect before deciding (below) |
Sonar gate introspection
Never merge on a red Sonar gate without knowing why it is red:
AUTH="Authorization: Bearer $SONAR_TOKEN"
curl -s -H "$AUTH" "https://sonarcloud.io/api/qualitygates/project_status?projectKey=$KEY&pullRequest=$PR" \
| jq -r '[.projectStatus.conditions[]|select(.status!="OK")|.metricKey]|join(",")'
curl -s -H "$AUTH" "https://sonarcloud.io/api/issues/search?componentKeys=$KEY&pullRequest=$PR&resolved=false&ps=1" | jq .totalMerge-despite is defensible only when the sole failing condition is a touched-line re-attribution metric (new_duplicated_lines_density, patch coverage on refactor-moved lines), open PR issues are 0, and the PR body documents the rationale. Real findings: fix them.
Watcher skeleton
R=owner/repo; PR=123; BR=branch; RERUN_DONE=0
for i in $(seq 1 100); do
sleep 30
STATE=$(gh pr view $PR --repo $R --json state,mergeStateStatus) || continue
[ "$(jq -r .state <<<"$STATE")" = "MERGED" ] && exit 0
MS=$(jq -r .mergeStateStatus <<<"$STATE")
UNRES=$(gh api graphql -f query="{repository(owner:\"${R%/*}\",name:\"${R#*/}\"){pullRequest(number:$PR){reviewThreads(first:100){nodes{isResolved}}}}}" \
--jq '[.data.repository.pullRequest.reviewThreads.nodes[]|select(.isResolved|not)]|length') || continue
CHECKS=$(gh pr checks $PR --repo $R 2>/dev/null)
PENDING=$(grep -c -E "pending|in_progress" <<<"$CHECKS" || true)
HARD=$(grep "fail" <<<"$CHECKS" | grep -v -c -E "codecov|SonarCloud Code Analysis" || true)
SOFT=$(grep "fail" <<<"$CHECKS" | grep -c -E "codecov|SonarCloud Code Analysis" || true)
[ "$MS" = "BLOCKED" ] && [ "$UNRES" -gt 0 ] && { echo "HOLD: $UNRES threads"; exit 1; }
if [ "$HARD" -gt 0 ] && [ "$PENDING" -eq 0 ]; then
# one rerun for infra flakes only, then HOLD
if [ "$RERUN_DONE" -eq 0 ] && grep "fail" <<<"$CHECKS" | grep -qE "E2E|Integration|docker"; then
gh run rerun "$(gh run list --repo $R --branch $BR --workflow CI --limit 1 --json databaseId --jq '.[0].databaseId')" --repo $R --failed
RERUN_DONE=1; sleep 60; continue
fi
echo "HOLD: hard fails"; grep fail <<<"$CHECKS"; exit 1
fi
if [ "$PENDING" -eq 0 ] && [ "$UNRES" -eq 0 ] && { [ "$MS" = "CLEAN" ] || [ "$MS" = "UNSTABLE" ]; } && [ "$HARD" -eq 0 ] && [ "$SOFT" -eq 0 ]; then
gh pr merge $PR --repo $R --merge && exit 0
fi
donePitfalls baked in: grep -c exits 1 on zero matches (|| true); decide hard-fail only at PENDING -eq 0 (codecov posts transient FAILURE mid-run); never count a check class you did not explicitly list.
Two facts the loop depends on
`gh run rerun` reuses the original `GITHUB_SHA`. For pull_request events that is the merge commit computed at first run — a rerun after a base-branch fix still tests against the broken base. Rerun is only for flakes; to pick up a repaired base, rebase the branch and push.
Review bots converge over multiple rounds. Every push invalidates the review (ruleset copilot_code_review needs a fresh review on the latest head), so re-request after each push: gh api repos/$R/pulls/$PR/requested_reviewers -X POST -f 'reviewers[]=copilot-pull-request-reviewer[bot]'. Later rounds may flag UNCHANGED lines adjacent to the diff (latent legacy bugs) — triage each finding on its merits; expect 3–6 rounds on large refactor PRs, with finding severity decreasing per round. Re-arm the watcher after every push.
Related skills
How it compares
Pick git-workflow over ad hoc git prompts when agents must follow team Conventional Commits, PR templates, and CI merge gates instead of improvising merge commands.
FAQ
What branching models does git-workflow support?
git-workflow documents Git Flow with feature, release, and hotfix branches, GitHub Flow with simple feature branches, and trunk-based development. The skill's branching-strategies.md reference guides pattern selection and release management.
What reference docs ship with git-workflow?
git-workflow bundles five references: branching-strategies.md, commit-conventions.md, pull-request-workflow.md, ci-cd-integration.md, and advanced-git.md. Agents load the relevant file based on triggers for commits, PRs, or CI tasks.
What does the git-workflow /pr-finish command do?
git-workflow's /pr-finish slash command automates rebasing, resolving PR review threads, verifying CI, and merging pull requests. The skill requires resolved threads, passing CI, and rebased branches before merge completion.