Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ gitb commit <combined> # 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 |
Expand All @@ -383,6 +383,8 @@ gitb commit <combined> # 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`
Expand Down Expand Up @@ -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 |

Expand Down Expand Up @@ -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 <dir>` | Base directory for new worktrees (default `.worktree` under the repo root) |
| `gitbasher.commit-auto-split` | `git config gitbasher.commit-auto-split <ask\|always\|never>` | Offer to split a commit per scope (default `ask`) |
| `gitbasher.commit-auto-split` | `git config gitbasher.commit-auto-split <ask\|always\|never>` | 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 <auto\|alpha>` | Order split commits by dependency (`auto`, default) or alphabetically (`alpha`) |
| `gitbasher.commit-ai-grouping` | `git config gitbasher.commit-ai-grouping <never\|auto\|always>` | AI feature grouping for commit splits (default `auto`) |
Expand Down
152 changes: 134 additions & 18 deletions scripts/commit.sh

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions scripts/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
}
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions scripts/completion.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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]'
Expand Down Expand Up @@ -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"
Expand Down
53 changes: 53 additions & 0 deletions scripts/config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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";;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
113 changes: 111 additions & 2 deletions tests/test_commit_prompt.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down Expand Up @@ -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*}"
Expand Down Expand Up @@ -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"
Expand Down
61 changes: 61 additions & 0 deletions tests/test_commit_split_integrity.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]
}
Loading
Loading