Git Commit (Safety Enhanced, Angular Convention)
Create a commit from the current working tree with safety gates first, then generate a concise Angular-style message in English.
Maintaining this skill (not needed to run it): references/design-evidence.md records the measured evidence behind every contract below and the verified environment matrix; scripts/run_cross_env_probe.sh, scripts/run_portability_matrix.sh and scripts/eval/ re-derive it.
Hard Rules
- Never commit with unresolved conflicts.
- Never commit secrets, credentials, keys, or
.envsensitive values. - Subject line must fit the repo's limit — default
<= 50 charstotal (includingtype(scope):). A commitlint/CONTRIBUTINGlimit discovered in §5 replaces that default; the §6 guard enforces whichever applies. There is no separate absolute 50. - Do not use
--amendunless explicitly requested. If already pushed, warn about force push. - If any safety gate fails, stop and report clearly.
- One commit = one logical change. Do not mix unrelated fixes, features, or formatting.
Workflow
Bundled scripts are always invoked as bash "<path-to-skill>/scripts/<name>.sh" — <path-to-skill> is the absolute skill directory (where this SKILL.md was loaded from), never a path relative to the repository. Keep the working directory at the target repo root; the scripts read the repo through git.
1. Preflight
git rev-parse --is-inside-work-tree # must print "true"
git -c core.quotePath=false status --short # quotePath=false: see the raw path
BRANCH=$(git rev-parse --abbrev-ref HEAD) # "HEAD" means detached (allowed)
# In-progress operation? Worktree-safe: resolve real paths, never read .git/ directly.
for state in rebase-merge rebase-apply MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD; do
[ -e "$(git rev-parse --git-path "$state")" ] && echo "IN_PROGRESS $state"
done
# Unresolved conflicts: this diff exits 0 regardless, so act on OUTPUT, not exit code.
git -c core.quotePath=false diff --name-only --diff-filter=U
STOP (hard block — report which one and what to resolve) if any of these hold:
--is-inside-work-treedid not printtrue;- any
IN_PROGRESS <state>line printed (rebase / merge / cherry-pick / revert in progress); - the conflict diff printed any path (unresolved merge conflicts).
A detached HEAD is not a stop condition — proceed and note it in the report.
2. Staging
- Snapshot what is already staged first:
git -c core.quotePath=false diff --cached --name-only. If files are already staged that the user did not ask to commit, STOP and ask — commit them together, keep them staged for later, or split. Never silently fold pre-staged files into this commit. - If the user names exact files, stage those only with
git add -- <paths>(the--keeps a path that starts with-from being parsed as an option). - If the user says "commit my changes", start from
git -c core.quotePath=false status --short. Use-c core.quotePath=falseon any git command whose paths you show the user or pass back to git: the default escapes non-ASCII paths to"\346\272\220…", unreadable and not a pathgit add --accepts. - Count changed paths. If > 8 files, list the full file set and confirm before staging — unless the user already authorized that exact set (named the files, said "commit all of these", or confirmed it earlier in this session). The prompt resolves scope ambiguity; it is not a second ask for a question already answered.
- If
<= 8files, read the diffs and split by logical intent. If one file mixes intents, usegit add -p. - Stage task-related untracked files only when they clearly belong to the same change.
- If unstaged or untracked changes remain after staging, the quality gate MUST run through the isolation wrapper (§4) so it sees exactly the staged snapshot and your changes are restored on every exit path.
- Verify staging with
git diff --cached --stat. If nothing is staged, stop. - Run
git diff --cached --submodule=short. If a submodule pointer changed, confirm it is intentional.
3. Secret/sensitive-content gate
bash "<path-to-skill>/scripts/secret-scan.sh"
Uses the repo's gitleaks when installed, and always runs a built-in regex fallback over added staged lines only — removing a secret is never blocked. Exit code: 0 = the scan completed (clean, or findings printed for triage — "no secret" is never a gate failure); 2 = it could not complete, and every stage that failed names itself. Treat 2 as a gate failure, never as clean. Output lines:
SECRET_CANDIDATE: <file>:<line>: <redacted>— real source line numbers, cut at the match so only its first 4 chars survive.CONTEXT: <file>:<line>: <text>— 2 lines around each candidate, cut by the same rule.
A line is reported when it holds a known credential shape, or a sensitive key name followed by : or = (case-insensitive). It is cut at the earliest of those or any 20+ char token-ish run. Residual risk: a short literal not attached to a sensitive key still prints — context is a triage aid, not a sanitiser; do not paste it into a public channel.
ALLOWLISTED: …— matched a glob in the committed.commit-secret-allowlist(HEADversion; a staged-but-uncommitted allowlist is ignored).SENSITIVE_FILE: <path>— staged filename that is key material by name (.env,id_rsa,*.pem, …).SCANNER_ERROR: …— gitleaks misconfiguration or crash (script exits 2); the regex fallback still ran but is NOT equivalent coverage — fix the scanner or get explicit user sign-off.
Triage every match. Path/type never auto-dismisses a match — real keys do get committed into tests and docs. Only a committed allowlist hard-dismisses; everything else lowers confidence and is still surfaced.
| # | Filter | Effect |
|---|---|---|
| 1 | ALLOWLISTED: line (committed .commit-secret-allowlist glob) |
Hard-dismiss — but confirm the entry's scope/provenance; treat an unfamiliar allowlist as untrusted |
| 2 | Test/fixture path (/test(s)/, /__tests__/, /spec/, /mock(s)/, /fixture(s)/, /example(s)/, /testdata/, /snapshot(s)/, or a common test suffix) |
Downgrade to low confidence — still report |
| 3 | Documentation file (.md, .rst, .adoc, .mdx) |
Downgrade — still report |
| 4 | Comment line (after stripping +/whitespace: //, # not #!/, --, /*, *, %, ;;, REM ) |
Downgrade — still report |
High-confidence matches are blockers; downgraded matches need a human decision (never silently dropped). Binary files and high-entropy strings are out of scope for the regex fallback — rely on gitleaks/detect-secrets for those.
4. Quality gate
Detect ecosystems — extensions and dependency-manifest markers, so staging only go.mod or package.json still selects the right gate:
bash "<path-to-skill>/scripts/detect-ecosystems.sh" # one per line, most staged files first
Deletions count. Empty output = none; exit 2 = unknown — never treat exit 2 as "nothing to gate".
go→ loadreferences/quality-gate-go.md;node→references/quality-gate-node.md;python→references/quality-gate-python.md;java→references/quality-gate-java.md;rust→references/quality-gate-rust.md- Run the gate for EVERY detected ecosystem, largest first — a 5-Go-file + 1-TS-file stage runs both the Go and the Node gates (scope each gate to its own files where the reference allows). Never skip a minority ecosystem.
- The marker list is not exhaustive. Detector prints nothing but the stage clearly belongs to an ecosystem (an unlisted marker such as an uncommon lockfile) → pick that gate by judgment and say so in the report; otherwise try
make test/make check, else reportquality gate: not detected. - Makefile wrappers take precedence over raw ecosystem commands.
- If a check fails, stop and report it. The user may explicitly skip; report
quality gate: skipped by user. - Timeout (enforced, not advisory): default 120 seconds per gate command (each invocation bounds one command). If the repo wrapper or environment sets
COMMIT_TEST_TIMEOUT,QUALITY_GATE_TIMEOUT_SECONDS, orSKILL_QUALITY_GATE_TIMEOUT_SECONDS, use it and report the chosen timeout before running long tests. Exit124= timed out (one code on every host); exit 2 = the timeout could not be enforced, so no gate ran. Both block the commit.bash "<path-to-skill>/scripts/run-gate.sh" [-t <repo-wrapper-seconds>] <gate command> - Isolation (guaranteed restore): when unstaged or untracked changes are present, wrap the enforcer in the stash guard so those changes are stashed and restored exactly once on every exit path — gate pass, gate failure, timeout, or interrupt (SIGINT/SIGTERM exit 130/143 after restore, never 0):
Its exit code is the gate's. Every non-zero path preserves the stash and prints recovery instructions (bash "<path-to-skill>/scripts/stash-guard.sh" bash "<path-to-skill>/scripts/run-gate.sh" make testgit stash apply --index <OID>) — data is never lost, but a restore conflict can leave a partial worktree, so follow the instructions rather than assume a clean tree. It fails closed on both isolation edges: unstashable state (dirty submodule content) → exit 2 without running the gate; tracked-file drift while the gate ran → keeps the stash, refuses the reset.
5. Compose commit message
First discover the repo's own convention — it overrides the defaults below:
- commitlint config (
.commitlintrc*,commitlint.config.*, or acommitlintkey inpackage.json) → follow itstype-enum, case, and length rules. .gitmessagetemplate (git config --get commit.template), or commit rules inCONTRIBUTING/AGENTS.md→ follow them.- If a convention is found, adopt its type set, language, and subject length in place of the Angular / English / 50-char defaults. If none is found, use the defaults.
- Carry the discovered subject-length limit into the §6 guard as
SUBJECT_MAX(default 50) so the executable check enforces the repo's actual limit, not a hardcoded 50.
Scope discovery — these rules are mechanical, so run them rather than re-deriving them:
bash "<path-to-skill>/scripts/resolve-scope.sh" # SCOPE: <name>|(none) + SCOPE_SOURCE:
canonical— a scope used >= 3 times in the last 50 commits that matches a staged path and is not contradicted by a second canonical scope. A stage spanning two known scopes is a mixed commit: scope is omitted, never resolved to whichever is more frequent. A staged path matching no canonical scope (CHANGELOG.md) does not block.bootstrap— fewer than 10 conventional commits total, so the convention is not established; the scope comes from the staged directories with generic containers (src,pkg,internal,services,tests, …) stripped, and only when one stable directory remains.omitted— use<type>: <subject>. Exit 2 = the repo could not be read: omit the scope and say so.- Never invent a scope from filenames or issue text. If the answer looks wrong for this change, omit — do not substitute your own.
- Format is
<type>(<scope>): <subject>or<type>: <subject>; for a breaking change use<type>!:and/or aBREAKING CHANGE: <what>footer. - Subject must be imperative, no trailing period, and <= 50 chars (or the repo's discovered limit); body explains why and wraps at 72 chars.
- Add required trailers when repo policy or the user asks:
Signed-off-by:(git commit -s),Co-authored-by:, and issue refs (Closes #123).
6. Commit
Single-line commit:
SUBJECT='<type>(<scope>): <subject>'
SUBJECT_MAX=${SUBJECT_MAX:-50} # 50 default; set from the repo convention (§5)
[ ${#SUBJECT} -le "$SUBJECT_MAX" ] || { echo "subject too long (${#SUBJECT}/$SUBJECT_MAX)"; exit 1; }
case "$SUBJECT" in *.) echo "subject must not end with ."; exit 1 ;; esac
git commit -m "$SUBJECT"
Multi-line commit — the heredoc is the single source; the guard checks the exact first line that will be committed (no separate SUBJECT variable to drift):
SUBJECT_MAX=${SUBJECT_MAX:-50} # 50 default; set from the repo convention (§5)
MSG=$(cat <<'EOF'
<type>(<scope>): <subject>
<body — explain why, wrap at 72 chars>
<footer>
EOF
)
SUBJECT=${MSG%%$'\n'*}
[ ${#SUBJECT} -le "$SUBJECT_MAX" ] || { echo "subject too long (${#SUBJECT}/$SUBJECT_MAX)"; exit 1; }
case "$SUBJECT" in *.) echo "subject must not end with ."; exit 1 ;; esac
printf '%s\n' "$MSG" | git commit -F -
Prefer git commit -F - for any multi-paragraph body. Multiple -m flags do work — each becomes its own paragraph — but give no control over 72-char wrapping within a paragraph and are quoting-fragile for footers, so -F - is the reliable choice.
Hook awareness:
- If commitlint, pre-commit, husky, or lefthook rejects the commit, read the error and adapt the message.
- Never use
--no-verifyunless explicitly requested. - Report the hook name and the adjustment made.
7. Post-commit report
git rev-parse --short HEAD
git show -s --format='%h %s' HEAD # hash + subject
git -c core.quotePath=false show --name-status --format= HEAD # changed files (NOT --no-patch: it disables --name-status)
Report the hash, final subject, changed files summary, and quality gate status.
Message Examples
fix(account): guard nil balance map before mergerefactor(service): simplify aggregation flowtest: add case for tail-mapping loss
fix(auth): prevent token refresh race condition
Two concurrent requests could both trigger a refresh, causing one to
use an invalidated token. Add mutex to serialize refresh calls.
Closes #245
Edge Cases
--allow-empty: only if the user explicitly requests it. Note it in the post-commit report.- If the file at
git rev-parse --git-path SQUASH_MSGexists (worktree-safe — never read.git/directly), warn that this may be a squash-merge residual state. - Submodule pointer changes are never auto-assumed safe.
Failure Handling
- No staged changes → stop and report.
- Safety gate failure → stop and report the exact blocker.
- Commit failure → report the git error and leave staging untouched.