| name | commit-push |
|---|---|
| model | sonnet |
| description | Commit staged/unstaged changes and push to origin with an Angular-style commit message. Auto-detects when changes span multiple cohesive scopes and proposes splitting them into multiple commits; override with `--single`/`-1` or natural-language phrases like "as one commit" or "single commit". File scoping (explicit `@file` args, `--select` interactive picker) and secret-file filtering apply on every path, including the override. Trigger this skill whenever the user wants to commit, check in, save, or wrap up their work — including phrases like "commit and push", "commit this", "check in these changes", "save my work", "wrap this up", "/commit-push", "push my changes", or after completing any task that produced file changes the user will want to land. Also trigger when the user wants to choose, select, or pick which changed files to include in a commit, or mentions splitting commits by scope. Accepts optional file arguments to scope the commit, or interactive selection to pick files from the working tree. |
This skill ends with git push. If the Claude Code environment denies that call — common configurations: defaultMode: "plan", skipAutoPermissionPrompt: true, strict sandboxes like Conductor — add an allow entry to ~/.claude/settings.json (user-level) or .claude/settings.json (project-level) once and the denial never fires again.
Pattern matcher refresher. Claude Code's Bash-permission entries use two shapes:
"Bash(cmd)"— exact-match. Authorizes only the literal commandcmdwith no additional arguments."Bash(cmd:*)"— prefix-match glob. Authorizes any command starting withcmdfollowed by any arguments (the:*is the wildcard suffix, not a literal:*argument).
Which you want depends on how much destructive-push friction you're willing to accept. Pick one of the two below.
Merge, don't replace. The snippets below show the entries to add to your existing permissions.allow array. Most users already have a populated settings.json; pasting a top-level { "permissions": ... } object as a whole-file replacement would clobber existing settings (or produce invalid JSON if pasted inline next to an existing top-level object). Merge these entries into the existing array. The whole-file fallback form at the end of each option is safe only when the target settings.json does not exist yet or is empty ({}). A file that has any top-level keys — even unrelated ones like hooks, env, model, or statusLine — must be merged, not replaced; a whole-file paste would drop every sibling key. "No permissions key yet" is not a sufficient condition for the whole-file form.
Covers just the bare git push that step 7 invokes, plus common first-push bootstrapping (git push -u origin HEAD). git push --force, git push --force-with-lease, and any other variant remain permission-gated.
Add these entries to permissions.allow:
"Bash(git push)",
"Bash(git push -u origin HEAD)"Whole-file fallback (only if settings.json does not exist yet or is {} — see the merge-don't-replace note above for why "no permissions key yet" is not the right condition):
{ "permissions": { "allow": ["Bash(git push)", "Bash(git push -u origin HEAD)"] } }Trade-off: does not authorize git push origin main, git push origin <some-branch>, or any other explicit-ref form. If that matters for how you drive the skill, add those entries explicitly or use Option B.
Authorizes any git push invocation. Simpler and covers every form in one line.
Add this entry to permissions.allow:
"Bash(git push:*)"Whole-file fallback (only if settings.json does not exist yet or is {}):
{ "permissions": { "allow": ["Bash(git push:*)"] } }Trade-off: also authorizes git push --force, git push --force-with-lease, git push --delete <ref>, etc. Use only if you want push-in-general to be friction-free and trust the skill (and yourself) not to misuse it.
Caveat: you cannot reliably re-gate destructive push forms with a deny list under prefix-glob matching. An earlier version of this section recommended adding Bash(git push --force:*), Bash(git push -f:*), and Bash(git push --delete:*) to permissions.deny to carve out destructive variants from Option B's allow wildcard. That recipe is leaky and should not be trusted: prefix-glob entries anchor at the start of the command string, so they only match flag-first forms. Git accepts the same flags in remote-first form:
git push origin --delete feature/delete-me # not matched by Bash(git push --delete:*)
git push origin --force-with-lease HEAD:main # not matched by Bash(git push --force:*)
git push origin --force main # not matched by Bash(git push --force:*)
git push origin main -f # not matched by Bash(git push -f:*)
(The flag-first forms git push --force ..., git push --delete ..., and git push -f ... are caught by the corresponding deny entries, because those commands do start with the deny prefix. It's only when the remote argument comes first — which git accepts everywhere — that the prefix-glob fails to match.)
These remote-first commands match Bash(git push:*) allow and bypass the deny entries entirely — the destructive push goes through without prompt. There is no prefix-glob pattern that catches --force / --delete / -f at arbitrary argument positions, because the matcher doesn't parse flags.
If you need destructive-push gating, use Option A and explicitly list the non-destructive ref-pushing forms you use (e.g., add Bash(git push origin main) and Bash(git push origin <other-branch>) as needed). Option A's exact-match entries authorize only the literal commands you list, so git push --force and any other unlisted form remain permission-gated by default — no deny list needed.
Per-push explicit approval still works — but environments with skipAutoPermissionPrompt: true silently deny rather than prompting. In those environments either one of the options above or flipping skipAutoPermissionPrompt off is required for the push to succeed without shell-out.
-
Run
git status(never use-uall),git diff HEAD, andgit log --oneline -5in parallel.1a. Push-only fast path. If
git statusreports a clean working tree, inspect the branch's relationship to its upstream and take the matching case below:- Branch has upstream and is ahead (status line
Your branch is ahead of 'origin/<branch>' by N commit(s).): skip steps 2–6 entirely and jump to step 7 to push the unpushed commits. This is the normal recovery after a step 7a push denial on a tracked branch: the user added the permissions entry from step 7a, re-invoked/commit-push, and the local commits are already there — only the push needs to run. - Branch has no upstream configured (verify with
git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null; the command fails when no upstream is set). Compute the count of commits that this branch would newly publish to origin viagit rev-list --count HEAD --not --remotes=origin(commits reachable from HEAD but not from anyrefs/remotes/origin/*ref). If the count is0, the branch has nothing unique to publish — every commit on HEAD already exists on some origin ref. Exit without pushing; report the state clearly (e.g., "clean working tree; no commits unique to origin — nothing to publish") so the user understands why. Creatingorigin/<branch>in this case would pollute the remote with an empty branch pointing at the same SHA as an existing ref, which is worse than a silent exit. If the count is>0, treat this as a first-push recovery path: skip steps 2–6 and jump to step 7, which will push withgit push -u origin HEADto establish tracking and publish the unique commits in one step. This is the recovery path for the common case where the initialgit pushin a prior/commit-pushrun was denied before upstream was ever set — re-invocation on a clean tree with real commits must not exit silently, because those commits are still stranded on the client. - Branch has upstream but is behind, diverged, or up-to-date: there is nothing to push. Report the clean state and exit, propagating the relevant
git statusline — e.g.,Your branch is behind 'origin/<branch>' by N commit(s).(user may want to pull before starting new work) orbranches diverged(likely needs rebase/merge) — so the user isn't left guessing why the skill exited silently. This skill does not pull, rebase, or set upstream on the user's behalf.
- Branch has upstream and is ahead (status line
-
Pre-flight: determine the candidate file set, apply the secret filter, and pick a commit-count mode — three independent concerns, intentionally decoupled so overrides can't bypass the secret filter.
a) Candidate file set (files eligible for committing):
- Explicit file args (e.g.,
@file1 @file2): candidate set = those files. --select/-i: interactive picker. Rungit status --porcelain, then present a numbered list viaAskUserQuestionwith each file's M/A/D/?? indicator, e.g.:Parse comma-separated numbers and dash ranges. Empty/all-invalid selection → abort and explain. Out-of-range numbers → warn and use only the valid ones. Picked files = candidate set.Select files to include: 1. M src/auth/login.ts 2. M src/auth/session.ts 3. ?? tests/new_test.py Type the numbers to include (e.g. 1,3,5 or 1-3,5):- Otherwise: candidate set = all changed files in the working tree (modified, staged, untracked).
- Conflict warning + precedence: if both
--selectand explicit files are passed, warn and use the explicit files. When multiple file-scoping signals are present, precedence is: explicit@fileargs >--select/-i> the default "all changed files" path. - Pre-existing staging warning: if
git statusshows files already staged (e.g., fromgit add -p), warn before proceeding — the per-commit loop in step 5 resets the index before staging each group, which discards any partial staging.
b) Secret filter (applied to the candidate set on every path — including when
--single,-1,--select, or explicit@fileargs are used; the filter cannot be bypassed by any flag):- Exclude files that likely contain secrets:
.env/.env.*, private keys (*.pem,*.key,*.p12,id_rsa*,id_dsa*,id_ecdsa*,id_ed25519*— the.pubsiblings are public and OK), credential files (credentials*,*credentials.json,aws_credentials*), and any file whose name containstokenorsecretunless it's clearly a safe fixture (e.g., a test data file the user obviously meant to commit). - When in doubt, exclude and tell the user.
- List any excluded files explicitly in your reply so the user knows what was omitted. For false positives (e.g., a genuine test fixture whose filename matches the secret pattern), the workarounds are: rename the file, adjust the filter rules above, or commit manually outside this skill. No flag re-includes a filtered file.
c) Commit-count mode:
- Single-commit mode if any of the following:
- Invocation contains
--single,-1, or one of these unambiguous multi-word phrases — "single commit", "as one commit", "as a single commit", "commit this as one", "all together" — → acknowledge the override in your reply. Bare tokens like "as one" or "one commit" alone are not sufficient; they appear too often in unrelated prose ("commit this as one last sanity check", "this should be one commit per scope") and would false-positive. The phrases above all force the user to express override intent explicitly. - The user used
--selector explicit@fileargs (manual scoping signals "this is one commit's worth of files"; preserves backward-compat with how the picker has always worked).
- Invocation contains
- Auto-detect mode otherwise.
In single-commit mode: skip steps 3–4 entirely; jump to step 5 with the secret-filtered candidate set as one group.
- Explicit file args (e.g.,
-
Auto-detect cohesive groups in the candidate set. Apply rules in order; first match wins per file:
- Skill subdirectory: files under
skills/<name>/...group by<name>(e.g.,spring-init,commit-push). - CI/infra directory: files under
.github/...group as scopeci. This rule fires before the generic top-level-directory rule below so.githubdoesn't end up as a literal directory scope; thecimapping reflects what these files actually configure. - Top-level directory: files under any other top-level
<dir>/...group by<dir>(e.g.,hooks,docs). - Root files (no directory prefix): infer scope per file —
Makefile/Dockerfile/package.json/pyproject.toml/Cargo.toml/build configs →build;README*and top-level*.md→docs;LICENSE*→chore; others →chore. Then group root files by inferred scope, so multiple root files sharing a scope form one group while files with different scopes form separate groups (e.g., changingMakefileandDockerfile→ onebuildgroup; changingMakefileandREADME.md→ two groups:buildanddocs).
Cross-rule scope merging: groups are keyed by their resolved scope name, not by the rule that produced them. So if rule 3.3 yields a
docsgroup fromdocs/README.mdand rule 3.4 yields adocsscope from a root-levelREADME.md, those merge into a singledocsgroup spanning both files (onedocs(docs): ...commit — the outerdocsis the Angular type, the innerdocsis the resolved scope name). Same forci(e.g.,.github/workflows/test.ymlfrom rule 3.2 plus a hypothetical root.codecov.ymlmapped toci→ oneci(ci): ...commit). This keeps commit boundaries deterministic when scope names collide across rules.Then refine for tests: scan files matching
tests/...,test/...,__tests__/..., ortest_*.{ext}/*_test.{ext}patterns. Re-attribute each test file to a non-test scope when possible (impl + its tests should be one cohesive commit, not two): a. Path-component match:tests/<scope>/...(e.g.,tests/spring-init/...→spring-init), or filename containing a scope keyword that exists in the candidate set (e.g.,tests/test_hooks.shwhenhooksis a non-test scope →hooks). b. Sole non-test scope: if exactly one non-test scope exists in the candidate set, attribute all unmatched test files to it. c. Otherwise: leave unmatched test files in their owntestsgroup.Trade-off note: rule 3b ("sole non-test scope") means adding a second non-test scope to a previously-merged changeset can split test files off into their own group (because rule 3b no longer applies). This is intentional — arbitrary attachment to one of two impl scopes would be more surprising than a clean separation. If the user wants a particular test file to land with a particular impl group in a multi-scope changeset, they can pre-stage with
--selector useAdjust groupings(step 4b) to define the grouping explicitly.- If only 1 group → silently jump to step 5.
- If 2+ groups → continue to step 4.
- Skill subdirectory: files under
-
Propose the split via
AskUserQuestion. The prompt must:- Preview each proposed commit with: scope, files (M/A/D/?? indicators), and a draft
type(scope): subject(infertypefrom the diff: new behavior →feat, bugfix →fix, tests-only →test, docs-only →docs, config/build/CI →chore/build/ci; if mixed within a group, pick the dominant type — do not subdivide further). - Include a cap warning if there are >5 groups: "This change spans N groups, which is unusually scattered. Consider combining or adjusting groupings." Surface the Combine and Adjust options prominently.
- Offer exactly these 3 options:
Yes — create N commits as proposedCombine into a single commitAdjust groupings (custom)
- Routing:
- Yes → step 5 with the proposed groups in order.
- Combine → step 5 with all candidate files as one group.
- Adjust groupings → step 4b.
4b. Custom groupings sub-flow (only entered from "Adjust groupings"). Iteratively let the user define groups until every candidate file is assigned — this differs from
--select, which only produces a single set:- Maintain a list of unassigned files (initially: the entire candidate set).
- Loop:
- Use
AskUserQuestionto present unassigned files (numbered, with M/A/D/?? indicators). Offer:- Numeric input (e.g.,
1,3,5or1-3,5) → those files form the next group; mark them assigned. All remaining files in one final commit→ all unassigned become the last group; exit loop.Cancel and return to the proposal→ discard any in-progress custom groupings and return to step 4's 3-option proposal.
- Numeric input (e.g.,
- Continue until unassigned is empty.
- Use
- Once every candidate file is assigned → step 5 with the user-defined groups in creation order.
- Preview each proposed commit with: scope, files (M/A/D/?? indicators), and a draft
-
Per-commit loop. For each group (in order). Each iteration creates a NEW commit:
- Clear the index:
git restore --staged :/(the:/pathspec anchors to the repo root so this is CWD-independent;git reset HEADis an acceptable fallback on older Git). Do not use baregit restore --staged .— it's CWD-relative and leaves staged files outside the current subtree. Then stage only this group's files viagit add <file>.... Prefer explicit file args; nevergit add -Aorgit add .. The candidate set has already passed the secret filter (step 2b), so no further secret check is needed here. - Draft an Angular-style commit message:
- Format:
type(scope): subjectwith a body of bullet-point details. - Types:
feat,fix,docs,refactor,perf,test,style,build,ci,chore. - Subject: lowercase, imperative, no trailing period.
- Body: each bullet starts with
"- ", describing a concrete change. - Match the style of recent commits in the repo.
- Always pass the message via a HEREDOC.
- Format:
- Run
git commit(create a NEW commit, never--amend). - If a pre-commit hook fails for this iteration, attempt a bounded recovery:
a. Determine a fixer from the hook's stderr + the affected file types — e.g.,
prettier --writefor JS/TS formatting,ruff formatorblackfor Python,gofmt -wfor Go,markdownlint-cli --fixfor markdown,shfmt -wfor shell. If the hook is a spec check with no auto-fix equivalent (e.g.,pytest,jsonschema, integrity/hash checks, custom validators), skip directly to step 4c. b. Retry up to twice (initial attempt + at most 2 fixer-driven retries — total ≤3git commitattempts per iteration). Each retry: run the fixer, re-stage only this group's files viagit add <file>...(do NOT pull in files from already-committed earlier groups), rungit commitagain with the original message. Two retries absorbs the case where a fixer needs multiple passes (e.g.,eslint --fixresolving cascading auto-fixes). Every retry creates a NEW commit — never use--amend, because earlier iterations' commits must be preserved exactly. c. If no fixer can be determined OR all retries fail: mark this group as FAILED and abort the loop. Do NOT attempt remaining groups. Do NOT run step 6 (reconciliation). Do NOT run step 7 (git push). Earlier successful per-group commits remain on the local branch; the user will resolve the hook error manually (reading the stderr output) and re-invoke/commit-pushto handle the remaining work. Jump directly to step 8 with the failure context (failing group, hook stderr, unprocessed groups).
- Clear the index:
-
Reconcile cross-group hook auto-fixes via a delta against the pre-loop snapshot. Skip step 6 entirely if step 5.4c fired — the delta rule below assumes every candidate file was staged and committed during the loop, which isn't true after an abort. On abort, leave the working tree as-is for the user to inspect and jump to step 8. Otherwise, proceed: repo-wide pre-commit hooks (formatters, linters that touch every matching file in the tree, not just staged ones) sometimes auto-fix files that belong to other groups — those edits remain unstaged after their iteration ends because step 5.1 only stages the current group's files. Without reconciliation, those edits would either silently disappear from the run or pollute the next session. However, the user may also have had unrelated unstaged changes in their working tree before invoking the skill — those must NOT be swept into a reconciliation commit. To distinguish:
-
Before entering step 5's loop, snapshot the working-tree state via
git status --porcelain(capture the output in memory or a temp file). Normalize the snapshot so it matches what post-loopgit status --porcelainwould show for the same pre-loop state assuming no hooks ran — step 5.1's index-cleargit restore --staged :/unstages everything, which is what the normalized form represents. Apply these per-line rewrites: Porcelain v1 format isXY<SP><path>— X is the index status (position 1), Y is the worktree status (position 2), followed by one separator space before the path. So a staged-only modification renders asM <path>(M, Y-space, separator-space, path), and a worktree-only modification asM <path>(X-space, M, separator-space, path). The rules below preserve that two-column + separator spacing so the normalization can be applied mechanically to literalgit status --porcelainoutput:M <path>→M <path>(column swap;<path>kept). HEAD has<path>; working tree still has the modification.D <path>→D <path>(column swap;<path>kept). HEAD has<path>; working tree is still missing it.A <path>→?? <path>(new classification). Staged new file; unstaging removes the index entry, and since HEAD has no entry either, the working-tree file becomes untracked — it does NOT becomeA(which isn't a valid porcelain state for an uncommitted new file).R <old> -> <new>→ two lines:D <old>+?? <new>(one snapshot line expands to two). Staged renames decompose because the index operation is both "delete<old>" and "add<new>" relative to HEAD; unstaging leaves<old>present in the index at HEAD content (missing in working tree →D) and removes<new>from the index entirely (present in working tree, absent from HEAD →??).C <old> -> <new>→?? <new>(one line, different path). Staged copies decompose;<old>stays clean because a copy doesn't modify the source, and<new>becomes untracked for the same reason asA.- Combined index+worktree statuses (both columns non-space — a user staged a file, then modified or deleted it in the worktree on top, before invoking the skill): treat by the same "what does
git restore --staged :/produce?" rule applied to each column combo. The four practical cases:MM <path>→M <path>(unstaging drops the index modification; worktree still has both layers of change relative to HEAD, which surfaces as a single unstagedM).AM <path>→?? <path>(same asA— HEAD has no entry, so unstaging removes the index entry entirely and the worktree content becomes untracked; the extra worktree mod is absorbed into the untracked content).MD <path>→D <path>(unstaging drops the index mod; worktree is still missing the file, which becomes an unstaged deletion relative to HEAD).AD <path>→ line dropped entirely (HEAD has no entry, unstaging removes the index entry, worktree has no file — the path ceases to exist anywhere, so it disappears from post-loop porcelain).
Treat each normalized line as part of the snapshot, matching by both path and indicator.
-
After the loop completes, compute the delta against the snapshot using this two-part rule:
- Candidate-set files (files from the post-filter candidate set — step 2a's file scoping intersected with step 2b's secret filter, i.e., what the per-commit loop actually committed): any file from the candidate set that shows an unstaged change in post-loop
git status --porcelainis residue, regardless of its pre-loop status. Every candidate file was staged and committed during the loop, so any non-clean state afterward is necessarily hook-introduced — this catches the case where a file wasMpre-loop, committed mid-loop (momentarily clean), and then re-dirtied by a repo-wide hook running on a later group's commit (both the pre-loop and post-loopgit statuslines look the same —M <path>— but the content was committed in between). - Non-candidate files: only those whose status indicator changed from the snapshot or that newly appeared as unstaged count as delta. Files that were already dirty pre-loop and remain identically dirty post-loop are the user's pre-existing unrelated work and must not be touched.
- Candidate-set files (files from the post-filter candidate set — step 2a's file scoping intersected with step 2b's secret filter, i.e., what the per-commit loop actually committed): any file from the candidate set that shows an unstaged change in post-loop
-
If the delta is empty: skip — no extra commit.
-
If the delta contains only files that were also in the post-filter candidate set (step 2a's scoping ∩ step 2b's secret filter): stage just the delta files (
git add <delta-file>...) and create one final commit, typicallychore(commit-push): reconcile hook auto-fixes(usestyle:if purely formatting, orfix:if the hook actually fixed bugs — match the diff). Mention this commit explicitly in your reply so the user understands why an extra commit appeared and can keep/squash/split it. -
If the delta contains files outside the original candidate set (a hook touched files the user wasn't trying to commit): warn the user and let them decide — do NOT auto-stage these. They may want to stash them, commit them separately under a different intent, or verify the hook's behavior. Auto-committing files the user didn't include in their candidate set would violate the principle that the skill only commits files the user opted in to.
-
-
After all commits (per-group + any reconciliation) succeed, push to origin on the current branch. Emit a one-line summary immediately before invoking the push, in the form:
→ pushing <N> commit(s) to <remote>/<branch>(a single push for the whole batch, regardless of how many commits were created).Computing N. When the branch has an upstream, use
git rev-list --count @{u}..HEAD(commits ahead of upstream). When the branch has no upstream (step 1a's first-push recovery path),@{u}is unresolvable, so usegit rev-list --count HEAD --not --remotes=origininstead — commits present locally but absent from everyrefs/remotes/origin/*ref. This scopes the count to the remote being pushed to (origin), not to every remote-tracking ref in the repo. The distinction matters in multi-remote setups: a branch based onupstream/feature/sourcein a fork workflow has zero commits unique toupstreambut may have many commits unique toorigin, and it's the origin-unique count that matches whatgit push -u origin HEADwill actually publish. A bare--not --remotes(without=origin) would exclude commits present on any remote and report0in that case, misstating the push. If noorigin/*refs exist at all (a repo freshlygit inited with a manually-added remote URL but nothing fetched), the count collapses togit rev-list --count HEAD— every local commit is net-new to origin in that case.Choose the push command based on upstream state. If the current branch already has an upstream (
git rev-parse --abbrev-ref --symbolic-full-name @{u}succeeds), run baregit push. If it does not (the command fails / no upstream configured), rungit push -u origin HEADinstead — this publishes the branch and establishes tracking in one call, and handles both the routine first-push case and the first-push recovery path from step 1a. Do not run baregit pushon a no-upstream branch; it will fail withfatal: The current branch ... has no upstream branchand leave the user in exactly the kind of blocked state step 7a exists to avoid.Do not wrap the push in
AskUserQuestionto "confirm" first —AskUserQuestionis a conversation-layer tool and cannot authorize aBash(git push ...)call, so using it as a pre-push confirmation just doubles the prompt count without improving safety. The summary above gives the user context; if the permission layer then prompts for the push, the user already knows exactly what they're authorizing. Skip the push if step 5.4c fired — earlier successful commits stay local so the user can inspect, amend, or cherry-pick before deciding whether to push. Pushing a partial batch would surface the leading groups to origin while silently dropping the failed group, violating the atomic-batch invariant this skill otherwise enforces.7a. If the push is denied / rejected by Claude Code (silent denial, sandbox rule,
PreToolUsehook rejection, or any "tool use was rejected" style error):- Do NOT tell the user to run
git pushthemselves in a terminal. Manual shell-out is the UX this workflow exists to avoid; instructing the user to shell out defeats the whole point of committing-and-pushing as one action. - Do NOT loop-retry the push in the same turn. The rejection state does not change mid-session; a second identical call will fail identically. One clean failure report is better than three.
- Distinguish two rejection sources — they have different fixes:
- Permission-allowlist / sandbox /
skipAutoPermissionPromptdenial — the error text mentions "permission", "allow rule", "allowlist", or similar, with no reference to a hook.permissions.allowdoes fix this. Emit the Option A merge-form fragment below verbatim; also point the user at the Permissions section above for the Option B wildcard variant (but do not emit Option B inline unless the user asks). PreToolUsehook rejection — the error text mentions "hook", "PreToolUse", or names a specific hook command that rejected the call.permissions.allowdoes not fix this: hooks run independently of the allowlist. Do NOT emit the Option A snippet in this case — it is off-topic and misleads the user into a wrong fix. Instead, report the hook rejection (including the hook's stderr / reason if surfaced in the error), and tell the user to audithooks.PreToolUseentries in project.claude/settings.jsonor user~/.claude/settings.jsonfor a matcher that rejectsBash(git push ...)and decide whether to remove, scope down, or exempt this repo.- Ambiguous / unknown error text — emit Option A as the most likely fix but add a one-line note: "If this keeps failing after you add the entry, a
PreToolUsehook may be rejectinggit pushindependently of the allowlist; checkhooks.PreToolUsein yoursettings.json."
- Permission-allowlist / sandbox /
- Option A merge-form fragment (safe to copy into an existing
settings.json): show the user the entries to add to theirpermissions.allowarray, not a whole-file replacement:Tell the user to merge these into the"Bash(git push)", "Bash(git push -u origin HEAD)"
permissions.allowarray in~/.claude/settings.json(user-level) or.claude/settings.json(project-level). If the file does not exist yet or is{}, the minimal wrapper from the Permissions section is{ "permissions": { "allow": ["Bash(git push)", "Bash(git push -u origin HEAD)"] } }— but emit this whole-file form only when the skill can confirm the target file is absent or empty. "Has nopermissionskey yet" is insufficient: a file with other top-level keys (e.g.,hooks,env,model) would be silently clobbered by a whole-file paste even though it lackspermissions. When in doubt, emit the merge-form fragment and let the user apply it manually. - After the emit, tell the user: "Once you've added the entry (or adjusted the hook) and the settings reload, re-invoke
/commit-push— the per-group commits are already on your local branch, and step 1a's push-only fast path will push them without re-running the commit steps." The already-created commits remain on the branch unchanged; nothing is rolled back, amended, or discarded.
- Do NOT tell the user to run
-
Report the outcome:
- On success: list all commit SHAs created in this run, one per line (per-group commits in loop order, followed by the reconciliation commit if any).
- On abort from step 5.4c: list the successful SHAs created before the abort (one per line), then a clear failure block: the failing group's scope name + files, the hook's stderr output (the last
git commitstderr), the list of groups that never got their turn (unprocessed), and explicit guidance — "Fix the hook error manually and re-invoke/commit-push. The earlier successful commits are still on your local branch but were not pushed; they'll be preserved and the remaining groups will be picked up fresh on re-invocation." - On push rejection from step 7a: list the successful commit SHAs (all of them — they exist on the local branch), then a clear rejection block whose content depends on which source fired per step 7a's distinguishing rule:
- Permission-allowlist / sandbox denial: "
git pushwas denied by the permission layer", the Option A merge-form fragment from step 7a (entries to add topermissions.allow— not a whole-file replacement unless the target file is confirmed empty), a one-line pointer to the Permissions section above for the Option B wildcard alternative, and the re-invocation instruction from step 7a. PreToolUsehook rejection: "git pushwas rejected by aPreToolUsehook" + the hook's stderr / reason if available, the instruction to audithooks.PreToolUsein the relevantsettings.jsonfor a matcher rejectingBash(git push ...), and the re-invocation instruction. Do not include the Option A snippet in this branch — it would not fix the problem.- Ambiguous rejection: the Option A fragment plus a note that a
PreToolUsehook may also be involved (audithooks.PreToolUseif the allowlist addition doesn't unblock).
- Permission-allowlist / sandbox denial: "
- In all three variants above: do not include any phrasing that directs the user to run
git pushthemselves in their shell — that's the anti-pattern step 7a is built to prevent.
Anti-pattern: do not use AskUserQuestion for push permission, and do not fall back to "please run git push yourself"
AskUserQuestion is a conversation tool and cannot pre-authorize a Bash(git push ...) call under any permission mode, sandbox configuration, or hook setup. Using it as a pre-push confirmation produces redundant prompts and has zero effect on whether the push actually succeeds — the permission layer still evaluates the Bash call independently. The one-line summary in step 7 is the correct way to give the user (and the permission layer's prompt, where it exists) context about what's about to happen.
Equally: if the push is denied, do not tell the user to run git push in their own terminal. Manual shell-out as a fallback defeats the point of commit-and-push being a single workflow, and it treats a fixable configuration issue (missing permission entry) as a workflow dead-end. The correct response to any permission-layer denial is step 7a: report the denial, emit the Permissions snippet, instruct re-invocation. The already-created local commits are the unit of work that gets pushed on re-invocation.