fix(git): commit only requested paths, handle deletions, label renames (#942) - #1058
Conversation
#942) Five defects, each putting wrong data in the user's repo or their patch file. 1. create_commit staged the requested files then called repo.index.commit(), which writes the ENTIRE index — anything an agent run left staged was swept into the user's commit while files_changed reported only what they asked for. Now commits via `git commit -- <paths>`, so unrelated staged work stays staged and out of the commit. 2. Paths that did not exist were skipped, so a deletion get_status offered as committable vanished from a 201 and a deletions-only request failed with "None of the specified files exist". Deletions of tracked paths are now staged with index.remove; genuinely unknown paths come back in a new CommitResult.skipped rather than disappearing. 3. get_diff_stats labelled every rename "modified" and reported numstat's "old => new" as the path. It now parses the new path (including the dir/{a => b}/f form) and labels renamed. The per-file re.search over the whole diff is replaced by one _index_diff_sections() pass — the old shape was O(files x diff size) on exactly the large diffs where it matters. 4. export_patch(staged_only=True) fell back to plain `git diff` on an empty index, writing UNSTAGED work to a file whose name and PatchInfo both claim "staged". It now raises "No staged changes to export"; exporting the wrong changes is worse than exporting none. 5. Nothing ever wrote a .codeframe/ ignore rule although the sandbox code assumed one, so `cf commit --all` could stage state.db, WAL files and worktree gitlinks into user history. create_or_load_workspace now writes .codeframe/.gitignore containing `*`. Deliberately self-ignoring rather than editing the repo's root .gitignore: that file belongs to the user, may be committed, and is not ours to touch. 15 tests against a REAL git repo, not mocks — the pre-staged-file and deletions-only cases are only meaningful against real index behaviour. Regression sweep: 707 passed across git/commit/patch/workspace/artifact. ruff + mypy clean.
|
Claude finished @frankbria's task in 9m 54s —— View job GLM precision review — 3 defects foundScope: concrete defects only (logic errors, error paths, security, data loss, races, contract breaks, resource leaks). Style/architecture/naming/coverage left to the general review. Findings posted as inline comments:
Most severe first. No critical/data-loss or security issues found. The five targeted defects are fixed correctly for their primary cases; the findings above are gaps in two of them (the deletion case misses already-staged removals; the skipped-path visibility doesn't reach the API) plus one false-positive in the rename labeling.
|
…#942) Three PR-review findings, two major. MAJOR — _is_tracked checked the INDEX (ls-files) but the docstring promised HEAD. `git rm` removes the index entry, so a deletion the user had ALREADY staged looked untracked and was rejected. Now checks the index or HEAD. Fixing that exposed a second half: index.remove on a path already gone from the index fails with "pathspec did not match any files", so removal is only staged for paths still indexed. MAJOR — `skipped` was computed in core and discarded by CommitResultResponse, so POST /git/commit returned a 201 and a files_changed count with no way for the caller to learn a requested path had been dropped. AC2 asked for it to be returned; it now is. MINOR — the rename check was an unanchored substring scan over the file section, so editing a file to contain the line "rename from users to accounts" labelled it `renamed`. Anchored to line start with re.MULTILINE. 3 tests, plus a correction to my own indexed-once test: it asserted `re.search` was absent from get_diff_stats, which the anchored rename check legitimately uses. It now asserts the OLD per-file pattern (re.escape(file_path)) is gone — the property that actually mattered.
|
Claude finished @frankbria's task in 6m 16s —— View job GLM precision review (re-run on synchronize)Re-verifying the current commit. Three findings were already posted on the prior run (git.py:282, :59, :407); I will not repeat those and will only surface new defects against the current diff.
Result✅ GLM review: no new defects found against the current commit ( The three prior findings are all correctly resolved, and the fixes introduce no regressions I can substantiate with a concrete failure scenario:
Spot-checks I confirmed against the code (no defect in any):
Edge cases considered and judged not reportable defects (no concrete failure scenario / pre-existing, not introduced here): paths containing spaces (git quotes them in both the No inline comments posted — nothing new cleared the concrete-failure bar. |

Closes #942.
Five defects, each of which put wrong data in the user's repository or their patch file.
1.
create_commitcommitted the entire indexIt staged the requested files, then called
repo.index.commit()— which writes the whole index. Anything already staged (an agent run's leftovers, say) was swept into the user's commit, whilefiles_changedreported only the count they asked for.Now commits via
git commit -- <paths>, so unrelated staged work stays staged and out of the commit. A test asserts both halves: the unrelated file is not in the commit, and is still staged afterwards — excluding it must not silently discard the user's other work.2. Deletions were silently dropped
Any path that did not exist was skipped. So a deletion
get_statusoffered as committable vanished from a 201 response, and a deletions-only request failed with"None of the specified files exist".Deletions of tracked paths are now staged with
index.remove. Genuinely unknown paths come back in a newCommitResult.skippedrather than disappearing from the response entirely.3. Every rename was labelled "modified"
get_diff_statsreported numstat's rawold => newstring as the path — a value matching no file — and the rename marker in the diff header was never found, so the type was alwaysmodified.It now parses the new path (including the
dir/{a => b}/fform git uses for directory moves) and labelsrenamed. The per-filere.searchover the whole diff is replaced by a single_index_diff_sections()pass; the old shape was O(files × diff size), worst on exactly the large diffs where it matters.4.
staged_only=Trueexported unstaged workOn an empty index it fell back to plain
git diff— writing unstaged changes to a file whose name andPatchInfoboth claim "staged".Now raises
"No staged changes to export". Exporting the wrong changes is worse than exporting none.5.
.codeframe/was never ignoredThe sandbox code assumed it, but nothing wrote the rule — so
cf commit --all(or anygit add -A) could stagestate.db, WAL files and worktree gitlinks into the user's history.create_or_load_workspacenow writes.codeframe/.gitignorecontaining*. Self-ignoring on purpose: the repo's root.gitignorebelongs to the user, may be committed, and is not ours to edit. A self-ignoring directory needs no cooperation from it, works in a repo with no.gitignoreat all, and disappears with the directory. An existing marker is never overwritten.Acceptance criteria
create_commitcommits only the requested paths; a test with an unrelated pre-staged file asserts it is excludedget_diff_statsreportsrenamedwith the new path and indexes the diff body onceexport_patch(staged_only=True)reports "no staged changes" instead of exporting unstaged work.codeframe/is ignored; a test assertsgit add -Astages nothing under itTests
15, against a real git repo rather than mocks — the pre-staged-file and deletions-only cases are only meaningful against real index behaviour, and a mocked index would have happily confirmed the broken version.
Regression sweep: 707 passed across git/commit/patch/workspace/artifact selections.
ruffandmypy codeframe/clean.Known limitations
git commit -- <paths>bypassesrepo.index.commit(), so GitPython's in-memory index object is stale afterwards. Nothing in this codebase reuses it across a commit, but a future caller that does would need torepo.index.reset()..codeframe/.gitignoreguard only applies at workspace creation. Existing workspaces get it on their nextcreate_or_load_workspaceonly if the directory was removed; a migration for already-initialised repos is not included.