From cc028b0448d5750a923d4cba02832402a1cc4c12 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 12:52:52 +0000 Subject: [PATCH 1/3] feat(commit): ask before splitting, add undo split and fast-split setting Regular commits no longer start the split process on their own. When staged changes span several scopes, gitb first asks "Try to split them into atomic commits?" before any grouping work (AI call, ordering, preview); only on "y" does it group the files and ask a second time to confirm the resulting groups. Fast modes still split by default. New setting gitbasher.commit-fast-split (default true) turns that off: `fast` then asks like a regular commit, and prompt-less modes such as `ff` commit everything as one. At any split prompt (type menu or AI suggestion) `u` now undoes the split: commits the split already created are dropped (soft reset to the starting HEAD), the original index is restored exactly (read-tree of the pre-split tree, partial hunks included), and the flow continues with a single commit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SfsWNhBqS9KpUcvsBuW9yE --- README.md | 7 +- scripts/commit.sh | 140 ++++++++++++++++++++++--- tests/test_commit_prompt.bats | 102 +++++++++++++++++- tests/test_commit_split_integrity.bats | 61 +++++++++++ 4 files changed, 290 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index c96d345..99073c5 100644 --- a/README.md +++ b/README.md @@ -360,7 +360,7 @@ gitb commit # compact form: ff, aifp, fastsp, ... | `staged` | `st` | Use already-staged files (skip the add step) | | `push` | `p` `pu` | Push after the commit succeeds | | `scope` | `s` | Force a scope: `type(scope): message` (useful with fast mode) | -| `no-split` | `nsp` `nsl` | Disable automatic split detection for this commit | +| `no-split` | `nos` `nsp` `nsl` | Don't offer to split this commit (fast modes split by default) | | `ai` | `i` `llm` | Generate the commit message with AI | | `msg` | `m` | Open `$EDITOR` for a multiline message body | | `ticket` | `t` `j` `jira` | Append ticket info to the header | @@ -383,6 +383,8 @@ gitb commit # compact form: ff, aifp, fastsp, ... - Word order doesn't matter: `ai fast push` == `push fast ai` == `aifp`. - Modifiers stack on actions: `ai+fixup`, `fast+amend`, `split+push`, `ai+staged`, … - `fast` and `staged` are mutually exclusive (one stages all, the other uses what's staged). +- Splitting: a regular commit **asks first** whether to try a split (before any grouping or AI work), then asks again to confirm the resulting groups. Fast modes (`fast`, `ff`, …) split without asking unless `gitbasher.commit-fast-split` is `false`. +- Press `u` at any split prompt to **undo the split**: commits already made by the split are dropped, staging is restored, and you continue with a single commit. `ff` never stops, so use the setting or `no-split` there. - `revert` and `ff` only accept `push` (as `revp` / `ffp`); to rewrite the last commit message use [`gitb edit`](#gitb-edit). ### `gitb edit` @@ -890,7 +892,8 @@ Overview-first diffs built for the gitbasher workflow — no flag memorization. | `gitbasher.ai-commit-history-limit` | `gitb cfg history` | Recent commits included in AI prompts (default `10`) | | `gitbasher.push-warn-size` | `gitb cfg push-size` | Warn before pushing more than N MB (default `50`, `0` disables) | | `gitbasher.worktreebase` | `git config gitbasher.worktreebase ` | Base directory for new worktrees (default `.worktree` under the repo root) | -| `gitbasher.commit-auto-split` | `git config gitbasher.commit-auto-split ` | Offer to split a commit per scope (default `ask`) | +| `gitbasher.commit-auto-split` | `git config gitbasher.commit-auto-split ` | Offer to split a commit per scope (default `ask`: ask before splitting, then confirm the groups) | +| `gitbasher.commit-fast-split` | `git config gitbasher.commit-fast-split ` | Fast modes split automatically (default `true`); `false` makes `fast` ask and `ff` commit as one | | `gitbasher.commit-max-split-groups` | `git config gitbasher.commit-max-split-groups <2..20>` | Cap on split commits per run (default `7`) | | `gitbasher.commit-split-order` | `git config gitbasher.commit-split-order ` | Order split commits by dependency (`auto`, default) or alphabetically (`alpha`) | | `gitbasher.commit-ai-grouping` | `git config gitbasher.commit-ai-grouping ` | AI feature grouping for commit splits (default `auto`) | diff --git a/scripts/commit.sh b/scripts/commit.sh index 1304d6a..a62e195 100644 --- a/scripts/commit.sh +++ b/scripts/commit.sh @@ -953,6 +953,33 @@ function _restore_split_snapshot { rm -f "$snapshot" } +### Undo an in-progress split: drop the commits it already created and put the +### index back exactly as it was before the split started. +# $1: HEAD before the split (empty on an unborn branch) +# $2: tree of the original index (git write-tree), empty if unavailable +# $3: snapshot file (fallback restore when the tree is unavailable) +# $4: number of commits the split already created +function _undo_commit_split { + local start_head="$1" + local orig_tree="$2" + local snapshot="$3" + local made="$4" + + if [ "${made:-0}" -gt 0 ]; then + if [ -n "$start_head" ]; then + git reset -q --soft "$start_head" >/dev/null 2>&1 + else + git update-ref -d HEAD >/dev/null 2>&1 + fi + fi + + if [ -n "$orig_tree" ] && git read-tree "$orig_tree" >/dev/null 2>&1; then + rm -f "$snapshot" + else + _restore_split_snapshot "$snapshot" + fi +} + function print_split_type_menu { local scope="$1" local ai_available="$2" @@ -972,6 +999,7 @@ function print_split_type_menu { echo -e "g. ${GREEN}${BOLD}ai${ENDCOLOR}:\t\tgenerate commit message using AI" fi echo -e "s. ${YELLOW}${BOLD}skip${ENDCOLOR}:\tSkip this group and leave its files unstaged" + echo -e "u. ${CYAN}${BOLD}undo${ENDCOLOR}:\tUndo split and commit all changes as a single commit" echo -e "0. ${RED}${BOLD}abort${ENDCOLOR}:\tAbort split and restore original staging" echo } @@ -1034,7 +1062,7 @@ function print_split_groups_preview { # Sets the caller's $msg variable on success (relies on bash dynamic scoping). # $1: scope label (used in messages), $2: scope_for_msg (empty -> no scope), # $3: files_str (newline-separated; used to unstage on skip) -# Returns: 0 success, 1 AI failed/declined, 2 skip scope, 3 abort split +# Returns: 0 success, 1 AI failed/declined, 2 skip scope, 3 abort split, 4 undo split function run_split_ai_for_scope { local _scope="$1" local _scope_for_msg="$2" @@ -1061,7 +1089,7 @@ function run_split_ai_for_scope { echo echo -e "${GREEN}AI suggestion:${ENDCOLOR} ${BOLD}$ai_msg${ENDCOLOR}" echo - read_key choice "Use it? (y/e to edit/r to regenerate/s to skip group/0 to abort) " || choice="0" + read_key choice "Use it? (y/e to edit/r to regenerate/s to skip group/u to undo split/0 to abort) " || choice="0" echo normalize_key "$choice" @@ -1082,7 +1110,7 @@ ${ai_msg}" echo echo -e "${GREEN}AI suggestion:${ENDCOLOR} ${BOLD}$ai_msg${ENDCOLOR}" echo - read_key choice "Use it? (y/e to edit/r to regenerate/s to skip group/0 to abort) " || choice="0" + read_key choice "Use it? (y/e to edit/r to regenerate/s to skip group/u to undo split/0 to abort) " || choice="0" echo normalize_key "$choice" done @@ -1100,6 +1128,8 @@ ${ai_msg}" [ -n "$f" ] && git restore --staged -- ":(top,literal)$f" >/dev/null 2>&1 done <<< "$_files_str" return 2 + elif [ "$normalized_key" = "u" ]; then + return 4 elif [ "$choice" = "0" ]; then return 3 fi @@ -1111,6 +1141,8 @@ ${ai_msg}" # Globals consumed: split_groups, split_group_keys, push, current_branch # Exits the script on success (entire commit flow handled). # Returns 1 (and restores staging) on failure so caller can fall through. +# Returns 3 when the user undid the split: split commits are dropped and the +# original index is restored, so the caller continues with a single commit. function perform_commit_split { local original_staged original_staged=$(git -c core.quotePath=false diff --no-renames --name-only --cached) @@ -1125,6 +1157,12 @@ function perform_commit_split { # copy when the file still exists on disk. _capture_split_statuses + # Exact pre-split state, so "undo split" can drop the split commits and + # put the index back byte-for-byte (partial hunks included). + local start_head orig_tree + start_head=$(git rev-parse -q --verify HEAD 2>/dev/null) + orig_tree=$(git write-tree 2>/dev/null) + local snapshot_file snapshot_file=$(mktemp "${TMPDIR:-/tmp}/gitb-split-snapshot.XXXXXX") chmod 600 "$snapshot_file" 2>/dev/null || true @@ -1157,6 +1195,11 @@ function perform_commit_split { local scope files_str msg ai_msg choice scope_for_msg prefix manual_input local -a files_array + if [ -z "$auto_accept" ]; then + echo + echo -e "${GRAY}Tip: press ${ENDCOLOR}${BOLD}u${NORMAL}${GRAY} at a split prompt to undo the split and make a single commit instead.${ENDCOLOR}" + fi + for scope in "${split_group_keys[@]}"; do idx=$((idx + 1)) echo @@ -1224,6 +1267,13 @@ function perform_commit_split { run_split_ai_for_scope "$scope" "$scope_for_msg" "$files_str" case $? in 2) continue ;; + 4) + _undo_commit_split "$start_head" "$orig_tree" "$snapshot_file" "$commit_count" + trap - INT TERM + echo + echo -e "${YELLOW}Split undone, continuing with a single commit.${ENDCOLOR}" + return 3 + ;; 3) _restore_split_snapshot "$snapshot_file" trap - INT TERM @@ -1254,7 +1304,7 @@ function perform_commit_split { local tchoice while true; do read_key tchoice || tchoice="0" - if ! sanitize_choice_input "$tchoice" "^[0-9sg]$"; then + if ! sanitize_choice_input "$tchoice" "^[0-9sgu]$"; then continue fi tchoice="$sanitized_choice" @@ -1276,6 +1326,13 @@ function perform_commit_split { case $? in 0) break ;; 2) continue 2 ;; + 4) + _undo_commit_split "$start_head" "$orig_tree" "$snapshot_file" "$commit_count" + trap - INT TERM + echo + echo -e "${YELLOW}Split undone, continuing with a single commit.${ENDCOLOR}" + return 3 + ;; 3) _restore_split_snapshot "$snapshot_file" trap - INT TERM @@ -1289,6 +1346,13 @@ function perform_commit_split { ;; esac ;; + u) + _undo_commit_split "$start_head" "$orig_tree" "$snapshot_file" "$commit_count" + trap - INT TERM + echo + echo -e "${YELLOW}Split undone, continuing with a single commit.${ENDCOLOR}" + return 3 + ;; s) echo -e "${YELLOW}Skipping scope '${scope}' (files left unstaged)${ENDCOLOR}" while IFS= read -r f; do @@ -1386,7 +1450,7 @@ function perform_commit_split { # Skipped silently when: # - gitbasher.commit-auto-split = "never" # - fewer than 2 distinct scopes are detected (after optional AI refinement) -# Auto-accepts the y/N prompt when: +# Auto-accepts the y/N prompts when: # - gitbasher.commit-auto-split = "always" # - $1 is non-empty (split mode forced via CLI) # - $2 is non-empty (caller is fast/auto-accept and doesn't want to ask) @@ -1404,8 +1468,11 @@ function perform_commit_split { # gitbasher.commit-split-order = "alpha". # Returns to the caller (no exit) when the user declines or the split isn't # applicable; perform_commit_split exits the script directly on success. +# In "ask" mode there are two prompts: one BEFORE any grouping work ("try to +# split?") and one after, showing the resulting groups ("split into N?"). # $1: "true" to force the split flow even when the config says "ask"/"never" -# $2: "true" to skip the y/N prompt and proceed straight to splitting +# $2: "true" to skip both y/N prompts and proceed straight to splitting +# Returns 0 split done, 1 declined/not applicable, 2 split failed, 3 split undone function try_offer_commit_split { local force_split="$1" local auto_yes="$2" @@ -1437,6 +1504,31 @@ function try_offer_commit_split { should_use_ai="true" fi + # Nothing to split and no AI pass that could find features — stay silent. + if [ ${#split_group_keys[@]} -lt 2 ] && [ "$should_use_ai" != "true" ]; then + if [ -n "$force_split" ]; then + echo -e "${YELLOW}Cannot split: all changes look like a single feature.${ENDCOLOR}" + fi + return 1 + fi + + # Ask BEFORE doing any grouping work (AI call, ordering, preview), unless + # the caller already decided (forced split, fast/auto-accept) or the config + # says "always". The second prompt below confirms the resulting groups. + local choice + if [ -z "$force_split" ] && [ -z "$auto_yes" ] && [ "$auto_split" != "always" ]; then + echo + if [ ${#split_group_keys[@]} -ge 2 ]; then + echo -e "${YELLOW}Staged changes span ${#split_group_keys[@]} scopes:${ENDCOLOR} ${BLUE}${split_group_keys[*]}${ENDCOLOR}" + fi + read_key choice "Try to split them into atomic commits? (y/N) " || choice="" + echo + # Default is No: Enter/EOF must decline (is_yes treats Enter as yes). + if [ -z "$choice" ] || ! is_yes "$choice"; then + return 1 + fi + fi + if [ "$should_use_ai" = "true" ] && check_ai_available 2>/dev/null; then echo echo -e "${YELLOW}Grouping changes by feature with AI...${ENDCOLOR}" @@ -1470,7 +1562,6 @@ function try_offer_commit_split { print_split_groups_preview echo - local choice if [ -n "$force_split" ] || [ -n "$auto_yes" ] || [ "$auto_split" = "always" ]; then choice="y" else @@ -1487,8 +1578,15 @@ function try_offer_commit_split { # 2 = "split was attempted and failed" (vs 1 = declined/not applicable): # the forced-split caller must not treat a failed split as success, and # must not unstage the staging the failure path just restored. - perform_commit_split || return 2 - return 0 + # 3 = the user undid the split: staging is back to the original, continue + # with a single regular commit. + local split_rc=0 + perform_commit_split || split_rc=$? + case "$split_rc" in + 0) return 0 ;; + 3) return 3 ;; + *) return 2 ;; + esac } ### Function to handle AI commit message generation @@ -2049,7 +2147,7 @@ function commit_script { print_help_row $FPAD "staged" "st" "Use already-staged files (skip the add step)" print_help_row $FPAD "push" "p, pu" "Push after the commit succeeds" print_help_row $FPAD "scope" "s" "Force a scope: 'type(scope): message' (useful with fast mode)" - print_help_row $FPAD "no-split" "nos, nsp, nsl" "Disable automatic split detection for this commit" + print_help_row $FPAD "no-split" "nos, nsp, nsl" "Don't offer to split this commit (fast modes split by default)" print_help_row $FPAD "ai" "i, llm" "Generate the commit message with AI" print_help_row $FPAD "msg" "m" "Open \$EDITOR for a multiline message body" print_help_row $FPAD "ticket" "t, j, jira" "Append ticket info to the header" @@ -2068,6 +2166,8 @@ function commit_script { echo -e " ${BLUE}•${ENDCOLOR} Word order doesn't matter: ${GREEN}ai fast push${ENDCOLOR} == ${GREEN}push fast ai${ENDCOLOR} == ${GREEN}aifp${ENDCOLOR}" echo -e " ${BLUE}•${ENDCOLOR} Modifiers stack on actions: ${GREEN}ai+fixup${ENDCOLOR}, ${GREEN}fast+amend${ENDCOLOR}, ${GREEN}split+push${ENDCOLOR}, ${GREEN}ai+staged${ENDCOLOR}, ..." echo -e " ${BLUE}•${ENDCOLOR} ${BOLD}fast${NORMAL} and ${BOLD}staged${NORMAL} are mutually exclusive (one stages all, the other uses what's staged)" + echo -e " ${BLUE}•${ENDCOLOR} Regular commits ask before splitting; fast modes split by default (off: ${GREEN}git config gitbasher.commit-fast-split false${ENDCOLOR})" + echo -e " ${BLUE}•${ENDCOLOR} Press ${BOLD}u${NORMAL} at a split prompt to undo the split and commit everything as one" echo -e " ${BLUE}•${ENDCOLOR} ${BOLD}revert${NORMAL} and ${BOLD}ff${NORMAL} only accept ${BOLD}push${NORMAL} (as ${BOLD}revp${NORMAL}/${BOLD}ffp${NORMAL}); to rewrite the last message use ${GREEN}gitb edit${ENDCOLOR}" # Clean up cached git add on help exit git config --unset gitbasher.cached-git-add 2>/dev/null || true @@ -2283,17 +2383,25 @@ function commit_script { # Split runs the entire commit flow itself (one commit per scope) and exits # the script on success. Skipped for modes where it doesn't make sense # (amend, fixup, multi-line msg, ticket) or when forced off via config. - # Fast/auto-accept modes skip the y/N prompt — split silently when applicable. - if [ -z "${amend}" ] && [ -z "${fixup}" ] && [ -z "${msg}" ] && [ -z "${ticket}" ] && [ -z "${no_split}" ]; then - _split_auto_yes="" - if [ -n "$fast" ] || [ -n "$auto_accept" ]; then + # Regular mode asks first. Fast/auto-accept modes split silently by default; + # gitbasher.commit-fast-split=false turns that off (fast then asks like + # regular mode, and prompt-less auto-accept modes such as ff don't split). + _split_skip="${no_split}" + _split_auto_yes="" + if [ -z "$split" ] && { [ -n "$fast" ] || [ -n "$auto_accept" ]; }; then + if [ "$(get_config_value gitbasher.commit-fast-split "true")" = "false" ]; then + [ -n "$auto_accept" ] && _split_skip="true" + else _split_auto_yes="true" fi + fi + if [ -z "${amend}" ] && [ -z "${fixup}" ] && [ -z "${msg}" ] && [ -z "${ticket}" ] && [ -z "${_split_skip}" ]; then try_offer_commit_split "$split" "$_split_auto_yes" _split_rc=$? # If the user explicitly asked for split mode but it wasn't applicable, - # don't silently fall through to the single-commit flow. - if [ -n "$split" ]; then + # don't silently fall through to the single-commit flow — unless they + # undid the split themselves (rc 3), which means "commit it as one". + if [ -n "$split" ] && [ "$_split_rc" -ne 3 ]; then if [ "$_split_rc" -ge 2 ]; then # The split ran and failed; its snapshot restore already put # the staging back — don't unstage it again, and report the diff --git a/tests/test_commit_prompt.bats b/tests/test_commit_prompt.bats index 657134d..143e4ab 100644 --- a/tests/test_commit_prompt.bats +++ b/tests/test_commit_prompt.bats @@ -29,6 +29,7 @@ teardown() { [[ "$output" == *"9. ${blue}${bold}plain${endcolor}"* ]] [[ "$output" == *"${yellow}${bold}skip${endcolor}"* ]] [[ "$output" == *"${red}${bold}abort${endcolor}"* ]] + [[ "$output" == *"u. "*"undo"* ]] } @test "regular type menu uses split menu styling" { @@ -85,7 +86,7 @@ teardown() { assert_success local use_prompt before_type yellow yellow=$(printf '%b' "$YELLOW") - use_prompt="Use it? (y/e to edit/r to regenerate/s to skip group/0 to abort) " + use_prompt="Use it? (y/e to edit/r to regenerate/s to skip group/u to undo split/0 to abort) " before_type="${output#*"$use_prompt"}" [[ "$before_type" != "$output" ]] before_type="${before_type%%What ${yellow}type*}" @@ -192,12 +193,109 @@ teardown() { return 0 } - run try_offer_commit_split "" "" <<< "y" + # First "y" answers "try to split?", second confirms the previewed groups. + run try_offer_commit_split "" "" <<< "yy" assert_success + [[ "$output" == *"Try to split"* ]] [[ "$output" == *"split-performed"* ]] } +@test "split offer asks before grouping: declining skips AI grouping and preview" { + mkdir -p api web cli + create_test_file "api/a.go" "a" + create_test_file "web/b.js" "b" + create_test_file "cli/c.sh" "c" + git add api/a.go web/b.js cli/c.sh + + git config gitbasher.commit-auto-split "ask" + git config gitbasher.commit-ai-grouping "auto" + llm="true" + + check_ai_available() { return 0; } + group_files_by_feature_with_ai() { echo "AI-GROUPING-CALLED"; return 0; } + perform_commit_split() { echo "split-performed"; return 0; } + + run try_offer_commit_split "" "" <<< "n" + + [ "$status" -eq 1 ] + [[ "$output" == *"Try to split"* ]] + [[ "$output" != *"AI-GROUPING-CALLED"* ]] + [[ "$output" != *"Detected changes across"* ]] + [[ "$output" != *"split-performed"* ]] +} + +@test "split offer: yes to the first prompt, no to the groups declines" { + mkdir -p docs scripts + create_test_file "docs/new.md" "doc" + create_test_file "scripts/change.sh" "script" + git add docs/new.md scripts/change.sh + + git config gitbasher.commit-auto-split "ask" + git config gitbasher.commit-ai-grouping "never" + llm="" + + perform_commit_split() { echo "split-performed"; return 0; } + + run try_offer_commit_split "" "" <<< "yn" + + [ "$status" -eq 1 ] + [[ "$output" == *"Detected changes across"* ]] + [[ "$output" != *"split-performed"* ]] +} + +@test "split offer: auto-yes (fast mode) never prompts" { + mkdir -p docs scripts + create_test_file "docs/new.md" "doc" + create_test_file "scripts/change.sh" "script" + git add docs/new.md scripts/change.sh + + git config gitbasher.commit-auto-split "ask" + git config gitbasher.commit-ai-grouping "never" + llm="" + + perform_commit_split() { echo "split-performed"; return 0; } + + run try_offer_commit_split "" "true" < /dev/null + + assert_success + [[ "$output" != *"Try to split"* ]] + [[ "$output" == *"split-performed"* ]] +} + +@test "split offer: single scope never prompts" { + mkdir -p docs + create_test_file "docs/a.md" "a" + create_test_file "docs/b.md" "b" + git add docs/a.md docs/b.md + + git config gitbasher.commit-auto-split "ask" + git config gitbasher.commit-ai-grouping "never" + llm="" + + run try_offer_commit_split "" "" < /dev/null + + [ "$status" -eq 1 ] + [[ "$output" != *"Try to split"* ]] +} + +@test "split offer: undone split returns 3" { + mkdir -p docs scripts + create_test_file "docs/new.md" "doc" + create_test_file "scripts/change.sh" "script" + git add docs/new.md scripts/change.sh + + git config gitbasher.commit-auto-split "ask" + git config gitbasher.commit-ai-grouping "never" + llm="" + + perform_commit_split() { return 3; } + + run try_offer_commit_split "" "true" < /dev/null + + [ "$status" -eq 3 ] +} + @test "split group preview colors scopes and files by staged status" { mkdir -p docs scripts create_test_file "scripts/change.sh" "old" diff --git a/tests/test_commit_split_integrity.bats b/tests/test_commit_split_integrity.bats index 3662a4e..f4660fb 100644 --- a/tests/test_commit_split_integrity.bats +++ b/tests/test_commit_split_integrity.bats @@ -108,3 +108,64 @@ teardown() { run commit_script last push [ "$status" -ne 0 ] } + +@test "perform_commit_split: undo after a split commit drops it and restores staging" { + mkdir -p docs scripts + echo "base" > docs/a.md + echo "base" > scripts/b.sh + git add docs/a.md scripts/b.sh + git commit -q -m "seed" + local start_head + start_head=$(git rev-parse HEAD) + + echo "changed" > docs/a.md + echo "changed" > scripts/b.sh + # Partial staging must survive the undo byte-for-byte. + git add docs/a.md scripts/b.sh + echo "unstaged-extra" >> docs/a.md + local orig_tree + orig_tree=$(git write-tree) + + gmap_clear split_groups + gmap_set split_groups "docs" "docs/a.md" + gmap_set split_groups "scripts" "scripts/b.sh" + split_group_keys=(docs scripts) + llm="true" + auto_accept="" + push="" + current_branch="main" + check_ai_available() { return 0; } + generate_ai_commit_message() { echo "chore: ai message"; } + + # "y" accepts the AI message for group 1, "u" undoes at group 2. + run perform_commit_split <<< "yu" + + [ "$status" -eq 3 ] + [[ "$output" == *"Split undone"* ]] + [ "$(git rev-parse HEAD)" = "$start_head" ] + [ "$(git write-tree)" = "$orig_tree" ] +} + +@test "perform_commit_split: undo from the type menu before any commit" { + mkdir -p docs scripts + echo "a" > docs/a.md + echo "b" > scripts/b.sh + git add docs/a.md scripts/b.sh + local start_head orig_tree + start_head=$(git rev-parse HEAD) + orig_tree=$(git write-tree) + + gmap_clear split_groups + gmap_set split_groups "docs" "docs/a.md" + gmap_set split_groups "scripts" "scripts/b.sh" + split_group_keys=(docs scripts) + llm="" + auto_accept="" + check_ai_available() { return 1; } + + run perform_commit_split <<< "u" + + [ "$status" -eq 3 ] + [ "$(git rev-parse HEAD)" = "$start_head" ] + [ "$(git write-tree)" = "$orig_tree" ] +} From 31690a9ed6b1aa90e3a5c8ce78b6a69299b288c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 13:00:41 +0000 Subject: [PATCH 2/3] feat(config): add `gitb cfg fast-split` to toggle split in fast modes gitbasher.commit-fast-split is now managed like the other settings: an interactive `gitb cfg fast-split` (aliases fs, fastsplit) with local/global choice, a "Fast split" row in `gitb cfg`, an entry in `gitb cfg delete`, help text, tab completion (bash/zsh/fish) and README docs. Reads go through the new get_fast_split helper, which normalizes unknown values to true. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SfsWNhBqS9KpUcvsBuW9yE --- README.md | 5 +-- scripts/commit.sh | 4 +-- scripts/common.sh | 18 +++++++++++ scripts/completion.sh | 6 ++-- scripts/config.sh | 53 +++++++++++++++++++++++++++++++ tests/test_config_fast_split.bats | 53 +++++++++++++++++++++++++++++++ 6 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 tests/test_config_fast_split.bats diff --git a/README.md b/README.md index 99073c5..8069926 100644 --- a/README.md +++ b/README.md @@ -383,7 +383,7 @@ gitb commit # compact form: ff, aifp, fastsp, ... - Word order doesn't matter: `ai fast push` == `push fast ai` == `aifp`. - Modifiers stack on actions: `ai+fixup`, `fast+amend`, `split+push`, `ai+staged`, … - `fast` and `staged` are mutually exclusive (one stages all, the other uses what's staged). -- Splitting: a regular commit **asks first** whether to try a split (before any grouping or AI work), then asks again to confirm the resulting groups. Fast modes (`fast`, `ff`, …) split without asking unless `gitbasher.commit-fast-split` is `false`. +- Splitting: a regular commit **asks first** whether to try a split (before any grouping or AI work), then asks again to confirm the resulting groups. Fast modes (`fast`, `ff`, …) split without asking unless you turn it off with `gitb cfg fast-split`. - Press `u` at any split prompt to **undo the split**: commits already made by the split are dropped, staging is restored, and you continue with a single commit. `ff` never stops, so use the setting or `no-split` there. - `revert` and `ff` only accept `push` (as `revp` / `ffp`); to rewrite the last commit message use [`gitb edit`](#gitb-edit). @@ -806,6 +806,7 @@ gitb origin remove # delete the remote | `history` | `hist` | How many recent commits to include in AI prompts | | `diff` | `payload` | AI diff payload size (line and character caps) | | `push-size` | `ps` `pushsize` | Warn before pushing more than N MB (0 disables) | +| `fast-split` | `fs` `fastsplit` | Split automatically in fast commit modes (on/off) | | `completion` | `comp` | Install / uninstall bash & zsh tab completion | | `delete` | `unset` `del` | Remove global config | @@ -893,7 +894,7 @@ Overview-first diffs built for the gitbasher workflow — no flag memorization. | `gitbasher.push-warn-size` | `gitb cfg push-size` | Warn before pushing more than N MB (default `50`, `0` disables) | | `gitbasher.worktreebase` | `git config gitbasher.worktreebase ` | Base directory for new worktrees (default `.worktree` under the repo root) | | `gitbasher.commit-auto-split` | `git config gitbasher.commit-auto-split ` | Offer to split a commit per scope (default `ask`: ask before splitting, then confirm the groups) | -| `gitbasher.commit-fast-split` | `git config gitbasher.commit-fast-split ` | Fast modes split automatically (default `true`); `false` makes `fast` ask and `ff` commit as one | +| `gitbasher.commit-fast-split` | `gitb cfg fast-split` | Fast modes split automatically (default `true`); `false` makes `fast` ask and `ff` commit as one | | `gitbasher.commit-max-split-groups` | `git config gitbasher.commit-max-split-groups <2..20>` | Cap on split commits per run (default `7`) | | `gitbasher.commit-split-order` | `git config gitbasher.commit-split-order ` | Order split commits by dependency (`auto`, default) or alphabetically (`alpha`) | | `gitbasher.commit-ai-grouping` | `git config gitbasher.commit-ai-grouping ` | AI feature grouping for commit splits (default `auto`) | diff --git a/scripts/commit.sh b/scripts/commit.sh index a62e195..5e6f55a 100644 --- a/scripts/commit.sh +++ b/scripts/commit.sh @@ -2166,7 +2166,7 @@ function commit_script { echo -e " ${BLUE}•${ENDCOLOR} Word order doesn't matter: ${GREEN}ai fast push${ENDCOLOR} == ${GREEN}push fast ai${ENDCOLOR} == ${GREEN}aifp${ENDCOLOR}" echo -e " ${BLUE}•${ENDCOLOR} Modifiers stack on actions: ${GREEN}ai+fixup${ENDCOLOR}, ${GREEN}fast+amend${ENDCOLOR}, ${GREEN}split+push${ENDCOLOR}, ${GREEN}ai+staged${ENDCOLOR}, ..." echo -e " ${BLUE}•${ENDCOLOR} ${BOLD}fast${NORMAL} and ${BOLD}staged${NORMAL} are mutually exclusive (one stages all, the other uses what's staged)" - echo -e " ${BLUE}•${ENDCOLOR} Regular commits ask before splitting; fast modes split by default (off: ${GREEN}git config gitbasher.commit-fast-split false${ENDCOLOR})" + echo -e " ${BLUE}•${ENDCOLOR} Regular commits ask before splitting; fast modes split by default (toggle: ${GREEN}gitb cfg fast-split${ENDCOLOR})" echo -e " ${BLUE}•${ENDCOLOR} Press ${BOLD}u${NORMAL} at a split prompt to undo the split and commit everything as one" echo -e " ${BLUE}•${ENDCOLOR} ${BOLD}revert${NORMAL} and ${BOLD}ff${NORMAL} only accept ${BOLD}push${NORMAL} (as ${BOLD}revp${NORMAL}/${BOLD}ffp${NORMAL}); to rewrite the last message use ${GREEN}gitb edit${ENDCOLOR}" # Clean up cached git add on help exit @@ -2389,7 +2389,7 @@ function commit_script { _split_skip="${no_split}" _split_auto_yes="" if [ -z "$split" ] && { [ -n "$fast" ] || [ -n "$auto_accept" ]; }; then - if [ "$(get_config_value gitbasher.commit-fast-split "true")" = "false" ]; then + if [ "$(get_fast_split)" = "false" ]; then [ -n "$auto_accept" ] && _split_skip="true" else _split_auto_yes="true" diff --git a/scripts/common.sh b/scripts/common.sh index 41590e8..fc9bdb4 100644 --- a/scripts/common.sh +++ b/scripts/common.sh @@ -1435,6 +1435,12 @@ function print_configuration { echo -e "\tPush warn:\t${GREEN}${push_warn} MB${ENDCOLOR} $(config_source_tag gitbasher.push-warn-size)" fi + if [ "$(get_fast_split)" = "true" ]; then + echo -e "\tFast split:\t${GREEN}auto${ENDCOLOR} $(config_source_tag gitbasher.commit-fast-split)" + else + echo -e "\tFast split:\t${YELLOW}off${ENDCOLOR} $(config_source_tag gitbasher.commit-fast-split)" + fi + echo echo -e "${GRAY}Source:${ENDCOLOR} ${BLUE}(project)${ENDCOLOR}${GRAY} this repo,${ENDCOLOR} ${PURPLE}(global)${ENDCOLOR}${GRAY} ~/.gitconfig,${ENDCOLOR} ${GRAY}(default) built-in fallback${ENDCOLOR}" } @@ -1826,6 +1832,18 @@ function set_push_warn_size { } +### Whether fast commit modes (fast, ff, ...) split into atomic commits without +### asking. "true" (default) or "false"; anything else reads as "true". +function get_fast_split { + local v + v=$(get_config_value gitbasher.commit-fast-split "true") + if [ "$v" = "false" ]; then echo "false"; else echo "true"; fi +} +function set_fast_split { + set_config_value gitbasher.commit-fast-split "$1" +} + + ### Format an integer byte count as "N B" / "N.d KB|MB|GB" (base 1024, one ### decimal via integer math — no numfmt, bash-3.2 safe). # $1: bytes diff --git a/scripts/completion.sh b/scripts/completion.sh index a4f9822..1a9feef 100644 --- a/scripts/completion.sh +++ b/scripts/completion.sh @@ -58,7 +58,7 @@ _gitb_sub_sync="push p merge m mergep mp pm dry d dr help h" _gitb_sub_wip="up u down d help h" _gitb_sub_branch="list l remote r re main def m new n c newd nd cd delete del d prev p - recent rc gone g tag t help h" _gitb_sub_tag="annotated a an commit c co cm all al push ps ph p push-all pa delete del d delete-all da list log l remote re r fetch help h" -_gitb_sub_config="default def d b main separator sep editor ed e ticket jira ti t scopes scope sc s ai llm key provider prov model m proxy prx p history hist diff payload push-size pushsize ps delete unset del user name email u auto completion comp help h" +_gitb_sub_config="default def d b main separator sep editor ed e ticket jira ti t scopes scope sc s ai llm key provider prov model m proxy prx p history hist diff payload push-size pushsize ps fast-split fastsplit fs delete unset del user name email u auto completion comp help h" _gitb_sub_undo="commit c amend a merge m rebase r stash s help h" _gitb_sub_reset="soft s undo u interactive i ref r help h" _gitb_sub_stash="select sel all list l pop p show s drop d apply a help h" @@ -410,7 +410,7 @@ _gitb() { _values 'tag mode' 'annotated[create annotated tag]' 'commit[tag a commit]' 'all[tag everything]' 'push[push tag]' 'push-all[push all tags]' 'delete[delete tag]' 'delete-all[delete all]' 'list[list tags]' 'remote[remote tags]' 'help[show help]' ;; config|cf|cfg|conf) - _values 'config key' 'default[default branch]' 'separator[separator char]' 'editor[editor]' 'ticket[ticket prefix]' 'scopes[scope list]' 'ai[AI setup wizard]' 'key[AI API key]' 'provider[AI provider]' 'model[AI model]' 'proxy[AI proxy]' 'history[AI history]' 'diff[AI diff payload]' 'push-size[warn on large pushes]' 'delete[unset a key]' 'user[user name/email]' 'auto[manage tab completion]' 'help[show help]' + _values 'config key' 'default[default branch]' 'separator[separator char]' 'editor[editor]' 'ticket[ticket prefix]' 'scopes[scope list]' 'ai[AI setup wizard]' 'key[AI API key]' 'provider[AI provider]' 'model[AI model]' 'proxy[AI proxy]' 'history[AI history]' 'diff[AI diff payload]' 'push-size[warn on large pushes]' 'fast-split[split in fast commit modes]' 'delete[unset a key]' 'user[user name/email]' 'auto[manage tab completion]' 'help[show help]' ;; undo|un) _values 'undo target' 'commit[undo last commit]' 'amend[undo amend]' 'merge[undo merge]' 'rebase[undo rebase]' 'stash[undo stash]' 'help[show help]' @@ -615,7 +615,7 @@ complete -c gitb -n "$__gitb_branch" -a "list remote main new newd delete prev r set -l __gitb_tag "__gitb_using_cmd tag t tg; and __gitb_at_position 2" complete -c gitb -n "$__gitb_tag" -a "annotated commit all push push-all delete delete-all list remote help" set -l __gitb_config "__gitb_using_cmd config cf cfg conf; and __gitb_at_position 2" -complete -c gitb -n "$__gitb_config" -a "default separator editor ticket scopes ai key provider model proxy history diff push-size delete user auto help" +complete -c gitb -n "$__gitb_config" -a "default separator editor ticket scopes ai key provider model proxy history diff push-size fast-split delete user auto help" set -l __gitb_undo "__gitb_using_cmd undo un; and __gitb_at_position 2" complete -c gitb -n "$__gitb_undo" -a "commit amend merge rebase stash help" set -l __gitb_reset "__gitb_using_cmd reset res; and __gitb_at_position 2" diff --git a/scripts/config.sh b/scripts/config.sh index b9441be..f7e0e98 100644 --- a/scripts/config.sh +++ b/scripts/config.sh @@ -1055,6 +1055,47 @@ function configure_push_warn_size { } +### Function asks user whether fast commit modes split automatically +function configure_fast_split { + echo -e "${YELLOW}Configure Fast Mode Split${ENDCOLOR}" + echo + + if [ "$(get_fast_split)" = "true" ]; then + echo -e "Current: ${GREEN}auto${ENDCOLOR} (fast modes split without asking)" + else + echo -e "Current: ${YELLOW}off${ENDCOLOR} (fast asks first, ff commits everything as one)" + fi + echo + echo -e "When staged changes span several scopes, fast commit modes (${BLUE}fast${ENDCOLOR}, ${BLUE}ff${ENDCOLOR}, ...)" + echo -e "split them into atomic commits without asking. Turn it off to have ${BLUE}fast${ENDCOLOR}" + echo -e "ask first (like a regular commit) and ${BLUE}ff${ENDCOLOR} make a single commit." + echo + echo -e "1. ${GREEN}auto${ENDCOLOR}:\tsplit automatically in fast modes" + echo -e "2. ${YELLOW}off${ENDCOLOR}:\tdon't split by default in fast modes" + echo -e "0. Exit without changes" + + local choice value label + while true; do + read_key choice || exit + case "$choice" in + 1) value="true"; label="auto"; break ;; + 2) value="false"; label="off"; break ;; + 0) exit ;; + esac + done + echo + + set_fast_split "$value" >/dev/null + echo -e "${GREEN}✓ Set fast mode split to ${label} for '${project_name}'${ENDCOLOR}" + echo + + [ "$GITBASHER_NO_REPO" = "true" ] && exit + echo -e "Do you want to set it ${YELLOW}globally${ENDCOLOR} for all projects (Y/n)?" + yes_no_choice "\nSet fast mode split globally" "true" + set_config_value gitbasher.commit-fast-split "$value" "true" >/dev/null +} + + ### Function asks user to configure AI diff payload size # Controls how much of the staged diff is sent to the model. Two knobs: # - lines: head -n N applied to the diff (primary cap, intuitive for users) @@ -1275,6 +1316,9 @@ function delete_global { local global_push_warn=$(git config --global --get gitbasher.push-warn-size) _delete_global_add "gitbasher.push-warn-size" "Push size warning" "${global_push_warn:+${global_push_warn} MB}" "$GREEN" + local global_fast_split=$(git config --global --get gitbasher.commit-fast-split) + _delete_global_add "gitbasher.commit-fast-split" "Fast mode split" "$global_fast_split" "$GREEN" + local global_ai_diff_lines=$(git config --global --get gitbasher.ai-diff-limit) local global_ai_diff_chars=$(git config --global --get gitbasher.ai-diff-max-chars) if [ -n "$global_ai_diff_lines" ] || [ -n "$global_ai_diff_chars" ]; then @@ -1402,6 +1446,7 @@ function config_script { history|hist) set_ai_history_cfg="true";; diff|payload) set_ai_diff_cfg="true";; push-size|pushsize|ps) set_push_warn_cfg="true";; + fast-split|fastsplit|fs) set_fast_split_cfg="true";; delete|unset|del) delete_cfg="true";; user|name|email|u) set_user_cfg="true";; auto|completion|comp) auto_cfg="true";; @@ -1437,6 +1482,8 @@ function config_script { header="$header AI DIFF PAYLOAD" elif [ -n "${set_push_warn_cfg}" ]; then header="$header PUSH SIZE WARNING" + elif [ -n "${set_fast_split_cfg}" ]; then + header="$header FAST MODE SPLIT" elif [ -n "${delete_cfg}" ]; then header="$header UNSET GLOBAL CONFIG" elif [ -n "${set_user_cfg}" ]; then @@ -1528,6 +1575,11 @@ function config_script { exit fi + if [ "$set_fast_split_cfg" == "true" ]; then + configure_fast_split + exit + fi + if [ "$delete_cfg" == "true" ]; then delete_global exit @@ -1558,6 +1610,7 @@ function config_script { print_help_row $PAD "history" "hist" "Set how many recent commits to include in AI prompts" print_help_row $PAD "diff" "payload" "Set the diff payload size (lines and char cap) sent to AI" print_help_row $PAD "push-size" "ps, pushsize" "Warn before pushing more than N MB (0 disables)" + print_help_row $PAD "fast-split" "fs, fastsplit" "Split automatically in fast commit modes (on/off)" print_help_row $PAD "auto" "completion, comp" "Install/remove tab completion for bash, zsh, or fish" print_help_row $PAD "delete" "unset, del" "Unset a global gitbasher configuration value" print_help_row $PAD "help" "h" "Show this help" diff --git a/tests/test_config_fast_split.bats b/tests/test_config_fast_split.bats new file mode 100644 index 0000000..69ccf11 --- /dev/null +++ b/tests/test_config_fast_split.bats @@ -0,0 +1,53 @@ +#!/usr/bin/env bats + +# `gitb cfg fast-split` toggles gitbasher.commit-fast-split, which decides +# whether fast commit modes (fast, ff, ...) split without asking. + +load setup_suite + +setup() { + setup_test_repo + cd "$TEST_REPO" +} + +teardown() { + cleanup_test_repo +} + +fast_split_script() { + printf '%s' " + export GIT_CONFIG_GLOBAL='$BATS_TEST_TMPDIR/gitconfig-global' + source '$GITBASHER_ROOT/scripts/common.sh' 2>/dev/null + GITBASHER_SKIP_INIT_QUERIES=1 source '$GITBASHER_ROOT/scripts/init.sh' 2>/dev/null + source '$GITBASHER_ROOT/scripts/config.sh' + cd '$TEST_REPO' + project_name=test + $1 + " +} + +@test "get_fast_split: defaults to true and normalizes unknown values" { + run perl -e 'alarm 10; exec @ARGV' -- bash -c "$(fast_split_script 'get_fast_split + git config gitbasher.commit-fast-split false; get_fast_split + git config gitbasher.commit-fast-split junk; get_fast_split')" + assert_success + [ "$output" = $'true\nfalse\ntrue' ] +} + +@test "cfg fast-split: choice 2 turns it off locally, n skips global" { + run perl -e 'alarm 10; exec @ARGV' -- bash -c "$(fast_split_script 'configure_fast_split')" <<< "2n" + [[ "$output" == *"Set fast mode split to off"* ]] + [ "$(git config --local --get gitbasher.commit-fast-split)" = "false" ] + [ -z "$(git config --file "$BATS_TEST_TMPDIR/gitconfig-global" --get gitbasher.commit-fast-split)" ] +} + +@test "cfg fast-split: y moves the value to global config" { + run perl -e 'alarm 10; exec @ARGV' -- bash -c "$(fast_split_script 'configure_fast_split')" <<< "2y" + [ "$(git config --file "$BATS_TEST_TMPDIR/gitconfig-global" --get gitbasher.commit-fast-split)" = "false" ] + [ -z "$(git config --local --get gitbasher.commit-fast-split)" ] +} + +@test "cfg fast-split: 0 exits without changes" { + run perl -e 'alarm 10; exec @ARGV' -- bash -c "$(fast_split_script 'configure_fast_split')" <<< "0" + [ -z "$(git config --local --get gitbasher.commit-fast-split)" ] +} From fce02251ae8a4e8cfdcc7bd2127ffec208768b0f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 13:02:48 +0000 Subject: [PATCH 3/3] feat(commit): show undo-split hint in the split type menu The per-group type menu now says, under its header, that the changes were split and that `u` undoes it, including how many split commits made so far it would drop. The one-time tip at the start of the split is kept only for the AI path, which has no type menu up front. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SfsWNhBqS9KpUcvsBuW9yE --- scripts/commit.sh | 14 +++++++++++--- tests/test_commit_prompt.bats | 11 +++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/scripts/commit.sh b/scripts/commit.sh index 5e6f55a..ed8a1a8 100644 --- a/scripts/commit.sh +++ b/scripts/commit.sh @@ -980,12 +980,19 @@ function _undo_commit_split { fi } +# $3: number of split commits already created in this run (for the undo hint) function print_split_type_menu { local scope="$1" local ai_available="$2" + local made="${3:-0}" echo -e "${YELLOW}What type of changes for ${BLUE}${BOLD}${scope}${ENDCOLOR}${YELLOW}?${ENDCOLOR}" echo -e "Final message will be ${YELLOW}${ENDCOLOR}(${BLUE}${ENDCOLOR}): ${BLUE}${ENDCOLOR}" + if [ "$made" -gt 0 ]; then + echo -e "${GRAY}Changes were split into groups — press ${ENDCOLOR}${BOLD}u${NORMAL}${GRAY} to undo the split (drops the ${made} split commit(s) made so far) and commit everything at once.${ENDCOLOR}" + else + echo -e "${GRAY}Changes were split into groups — press ${ENDCOLOR}${BOLD}u${NORMAL}${GRAY} to undo the split and commit everything at once.${ENDCOLOR}" + fi echo -e "1. ${BOLD}feat${ENDCOLOR}:\tnew feature, logic change or performance improvement" echo -e "2. ${BOLD}fix${ENDCOLOR}:\t\tsmall changes, eg. bug fix" echo -e "3. ${BOLD}refactor${ENDCOLOR}:\tcode change that neither fixes a bug nor adds a feature, style changes" @@ -1195,7 +1202,8 @@ function perform_commit_split { local scope files_str msg ai_msg choice scope_for_msg prefix manual_input local -a files_array - if [ -z "$auto_accept" ]; then + # The AI path has no type menu up front, so announce the undo key once. + if [ -z "$auto_accept" ] && [ -n "$llm" ] && [ "$ai_ok" = "true" ]; then echo echo -e "${GRAY}Tip: press ${ENDCOLOR}${BOLD}u${NORMAL}${GRAY} at a split prompt to undo the split and make a single commit instead.${ENDCOLOR}" fi @@ -1299,7 +1307,7 @@ function perform_commit_split { local commit_type="" local is_empty_msg="" echo - print_split_type_menu "$scope" "$ai_ok" + print_split_type_menu "$scope" "$ai_ok" "$commit_count" local tchoice while true; do @@ -1342,7 +1350,7 @@ function perform_commit_split { ;; *) echo - print_split_type_menu "$scope" "$ai_ok" + print_split_type_menu "$scope" "$ai_ok" "$commit_count" ;; esac ;; diff --git a/tests/test_commit_prompt.bats b/tests/test_commit_prompt.bats index 143e4ab..e1fc8eb 100644 --- a/tests/test_commit_prompt.bats +++ b/tests/test_commit_prompt.bats @@ -32,6 +32,17 @@ teardown() { [[ "$output" == *"u. "*"undo"* ]] } +@test "split type menu explains undo and counts split commits already made" { + run print_split_type_menu "docs" "false" "0" + assert_success + [[ "$output" == *"to undo the split and commit everything at once"* ]] + [[ "$output" != *"split commit(s) made so far"* ]] + + run print_split_type_menu "docs" "false" "2" + assert_success + [[ "$output" == *"drops the 2 split commit(s) made so far"* ]] +} + @test "regular type menu uses split menu styling" { run print_commit_type_menu "2" assert_success