GTST-21 feat: add hunk-level checkout and diff --staged support#18
GTST-21 feat: add hunk-level checkout and diff --staged support#18
Conversation
Add selective discard of changes at hunk granularity: - `gitsift checkout --hunk-ids` discards unstaged hunks (workdir → index) - `gitsift checkout --staged --hunk-ids` discards staged hunks (index → HEAD) - `gitsift diff --staged` shows staged changes (HEAD vs index) - Protocol support: checkout method with staged param, diff staged param - Handles untracked files (deletion) and staged new files (index removal) New file: src/git/checkout.rs (reverse-patch reconstruction + apply) Updated: cli, models, diff, output, toon, protocol, main, docs, skill, tests
Review -- Opus 4.6Overall Verdict: HAS_ISSUES 1. CorrectnessVerdict: HAS_ISSUES IssuesImportant
Minor
Fix InstructionsFor the Important issue: Either (a) apply all reverse patches in a single concatenated patch string (one // Concatenate all reverse patches into one unified diff string
let mut combined_patch = String::new();
let mut patch_count = 0usize;
for hunk_id in &tracked_ids {
match hunks.get(hunk_id) {
None => { /* error handling */ }
Some(raw) => {
combined_patch.push_str(&reconstruct_reverse_patch(raw));
patch_count += 1;
}
}
}
if !combined_patch.is_empty() {
let patch_diff = Diff::from_buffer(combined_patch.as_bytes())
.context("failed to parse combined reverse patch")?;
repo.apply(&patch_diff, ApplyLocation::WorkDir, None)
.context("failed to apply combined reverse patch")?;
discarded += patch_count;
}For the Minor issue: Change line 279 from 2. ArchitectureVerdict: PASS The PR follows established patterns well:
Minor
3. SecurityVerdict: HAS_ISSUES IssuesImportant
Fix InstructionsCanonicalize the path and verify it is within the repository before deletion: for path in &untracked_paths {
let full_path = repo_path.join(path);
let canonical = full_path.canonicalize()
.with_context(|| format!("failed to resolve path: {path}"))?;
let repo_canonical = repo_path.canonicalize()
.context("failed to resolve repo path")?;
if !canonical.starts_with(&repo_canonical) {
errors.push(format!("path escapes repository: {path}"));
failed += 1;
continue;
}
if canonical.exists() {
std::fs::remove_file(&canonical)
.with_context(|| format!("failed to delete untracked file: {path}"))?;
}
}Note: For extra defense-in-depth, also check that the path does not contain 4. PerformanceVerdict: PASS Minor
5. SimplicityVerdict: PASS The implementation is appropriately scoped:
Minor
SummaryThis is a well-structured PR that cleanly mirrors the existing Two issues warrant attention before merge: (1) Sequential application of multiple reverse patches may fail when hunks are close together, because the working tree changes between applies but patches are from the pre-apply state -- this should be fixed by concatenating patches and applying atomically. (2) The untracked file deletion path should validate that the resolved path stays within the repository boundary, since this is a destructive filesystem operation that |
- Concatenate all reverse patches into a single string and apply atomically via one repo.apply() call, preventing failures when hunks are close together (both unstaged and staged paths). - Add canonicalize + starts_with guard before deleting untracked files to prevent path traversal outside the repository boundary.
Summary
gitsift checkoutsubcommand for selective hunk-level discard of changes--hunk-idsto discard unstaged hunks (workdir → index)--staged --hunk-idsto discard staged hunks (index → HEAD)--from-stdinfor JSON inputgitsift diff --stagedto view staged changes (HEAD vs index)checkoutmethod to JSON-lines protocol withstagedparamstagedparam to protocoldiffmethodImplementation
src/git/checkout.rs— reverse-patch reconstruction (swap +/- lines), apply viaDiff::from_buffer()+repo.apply()diff_staged()insrc/git/diff.rs— usesdiff_tree_to_index()CheckoutRequest,CheckoutResult,CheckoutParamsTest plan
src/git/checkout.rs(8 tests: unstaged/staged, single/all hunks, untracked, invalid ID, empty request, content restoration)src/git/diff.rs(4 tests: staged diff show/empty/filter/new-file)src/models.rs(5 tests: checkout request/result roundtrips, protocol parse)tests/cli.rs(7 tests: checkout unstaged/staged, toon output, diff --staged, full E2E workflow)tests/edge_cases.rs(3 tests: untracked file delete, from-stdin, protocol checkout)cargo clippy --all-targets— zero warningscargo fmt --check— clean