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
55 changes: 45 additions & 10 deletions scripts/commit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
37 changes: 37 additions & 0 deletions tests/test_commit_split_integrity.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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)" ]
}
Loading