diff --git a/README.md b/README.md index c96d345..8069926 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 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). ### `gitb edit` @@ -804,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 | @@ -890,7 +893,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` | `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 1304d6a..ed8a1a8 100644 --- a/scripts/commit.sh +++ b/scripts/commit.sh @@ -953,12 +953,46 @@ 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 +} + +# $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" @@ -972,6 +1006,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 +1069,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 +1096,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 +1117,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 +1135,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 +1148,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 +1164,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 +1202,12 @@ function perform_commit_split { local scope files_str msg ai_msg choice scope_for_msg prefix manual_input local -a files_array + # 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 + for scope in "${split_group_keys[@]}"; do idx=$((idx + 1)) echo @@ -1224,6 +1275,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 @@ -1249,12 +1307,12 @@ 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 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 +1334,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 @@ -1285,10 +1350,17 @@ function perform_commit_split { ;; *) echo - print_split_type_menu "$scope" "$ai_ok" + print_split_type_menu "$scope" "$ai_ok" "$commit_count" ;; 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 +1458,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 +1476,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 +1512,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 +1570,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 +1586,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 +2155,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 +2174,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 (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 git config --unset gitbasher.cached-git-add 2>/dev/null || true @@ -2283,17 +2391,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_fast_split)" = "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/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_commit_prompt.bats b/tests/test_commit_prompt.bats index 657134d..e1fc8eb 100644 --- a/tests/test_commit_prompt.bats +++ b/tests/test_commit_prompt.bats @@ -29,6 +29,18 @@ teardown() { [[ "$output" == *"9. ${blue}${bold}plain${endcolor}"* ]] [[ "$output" == *"${yellow}${bold}skip${endcolor}"* ]] [[ "$output" == *"${red}${bold}abort${endcolor}"* ]] + [[ "$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" { @@ -85,7 +97,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 +204,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" ] +} 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)" ] +}