diff --git a/scripts/commit.sh b/scripts/commit.sh index 04f57c1..ae4ff3a 100644 --- a/scripts/commit.sh +++ b/scripts/commit.sh @@ -902,6 +902,23 @@ function _stage_file_by_status { esac } +### Stage a split group exactly as it was in the original index: each path +### gets its entry from the pre-split tree (or is removed when the tree has +### none). Unlike re-adding the worktree copy this keeps partially staged +### hunks, and works for staged files since deleted from the worktree. +# $1: tree of the original index (git write-tree) +# $2..: files of the group +# Prints git's error on stderr and returns non-zero on failure. +function _stage_split_group_from_tree { + local tree="$1"; shift + local -a pathspecs=() + local f + for f in "$@"; do + pathspecs+=(":(top,literal)$f") + done + git restore --staged --source="$tree" -- "${pathspecs[@]}" 2>&1 >/dev/null +} + ### Capture the change type for each currently-staged file into a map. # Sets the global associative array `_split_status_by_file` (file → A/M/D/...). # For renames/copies (R, C), git emits the new path as the second field — we @@ -1238,18 +1255,31 @@ function perform_commit_split { # Stage each file using the original change type so deletions stay # deletions (otherwise `git add` would re-add the worktree copy and # the index ends up empty for that scope, breaking AI generation). - local stage_failed=0 + # Prefer the original index tree: it is exact (partial hunks, staged + # files since removed from the worktree). The per-file replay is the + # fallback when the tree could not be captured. + local stage_failed=0 stage_err="" local _stage_status - for f in "${files_array[@]}"; do - _stage_status=$(gmap_get _split_status_by_file "$f"); [ -z "$_stage_status" ] && _stage_status="M" - if ! _stage_file_by_status "$f" "$_stage_status"; then - stage_failed=1 - break - fi - done + if [ -n "$orig_tree" ]; then + stage_err=$(_stage_split_group_from_tree "$orig_tree" "${files_array[@]}") || stage_failed=1 + else + for f in "${files_array[@]}"; do + _stage_status=$(gmap_get _split_status_by_file "$f"); [ -z "$_stage_status" ] && _stage_status="M" + if ! stage_err=$(_stage_file_by_status "$f" "$_stage_status" 2>&1); then + stage_failed=1 + break + fi + done + fi if [ "$stage_failed" = "1" ]; then echo -e "${RED}✗ Cannot stage files for scope '${scope}'.${ENDCOLOR}" - _restore_split_snapshot "$snapshot_file" + [ -n "$stage_err" ] && echo -e "${GRAY}${stage_err}${ENDCOLOR}" + if [ -n "$orig_tree" ] && git read-tree "$orig_tree" >/dev/null 2>&1; then + rm -f "$snapshot_file" + else + _restore_split_snapshot "$snapshot_file" + fi + echo -e "${YELLOW}Original staging restored.${ENDCOLOR}" trap - INT TERM return 1 fi @@ -1539,9 +1569,13 @@ function try_offer_commit_split { fi fi + local grouped_with_ai="" if [ "$should_use_ai" = "true" ] && check_ai_available 2>/dev/null; then echo echo -e "${YELLOW}Grouping changes by feature with AI...${ENDCOLOR}" + # Keep the spinner off the header line, like the other progress views. + echo + grouped_with_ai="true" local ai_grouping_fail_reason="" if ! group_files_by_feature_with_ai; then # AI failed; keep the folder heuristic groups (may be empty). Surface @@ -1568,7 +1602,8 @@ function try_offer_commit_split { # split commits don't reference code that history hasn't introduced yet. order_split_groups_by_dependency - echo + # The AI grouping header already left a blank line. + if [ -z "$grouped_with_ai" ]; then echo; fi print_split_groups_preview echo diff --git a/tests/test_commit_split_integrity.bats b/tests/test_commit_split_integrity.bats index f4660fb..f6c1e5d 100644 --- a/tests/test_commit_split_integrity.bats +++ b/tests/test_commit_split_integrity.bats @@ -169,3 +169,40 @@ teardown() { [ "$(git rev-parse HEAD)" = "$start_head" ] [ "$(git write-tree)" = "$orig_tree" ] } + +@test "perform_commit_split: commits the staged content, even for files gone from the worktree" { + 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" + + echo "staged" > docs/a.md + echo "new" > docs/new.md + echo "changed" > scripts/b.sh + git add docs/a.md docs/new.md scripts/b.sh + # Unstaged edits and a staged file deleted from the worktree: re-adding + # the worktree copy would commit the wrong content or fail outright. + echo "unstaged-extra" >> docs/a.md + rm docs/new.md + + gmap_clear split_groups + gmap_set split_groups "docs" "docs/a.md"$'\n'"docs/new.md" + gmap_set split_groups "scripts" "scripts/b.sh" + split_group_keys=(docs scripts) + llm="true" + auto_accept="true" + push="" + current_branch="main" + check_ai_available() { return 0; } + generate_ai_commit_message() { echo "chore: ai message"; } + + run perform_commit_split + + [ "$status" -eq 0 ] + [[ "$output" != *"Cannot stage files"* ]] + [ "$(git show HEAD~1:docs/a.md)" = "staged" ] + [ "$(git show HEAD~1:docs/new.md)" = "new" ] + [ "$(git show HEAD:scripts/b.sh)" = "changed" ] + [ -z "$(git diff --cached --name-only)" ] +}