Skip to main content

grit_lib/porcelain/
hunk_select.rs

1//! Select structured diff hunks by stable id and apply them to the index.
2
3use std::borrow::Cow;
4use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
5use std::fs;
6use std::path::Path as StdPath;
7use std::str::FromStr;
8
9use thiserror::Error;
10
11use crate::apply::parse_patch;
12use crate::apply::{
13    apply_hunks_to_blob, apply_patch_to_index_and_write, ApplyOptions, FilePatch, Hunk, HunkLine,
14    IndexApplyOptions, IndexApplyOutcome,
15};
16use crate::diff::hunk_id::{
17    hunk_ids, hunk_ids_with_duplicate_peers, DiffSide, HunkId, HunkIdError,
18};
19use crate::diff::structured::{
20    structured_file_diff, SideBytes, StructuredHunk, StructuredLine, StructuredLineKind,
21    StructuredSegment,
22};
23use crate::diff::{
24    collect_files_for_target, hunk_diff_bundle, imara_unified_hunk_slices,
25    read_worktree_side_bytes_for_diff, refresh_index_stat_content_verified_with_rules,
26    unified_hunk_body_for_diff_options, unstaged_file_diffs_with_peer_hunks,
27    unstaged_raw_changes_with_peer_hunks, DiffOptions, DiffStatus, DiffTarget, FileChange,
28    FileDiff,
29};
30use crate::error::{ApplyError, Error};
31use crate::index::{index_file_mtime, MODE_EXECUTABLE, MODE_GITLINK, MODE_REGULAR};
32use crate::objects::FileMode;
33use crate::objects::{parse_commit, ObjectId, ObjectKind};
34use crate::porcelain::checkout::{
35    remove_empty_parent_dirs, smudge_bytes_for_worktree_path, write_smudged_blob_to_worktree,
36    write_to_worktree_cached, LeadingDirCache,
37};
38use crate::progress::{poll_cancel, Cancel, ProgressPhase, ProgressSink};
39use crate::repo::Repository;
40use crate::state::{resolve_head_for_repository, HeadState};
41use crate::worktree_rules::WorktreeRules;
42
43/// New-side line range for partial hunk staging (1-based, inclusive).
44///
45/// Line numbers match `grit diff --json` `lines[].new` on the unstaged side.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct LineRange {
48    /// Repository-relative path.
49    pub path: String,
50    /// First new-side line number in the range (1-based).
51    pub start: usize,
52    /// Last new-side line number in the range (1-based, inclusive).
53    pub end: usize,
54}
55
56/// Selected hunks by stable id and/or new-side line ranges.
57#[derive(Debug, Clone, Default, PartialEq, Eq)]
58pub struct HunkSelection {
59    /// Stable hunk ids from `grit diff --json` (`hunks[].id`).
60    pub ids: Vec<HunkId>,
61    /// Partial selections by new-side line number (`diff --json` `lines[].new`).
62    pub ranges: Vec<LineRange>,
63}
64
65/// Failure while resolving or staging hunk ids.
66#[derive(Debug, Error, Clone, PartialEq, Eq)]
67pub enum HunkSelectError {
68    /// Id string is not 12 lowercase hex characters.
69    #[error("unknown hunk id {id}")]
70    UnknownId {
71        /// Raw id token from the caller.
72        id: String,
73    },
74    /// Id is well-formed but no longer present on the requested side.
75    #[error("stale hunk id {id}")]
76    StaleId {
77        /// The requested id.
78        id: HunkId,
79        /// Path when the caller narrowed the diff (or a single candidate file).
80        path: Option<String>,
81        /// Current ids on the same path when cheap to compute.
82        current_ids: Vec<HunkId>,
83    },
84    /// Id belongs to the other index/worktree side (for example staged vs unstaged).
85    #[error("hunk id {id} is for the {actual_side} side, not {requested_side}")]
86    WrongSide {
87        /// The requested id.
88        id: HunkId,
89        /// Side the id was computed for.
90        actual_side: &'static str,
91        /// Side the caller requested.
92        requested_side: &'static str,
93    },
94    /// Binary paths cannot be partially staged by hunk id.
95    #[error("binary file {path} has no selectable hunks")]
96    Binary {
97        /// Repository-relative path.
98        path: String,
99    },
100    /// Index apply failed after hunk resolution.
101    #[error("{0}")]
102    Apply(#[from] ApplyError),
103    /// Line range lies outside the file's new-side line count.
104    #[error("line range {start}-{end} is out of bounds for {path} (file has {max_line} lines)")]
105    RangeOutOfBounds {
106        /// Path named in the range.
107        path: String,
108        /// Range start (1-based).
109        start: usize,
110        /// Range end (1-based).
111        end: usize,
112        /// Last valid new-side line number.
113        max_line: usize,
114    },
115    /// Range does not select any additions or deletions.
116    #[error("line range selects nothing on {path}")]
117    RangeSelectsNothing {
118        /// Path named in the range.
119        path: String,
120    },
121    /// Repository or diff engine failure (not a hunk-id selection error).
122    #[error("{0}")]
123    Repository(String),
124    /// Selected hunks could not be turned into an apply patch.
125    #[error("could not build patch for selected hunks")]
126    PatchBuild,
127}
128
129impl From<Error> for HunkSelectError {
130    fn from(err: Error) -> Self {
131        HunkSelectError::Repository(err.to_string())
132    }
133}
134
135impl From<HunkIdError> for HunkSelectError {
136    fn from(_: HunkIdError) -> Self {
137        HunkSelectError::UnknownId { id: String::new() }
138    }
139}
140
141/// Result of [`stage_hunks`].
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct StageHunksOutcome {
144    /// Whether this was a dry run.
145    pub dry_run: bool,
146    /// Staged ids per path (repository-relative).
147    pub staged_hunks: BTreeMap<String, Vec<HunkId>>,
148    /// Staged line ranges per path when line-range selection was used (`start` or `start-end` strings).
149    pub staged_lines: BTreeMap<String, Vec<String>>,
150    /// Index paths touched by the apply engine.
151    pub added: Vec<String>,
152    /// Paths updated in the index.
153    pub modified: Vec<String>,
154    /// Paths removed from the index.
155    pub removed: Vec<String>,
156}
157
158impl StageHunksOutcome {
159    fn from_apply(
160        dry_run: bool,
161        staged_hunks: BTreeMap<String, Vec<HunkId>>,
162        staged_lines: BTreeMap<String, Vec<String>>,
163        apply: IndexApplyOutcome,
164    ) -> Self {
165        Self {
166            dry_run,
167            staged_hunks,
168            staged_lines,
169            added: apply.added,
170            modified: apply.modified,
171            removed: apply.removed,
172        }
173    }
174}
175
176fn side_label(side: DiffSide) -> &'static str {
177    match side {
178        DiffSide::Unstaged => "unstaged",
179        DiffSide::Staged => "staged",
180        DiffSide::Other => "other",
181    }
182}
183
184/// Unstaged file diffs with staged peer hunks on the same path (for id assignment).
185///
186/// # Errors
187///
188/// Propagates diff and repository errors.
189pub fn unstaged_file_diffs(
190    repo: &Repository,
191    pathspecs: &[String],
192) -> std::result::Result<Vec<(FileDiff, Vec<StructuredHunk>)>, Error> {
193    let opts = DiffOptions::from_config(repo.config()?.as_ref());
194    let (files, peers) = unstaged_file_diffs_with_peer_hunks(repo, pathspecs, opts)?;
195    Ok(files
196        .into_iter()
197        .map(|f| {
198            let peer = peers.get(&f.path).cloned().unwrap_or_default();
199            (f, peer)
200        })
201        .collect())
202}
203
204/// Parse comma-separated or repeated id tokens into a [`HunkSelection`].
205///
206/// # Errors
207///
208/// Returns [`HunkSelectError::UnknownId`] for malformed tokens.
209pub fn parse_hunk_id_list(
210    tokens: &[String],
211) -> std::result::Result<HunkSelection, HunkSelectError> {
212    let mut ids = Vec::new();
213    for token in tokens {
214        for part in token.split(',').map(str::trim).filter(|s| !s.is_empty()) {
215            ids.push(
216                HunkId::from_str(part).map_err(|_| HunkSelectError::UnknownId {
217                    id: part.to_string(),
218                })?,
219            );
220        }
221    }
222    Ok(HunkSelection {
223        ids,
224        ranges: Vec::new(),
225    })
226}
227
228/// Resolve `selection` on `side` to index-ready [`FilePatch`] rows.
229///
230/// Recomputes the live diff only for unstaged paths (optionally filtered by `pathspecs`).
231/// Each path's id set uses the same peer-aware rules as `grit diff --json`.
232///
233/// # Errors
234///
235/// See [`HunkSelectError`].
236pub fn resolve_selection(
237    repo: &Repository,
238    side: DiffSide,
239    selection: &HunkSelection,
240    pathspecs: &[String],
241    diff_options: DiffOptions,
242) -> std::result::Result<Vec<FilePatch>, HunkSelectError> {
243    // Returns index-ready patches; see [`resolve_selection_with_staged`] for staged id map.
244    Ok(resolve_selection_with_staged(repo, side, selection, pathspecs, diff_options)?.patches)
245}
246
247/// Path-keyed hunk ids and line ranges resolved from a selection (for JSON reporting).
248#[derive(Debug, Clone, Default, PartialEq, Eq)]
249pub struct HunkSelectionReport {
250    /// Stable hunk ids per repository-relative path.
251    pub stashed_hunks: BTreeMap<String, Vec<HunkId>>,
252    /// Line range labels per path (`start` or `start-end` strings).
253    pub stashed_lines: BTreeMap<String, Vec<String>>,
254}
255
256/// Resolve `selection` on the unstaged side and return path-keyed reporting maps.
257///
258/// # Errors
259///
260/// Same as [`resolve_selection`].
261pub fn report_unstaged_hunk_selection(
262    repo: &Repository,
263    selection: &HunkSelection,
264    pathspecs: &[String],
265) -> std::result::Result<HunkSelectionReport, HunkSelectError> {
266    let diff_opts = diff_options_for_hunk_select(repo)?;
267    let resolved =
268        resolve_selection_with_staged(repo, DiffSide::Unstaged, selection, pathspecs, diff_opts)?;
269    Ok(HunkSelectionReport {
270        stashed_hunks: resolved.staged_hunks,
271        stashed_lines: resolved.staged_lines,
272    })
273}
274
275#[derive(Debug, Clone)]
276struct ResolvedSelection {
277    patches: Vec<FilePatch>,
278    staged_hunks: BTreeMap<String, Vec<HunkId>>,
279    staged_lines: BTreeMap<String, Vec<String>>,
280}
281
282fn merge_resolved(into: &mut ResolvedSelection, other: ResolvedSelection) {
283    for (path, ids) in other.staged_hunks {
284        into.staged_hunks.entry(path).or_default().extend(ids);
285    }
286    for (path, ranges) in other.staged_lines {
287        into.staged_lines.entry(path).or_default().extend(ranges);
288    }
289    for patch in other.patches {
290        if let Some(existing) = into
291            .patches
292            .iter_mut()
293            .find(|p| p.effective_path() == patch.effective_path())
294        {
295            existing.hunks.extend(patch.hunks);
296            existing.hunks.sort_by_key(|h| (h.new_start, h.old_start));
297        } else {
298            into.patches.push(patch);
299        }
300    }
301}
302
303fn resolve_selection_with_staged(
304    repo: &Repository,
305    side: DiffSide,
306    selection: &HunkSelection,
307    pathspecs: &[String],
308    diff_options: DiffOptions,
309) -> std::result::Result<ResolvedSelection, HunkSelectError> {
310    if selection.ids.is_empty() && selection.ranges.is_empty() {
311        return Ok(ResolvedSelection {
312            patches: Vec::new(),
313            staged_hunks: BTreeMap::new(),
314            staged_lines: BTreeMap::new(),
315        });
316    }
317    let opts = diff_options;
318    let requested: HashSet<HunkId> = selection.ids.iter().cloned().collect();
319
320    match side {
321        DiffSide::Unstaged => {
322            let mut resolved = if requested.is_empty() {
323                ResolvedSelection {
324                    patches: Vec::new(),
325                    staged_hunks: BTreeMap::new(),
326                    staged_lines: BTreeMap::new(),
327                }
328            } else {
329                resolve_unstaged(repo, pathspecs, opts.clone(), &requested)?
330            };
331            if !selection.ranges.is_empty() {
332                let line_part =
333                    resolve_line_ranges_unstaged(repo, pathspecs, opts, selection, &requested)?;
334                merge_resolved(&mut resolved, line_part);
335            }
336            Ok(resolved)
337        }
338        DiffSide::Staged => {
339            if !selection.ranges.is_empty() {
340                return Err(HunkSelectError::RangeSelectsNothing {
341                    path: selection.ranges[0].path.clone(),
342                });
343            }
344            resolve_staged(repo, pathspecs, opts, &requested)
345        }
346        DiffSide::Other => {
347            let id = selection.ids.first().cloned().ok_or_else(|| {
348                HunkSelectError::Repository(
349                    "hunk selection requires unstaged or staged side".into(),
350                )
351            })?;
352            Err(HunkSelectError::WrongSide {
353                id,
354                actual_side: "other",
355                requested_side: side_label(side),
356            })
357        }
358    }
359}
360
361fn resolve_staged(
362    repo: &Repository,
363    pathspecs: &[String],
364    opts: DiffOptions,
365    requested: &HashSet<HunkId>,
366) -> std::result::Result<ResolvedSelection, HunkSelectError> {
367    let files = collect_files_for_target(repo, &DiffTarget::IndexVsHead, pathspecs, opts)
368        .map_err(map_repo_err)?;
369    resolve_on_files(
370        DiffSide::Staged,
371        files,
372        HashMap::new(),
373        requested,
374        pathspecs,
375    )
376}
377
378fn resolve_unstaged(
379    repo: &Repository,
380    pathspecs: &[String],
381    opts: DiffOptions,
382    requested: &HashSet<HunkId>,
383) -> std::result::Result<ResolvedSelection, HunkSelectError> {
384    let (changes, peer_hunks) = unstaged_raw_changes_with_peer_hunks(repo, pathspecs, opts.clone())
385        .map_err(map_repo_err)?;
386    let mut patches = Vec::new();
387    let mut staged_hunks = BTreeMap::new();
388    let mut found = HashSet::new();
389
390    for change in &changes {
391        if change.binary {
392            continue;
393        }
394        let peer = peer_hunks
395            .get(&change.path)
396            .map(|h| h.as_slice())
397            .unwrap_or(&[]);
398        let (file_patches, staged, matched) = resolve_file_change_by_unified_body(
399            DiffSide::Unstaged,
400            change,
401            peer,
402            requested,
403            &opts,
404        )?;
405        found.extend(matched);
406        if let Some(staged) = staged {
407            staged_hunks.insert(change.path.clone(), staged);
408        }
409        patches.extend(file_patches);
410    }
411
412    let missing: Vec<HunkId> = requested
413        .iter()
414        .filter(|id| !found.contains(*id))
415        .cloned()
416        .collect();
417    if let Some(id) = missing.first().cloned() {
418        if let Some(wrong) = find_id_on_staged_side(repo, pathspecs, opts.clone(), requested)? {
419            return Err(HunkSelectError::WrongSide {
420                id: wrong,
421                actual_side: "staged",
422                requested_side: "unstaged",
423            });
424        }
425        if pathspecs.len() == 1 {
426            let path = &pathspecs[0];
427            if changes.iter().any(|c| c.path == *path && c.binary) {
428                return Err(HunkSelectError::Binary { path: path.clone() });
429            }
430        }
431        let files: Vec<FileDiff> = changes
432            .iter()
433            .filter(|c| !c.binary)
434            .map(|c| build_file_diff_for_stale(c, &opts))
435            .collect();
436        let current_ids = current_ids_for_stale(DiffSide::Unstaged, pathspecs, &files, &peer_hunks);
437        return Err(HunkSelectError::StaleId {
438            id,
439            path: stale_path_hint(pathspecs, &files),
440            current_ids,
441        });
442    }
443
444    Ok(ResolvedSelection {
445        patches,
446        staged_hunks,
447        staged_lines: BTreeMap::new(),
448    })
449}
450
451fn side_text(bytes: &SideBytes) -> Cow<'_, str> {
452    if bytes.data.is_empty() {
453        return Cow::Borrowed("");
454    }
455    match std::str::from_utf8(&bytes.data) {
456        Ok(text) => Cow::Borrowed(text),
457        Err(_) => Cow::Owned(String::from_utf8_lossy(&bytes.data).into_owned()),
458    }
459}
460
461fn line_count(text: &str) -> usize {
462    if text.is_empty() {
463        return 0;
464    }
465    let ends_nl = text.ends_with('\n');
466    let mut parts = text.split('\n').count();
467    if ends_nl {
468        parts = parts.saturating_sub(1);
469    }
470    parts
471}
472
473type UnifiedHunkResolve = (Vec<FilePatch>, Option<Vec<HunkId>>, HashSet<HunkId>);
474
475fn resolve_file_change_by_unified_body(
476    side: DiffSide,
477    change: &FileChange,
478    peer_hunks: &[StructuredHunk],
479    requested: &HashSet<HunkId>,
480    diff_options: &DiffOptions,
481) -> std::result::Result<UnifiedHunkResolve, HunkSelectError> {
482    let old_text = side_text(&change.old_bytes);
483    let new_text = side_text(&change.new_bytes);
484    let body =
485        unified_hunk_body_for_diff_options(old_text.as_ref(), new_text.as_ref(), diff_options);
486    let before_count = line_count(old_text.as_ref());
487    let after_count = line_count(new_text.as_ref());
488    let before_ends_nl = old_text.as_ref().ends_with('\n');
489    let after_ends_nl = new_text.as_ref().ends_with('\n');
490    let slices = imara_unified_hunk_slices(&body);
491    let ids = crate::diff::hunk_id::hunk_ids_for_unified_slices(
492        side,
493        &change.path,
494        &slices,
495        before_count,
496        after_count,
497        before_ends_nl,
498        after_ends_nl,
499        peer_hunks,
500    );
501    let mut selected_slices = Vec::new();
502    let mut staged_ids = Vec::new();
503    let mut matched = HashSet::new();
504    for (slice, id) in slices.iter().zip(ids.iter()) {
505        if requested.contains(id) {
506            selected_slices.push(*slice);
507            staged_ids.push(id.clone());
508            matched.insert(id.clone());
509        }
510    }
511    if selected_slices.is_empty() {
512        return Ok((Vec::new(), None, matched));
513    }
514    let patch = file_patch_from_unified_slices(change, &selected_slices)?;
515    Ok((vec![patch], Some(staged_ids), matched))
516}
517
518fn file_patch_from_unified_slices(
519    change: &FileChange,
520    slices: &[&str],
521) -> std::result::Result<FilePatch, HunkSelectError> {
522    let path = &change.path;
523    let is_new = change.old_mode == FileMode::ABSENT;
524    let mut is_deleted =
525        matches!(change.status, DiffStatus::Deleted) || change.new_mode == FileMode::ABSENT;
526    let is_rename = matches!(change.status, DiffStatus::Renamed);
527    let is_copy = matches!(change.status, DiffStatus::Copied);
528    if !is_new && !is_deleted && side_text(&change.new_bytes).trim().is_empty() {
529        let preimage = side_text(&change.old_bytes);
530        if !preimage.trim().is_empty() {
531            is_deleted = true;
532        }
533    }
534    let mut text = format!("diff --git a/{path} b/{path}\n");
535    if is_new {
536        text.push_str(&format!("new file mode {}\n", change.new_mode));
537    }
538    if is_deleted {
539        text.push_str(&format!("deleted file mode {}\n", change.old_mode));
540    }
541    let old = if is_new {
542        "/dev/null".to_string()
543    } else {
544        format!("a/{}", change.old_path.as_deref().unwrap_or(path.as_str()))
545    };
546    let new = if is_deleted {
547        "/dev/null".to_string()
548    } else {
549        format!("b/{}", path)
550    };
551    text.push_str(&format!("--- {old}\n+++ {new}\n"));
552    for slice in slices {
553        text.push_str(slice);
554        if !slice.ends_with('\n') {
555            text.push('\n');
556        }
557    }
558    let mut patches = parse_patch(&text, 1, path, false, None).map_err(map_parse_err)?;
559    let mut patch = patches.pop().ok_or(HunkSelectError::PatchBuild)?;
560    patch.is_new = false;
561    patch.is_deleted = is_deleted;
562    patch.is_rename = is_rename;
563    patch.is_copy = is_copy;
564    patch.old_path = if is_new {
565        None
566    } else {
567        change.old_path.clone().or_else(|| Some(path.clone()))
568    };
569    patch.new_path = if is_deleted { None } else { Some(path.clone()) };
570    patch.old_mode = Some(change.old_mode.to_string());
571    patch.new_mode = Some(change.new_mode.to_string());
572    Ok(patch)
573}
574
575fn build_file_diff_for_stale(change: &FileChange, opts: &DiffOptions) -> FileDiff {
576    let structured = structured_file_diff(
577        change.old_mode,
578        change.new_mode,
579        &change.old_bytes,
580        &change.new_bytes,
581        opts.clone(),
582    );
583    FileDiff {
584        path: change.path.clone(),
585        old_path: change.old_path.clone(),
586        status: change.status,
587        old_mode: structured.old_mode,
588        new_mode: structured.new_mode,
589        old_oid: change.old_oid,
590        new_oid: change.new_oid,
591        binary: change.binary,
592        content_changed: change.content_changed,
593        old_encoding_lossy: structured.old_encoding_lossy,
594        new_encoding_lossy: structured.new_encoding_lossy,
595        hunks: structured.hunks,
596    }
597}
598
599fn find_id_on_staged_side(
600    repo: &Repository,
601    pathspecs: &[String],
602    opts: DiffOptions,
603    requested: &HashSet<HunkId>,
604) -> std::result::Result<Option<HunkId>, HunkSelectError> {
605    let files = collect_files_for_target(repo, &DiffTarget::IndexVsHead, pathspecs, opts)
606        .map_err(map_repo_err)?;
607    Ok(files.into_iter().find_map(|file| {
608        if file.binary {
609            return None;
610        }
611        hunk_ids(DiffSide::Staged, &file.path, &file.hunks)
612            .into_iter()
613            .find(|id| requested.contains(id))
614    }))
615}
616
617fn resolve_on_files(
618    side: DiffSide,
619    files: Vec<FileDiff>,
620    peer_hunks: HashMap<String, Vec<StructuredHunk>>,
621    requested: &HashSet<HunkId>,
622    pathspecs: &[String],
623) -> std::result::Result<ResolvedSelection, HunkSelectError> {
624    let mut found = HashSet::new();
625    let mut patches = Vec::new();
626    let mut staged_by_path: BTreeMap<String, Vec<HunkId>> = BTreeMap::new();
627
628    for file in &files {
629        if file.binary {
630            continue;
631        }
632        let peer = peer_hunks
633            .get(&file.path)
634            .map(|h| h.as_slice())
635            .unwrap_or(&[]);
636        let ids = hunk_ids_with_duplicate_peers(side, &file.path, &file.hunks, peer);
637        let mut selected = Vec::new();
638        let mut selected_ids = Vec::new();
639        for (hunk, id) in file.hunks.iter().zip(ids.iter()) {
640            if requested.contains(id) {
641                selected.push(hunk.clone());
642                selected_ids.push(id.clone());
643                found.insert(id.clone());
644            }
645        }
646        if selected.is_empty() {
647            continue;
648        }
649        staged_by_path.insert(file.path.clone(), selected_ids);
650        patches.push(file_patch_from_hunks(file, &selected)?);
651    }
652
653    let missing: Vec<HunkId> = requested
654        .iter()
655        .filter(|id| !found.contains(*id))
656        .cloned()
657        .collect();
658    if let Some(id) = missing.first().cloned() {
659        if let Some(path) = stale_path_hint(pathspecs, &files) {
660            if files
661                .iter()
662                .find(|f| f.path == path)
663                .is_some_and(|f| f.binary)
664            {
665                return Err(HunkSelectError::Binary { path });
666            }
667        }
668        let current_ids = current_ids_for_stale(side, pathspecs, &files, &peer_hunks);
669        return Err(HunkSelectError::StaleId {
670            id,
671            path: stale_path_hint(pathspecs, &files),
672            current_ids,
673        });
674    }
675
676    Ok(ResolvedSelection {
677        patches,
678        staged_hunks: staged_by_path,
679        staged_lines: BTreeMap::new(),
680    })
681}
682
683fn stale_path_hint(pathspecs: &[String], files: &[FileDiff]) -> Option<String> {
684    if pathspecs.len() == 1 {
685        return Some(pathspecs[0].clone());
686    }
687    if files.len() == 1 {
688        return Some(files[0].path.clone());
689    }
690    None
691}
692
693fn current_ids_for_stale(
694    side: DiffSide,
695    pathspecs: &[String],
696    files: &[FileDiff],
697    peer_hunks: &HashMap<String, Vec<StructuredHunk>>,
698) -> Vec<HunkId> {
699    let path = stale_path_hint(pathspecs, files);
700    let Some(path) = path else {
701        return Vec::new();
702    };
703    let Some(file) = files.iter().find(|f| f.path == path) else {
704        return Vec::new();
705    };
706    if file.binary {
707        return Vec::new();
708    }
709    let peer = peer_hunks
710        .get(&file.path)
711        .map(|h| h.as_slice())
712        .unwrap_or(&[]);
713    hunk_ids_with_duplicate_peers(side, &file.path, &file.hunks, peer)
714}
715
716fn file_patch_from_hunks(
717    file: &FileDiff,
718    hunks: &[StructuredHunk],
719) -> std::result::Result<FilePatch, HunkSelectError> {
720    if file.binary {
721        return Err(HunkSelectError::Binary {
722            path: file.path.clone(),
723        });
724    }
725    // Index preimage always comes from the existing stage-0 entry (including intent-to-add).
726    let is_new = false;
727    let is_deleted = matches!(file.status, DiffStatus::Deleted);
728    let is_rename = matches!(file.status, DiffStatus::Renamed);
729    let apply_hunks: Vec<Hunk> = hunks
730        .iter()
731        .enumerate()
732        .map(|(i, h)| structured_hunk_to_apply_hunk(h, i + 1))
733        .collect();
734    Ok(FilePatch {
735        diff_old_path: file.old_path.clone().or_else(|| Some(file.path.clone())),
736        diff_new_path: Some(file.path.clone()),
737        old_path: if is_new {
738            None
739        } else {
740            file.old_path.clone().or_else(|| Some(file.path.clone()))
741        },
742        new_path: if is_deleted {
743            None
744        } else {
745            Some(file.path.clone())
746        },
747        saw_old_header: true,
748        saw_new_header: true,
749        old_mode: file.old_mode.clone(),
750        new_mode: file.new_mode.clone(),
751        old_mode_line: None,
752        new_mode_line: None,
753        is_new,
754        is_deleted,
755        is_rename,
756        is_copy: matches!(file.status, DiffStatus::Copied),
757        similarity_index: None,
758        dissimilarity_index: None,
759        old_oid: None,
760        new_oid: None,
761        binary_patch: None,
762        is_binary: false,
763        hunks: apply_hunks,
764        ws_rule: 0,
765        is_toplevel_relative: true,
766    })
767}
768
769fn merge_intervals(ranges: &[(usize, usize)]) -> Vec<(usize, usize)> {
770    if ranges.is_empty() {
771        return Vec::new();
772    }
773    let mut sorted = ranges.to_vec();
774    sorted.sort_unstable_by_key(|(s, _)| *s);
775    let mut out = vec![sorted[0]];
776    for (start, end) in sorted.into_iter().skip(1) {
777        if let Some(last) = out.last_mut() {
778            if start <= last.1 + 1 {
779                last.1 = last.1.max(end);
780                continue;
781            }
782        }
783        out.push((start, end));
784    }
785    out
786}
787
788fn line_in_intervals(line: usize, intervals: &[(usize, usize)]) -> bool {
789    intervals
790        .iter()
791        .any(|(start, end)| line >= *start && line <= *end)
792}
793
794fn deletion_selected(removal_point: usize, intervals: &[(usize, usize)]) -> bool {
795    intervals
796        .iter()
797        .any(|(start, end)| removal_point >= *start && removal_point <= *end + 1)
798}
799
800fn count_new_side_lines(bytes: &[u8]) -> usize {
801    if bytes.is_empty() {
802        return 0;
803    }
804    let mut count = bytes.iter().filter(|&&b| b == b'\n').count();
805    if bytes.last() != Some(&b'\n') {
806        count += 1;
807    }
808    count
809}
810
811fn new_side_line_count(
812    repo: &Repository,
813    file: &FileDiff,
814) -> std::result::Result<usize, HunkSelectError> {
815    if matches!(file.status, DiffStatus::Deleted) {
816        return Ok(0);
817    }
818    let wt = repo
819        .work_tree()
820        .ok_or_else(|| HunkSelectError::Repository("line ranges require a work tree".into()))?;
821    let mode_str = file.new_mode.as_deref().unwrap_or("100644");
822    let mode = u32::from_str_radix(mode_str, 8).map_err(|_| {
823        HunkSelectError::Repository(format!("invalid new mode {mode_str} for {}", file.path))
824    })?;
825    let config = repo.config().map_err(HunkSelectError::from)?;
826    let index = repo.index_handle().load().map_err(HunkSelectError::from)?;
827    let rules = WorktreeRules::from_repository(repo, &index).map_err(HunkSelectError::from)?;
828    let index_entry = index.get(file.path.as_bytes(), 0);
829    let bytes = read_worktree_side_bytes_for_diff(
830        repo.odb(),
831        wt,
832        &file.path,
833        mode,
834        config.as_ref(),
835        index_entry,
836        Some(&rules),
837    )
838    .map_err(HunkSelectError::from)?;
839    Ok(count_new_side_lines(&bytes))
840}
841
842fn range_label(start: usize, end: usize) -> String {
843    if start == end {
844        format!("{start}")
845    } else {
846        format!("{start}-{end}")
847    }
848}
849
850fn validate_line_ranges(
851    path: &str,
852    intervals: &[(usize, usize)],
853    max_line: usize,
854) -> std::result::Result<(), HunkSelectError> {
855    for &(start, end) in intervals {
856        if start == 0 || end == 0 || start > end || end > max_line {
857            return Err(HunkSelectError::RangeOutOfBounds {
858                path: path.to_string(),
859                start,
860                end,
861                max_line,
862            });
863        }
864    }
865    Ok(())
866}
867
868fn filter_hunk_by_intervals(
869    hunk: &StructuredHunk,
870    intervals: &[(usize, usize)],
871) -> Option<StructuredHunk> {
872    let mut new_cur = hunk.new_start;
873    let mut old_cur = hunk.old_start;
874    let mut out_lines = Vec::new();
875    let mut any_change = false;
876
877    for line in &hunk.lines {
878        match line.kind {
879            StructuredLineKind::Context => {
880                let new_n = line.new.unwrap_or(new_cur);
881                let old_n = line.old.unwrap_or(old_cur);
882                out_lines.push(line.clone());
883                new_cur = new_n.saturating_add(1);
884                old_cur = old_n.saturating_add(1);
885            }
886            StructuredLineKind::Del => {
887                let removal = new_cur;
888                let old_n = line.old.unwrap_or(old_cur);
889                if deletion_selected(removal, intervals) {
890                    out_lines.push(line.clone());
891                    any_change = true;
892                } else {
893                    let text: String = line.segments.iter().map(|s| s.text.as_str()).collect();
894                    out_lines.push(StructuredLine {
895                        kind: StructuredLineKind::Context,
896                        old: Some(old_n),
897                        new: Some(removal),
898                        segments: vec![StructuredSegment {
899                            text,
900                            emphasis: false,
901                        }],
902                        no_newline_at_eof: line.no_newline_at_eof,
903                    });
904                }
905                old_cur = old_n.saturating_add(1);
906            }
907            StructuredLineKind::Add => {
908                let new_n = line.new.unwrap_or(new_cur);
909                if line_in_intervals(new_n, intervals) {
910                    out_lines.push(line.clone());
911                    any_change = true;
912                }
913                new_cur = new_n.saturating_add(1);
914            }
915        }
916    }
917
918    if !any_change {
919        return None;
920    }
921
922    let mut filtered = StructuredHunk {
923        old_start: hunk.old_start,
924        new_start: hunk.new_start,
925        old_lines: hunk.old_lines,
926        new_lines: hunk.new_lines,
927        context: hunk.context.clone(),
928        lines: out_lines,
929    };
930    recount_structured_hunk(&mut filtered);
931    Some(filtered)
932}
933
934fn recount_structured_hunk(hunk: &mut StructuredHunk) {
935    let mut old_start = None;
936    let mut new_start = None;
937    let mut old_count = 0usize;
938    let mut new_count = 0usize;
939    let mut old_line = 0usize;
940    let mut new_line = 0usize;
941
942    for line in &mut hunk.lines {
943        match line.kind {
944            StructuredLineKind::Context => {
945                if old_start.is_none() {
946                    old_start = line.old.or(Some(old_line));
947                    new_start = line.new.or(Some(new_line));
948                }
949                old_count += 1;
950                new_count += 1;
951                if let Some(o) = line.old {
952                    old_line = o.saturating_add(1);
953                } else {
954                    old_line = old_line.saturating_add(1);
955                }
956                if let Some(n) = line.new {
957                    new_line = n.saturating_add(1);
958                } else {
959                    new_line = new_line.saturating_add(1);
960                }
961            }
962            StructuredLineKind::Del => {
963                if old_start.is_none() {
964                    old_start = line.old.or(Some(old_line));
965                    new_start = Some(new_line);
966                }
967                old_count += 1;
968                if let Some(o) = line.old {
969                    old_line = o.saturating_add(1);
970                } else {
971                    old_line = old_line.saturating_add(1);
972                }
973            }
974            StructuredLineKind::Add => {
975                if old_start.is_none() {
976                    old_start = Some(old_line);
977                    new_start = line.new.or(Some(new_line));
978                }
979                new_count += 1;
980                if let Some(n) = line.new {
981                    new_line = n.saturating_add(1);
982                } else {
983                    new_line = new_line.saturating_add(1);
984                }
985            }
986        }
987    }
988
989    hunk.old_start = old_start.unwrap_or(1);
990    hunk.new_start = new_start.unwrap_or(1);
991    hunk.old_lines = old_count;
992    hunk.new_lines = new_count;
993}
994
995fn resolve_line_ranges_unstaged(
996    repo: &Repository,
997    pathspecs: &[String],
998    opts: DiffOptions,
999    selection: &HunkSelection,
1000    id_selected: &HashSet<HunkId>,
1001) -> std::result::Result<ResolvedSelection, HunkSelectError> {
1002    let bundle = hunk_diff_bundle(repo, pathspecs, opts)?;
1003    let mut by_path: BTreeMap<String, Vec<(usize, usize)>> = BTreeMap::new();
1004    let mut labels: BTreeMap<String, Vec<String>> = BTreeMap::new();
1005    for range in &selection.ranges {
1006        by_path
1007            .entry(range.path.clone())
1008            .or_default()
1009            .push((range.start, range.end));
1010        labels
1011            .entry(range.path.clone())
1012            .or_default()
1013            .push(range_label(range.start, range.end));
1014    }
1015
1016    let mut patches = Vec::new();
1017    let mut staged_lines = BTreeMap::new();
1018
1019    for (path, raw_intervals) in by_path {
1020        let intervals = merge_intervals(&raw_intervals);
1021        let file = bundle
1022            .unstaged
1023            .iter()
1024            .find(|f| f.path == path)
1025            .ok_or_else(|| HunkSelectError::RangeSelectsNothing { path: path.clone() })?;
1026        if file.binary {
1027            return Err(HunkSelectError::Binary { path: path.clone() });
1028        }
1029        let max_line = new_side_line_count(repo, file)?;
1030        validate_line_ranges(&path, &intervals, max_line)?;
1031        let peer = bundle
1032            .peer_hunks
1033            .get(&path)
1034            .map(|h| h.as_slice())
1035            .unwrap_or(&[]);
1036        let ids = hunk_ids_with_duplicate_peers(DiffSide::Unstaged, &path, &file.hunks, peer);
1037        let mut partial = Vec::new();
1038        let mut selected_any = false;
1039        for (hunk, id) in file.hunks.iter().zip(ids.iter()) {
1040            if id_selected.contains(id) {
1041                continue;
1042            }
1043            if let Some(filtered) = filter_hunk_by_intervals(hunk, &intervals) {
1044                selected_any = true;
1045                partial.push(filtered);
1046            }
1047        }
1048        let id_covered_any = file
1049            .hunks
1050            .iter()
1051            .zip(ids.iter())
1052            .any(|(_, id)| id_selected.contains(id));
1053        if !selected_any && !id_covered_any {
1054            return Err(HunkSelectError::RangeSelectsNothing { path: path.clone() });
1055        }
1056        if selected_any {
1057            patches.push(file_patch_from_hunks(file, &partial)?);
1058        }
1059        if let Some(labs) = labels.remove(&path) {
1060            staged_lines.insert(path, labs);
1061        }
1062    }
1063
1064    Ok(ResolvedSelection {
1065        patches,
1066        staged_hunks: BTreeMap::new(),
1067        staged_lines,
1068    })
1069}
1070
1071fn structured_hunk_to_apply_hunk(hunk: &StructuredHunk, first_body_line: usize) -> Hunk {
1072    let mut lines = Vec::new();
1073    for line in &hunk.lines {
1074        let text: String = line.segments.iter().map(|s| s.text.as_str()).collect();
1075        match line.kind {
1076            StructuredLineKind::Context => lines.push(HunkLine::Context(text)),
1077            StructuredLineKind::Add => lines.push(HunkLine::Add(text)),
1078            StructuredLineKind::Del => lines.push(HunkLine::Remove(text)),
1079        }
1080        if line.no_newline_at_eof {
1081            lines.push(HunkLine::NoNewline);
1082        }
1083    }
1084    Hunk {
1085        old_start: hunk.old_start,
1086        old_count: hunk.old_lines,
1087        new_start: hunk.new_start,
1088        new_count: hunk.new_lines,
1089        first_body_line,
1090        lines,
1091    }
1092}
1093
1094/// Outcome of [`restore_hunks`].
1095#[derive(Debug, Clone, PartialEq, Eq, Default)]
1096pub struct RestoreHunksOutcome {
1097    /// Whether this was a dry run.
1098    pub dry_run: bool,
1099    /// Paths whose working tree or index was updated.
1100    pub restored: Vec<String>,
1101    /// Hunk ids restored per path (repository-relative).
1102    pub restored_hunks: BTreeMap<String, Vec<HunkId>>,
1103    /// Line ranges restored per path (`start` or `start-end` strings).
1104    pub restored_lines: BTreeMap<String, Vec<String>>,
1105    /// Paths removed (reserved; empty for partial hunk restore).
1106    pub removed: Vec<String>,
1107}
1108
1109/// Discard or unstage the hunks in `selection`.
1110///
1111/// With `restore_worktree` (default when `restore_staged` is false), reverse-applies unstaged
1112/// hunks onto the worktree using clean diff bytes and checkout smudge rules. With
1113/// `restore_staged`, reverse-applies staged hunks onto the index. Validates every patch before
1114/// writing anything.
1115///
1116/// # Errors
1117///
1118/// [`HunkSelectError`] for resolution failures; [`ApplyError`] for apply failures.
1119pub fn restore_hunks(
1120    repo: &Repository,
1121    selection: &HunkSelection,
1122    pathspecs: &[String],
1123    restore_staged: bool,
1124    restore_worktree: bool,
1125    dry_run: bool,
1126) -> std::result::Result<RestoreHunksOutcome, HunkSelectError> {
1127    let diff_opts = diff_options_for_hunk_select(repo)?;
1128    let restore_worktree = restore_worktree || !restore_staged;
1129    let mut outcome = RestoreHunksOutcome {
1130        dry_run,
1131        ..RestoreHunksOutcome::default()
1132    };
1133    if restore_staged {
1134        let side = DiffSide::Staged;
1135        let resolved =
1136            resolve_selection_with_staged(repo, side, selection, pathspecs, diff_opts.clone())?;
1137        let apply = apply_patch_to_index_and_write(
1138            repo,
1139            &resolved.patches,
1140            &IndexApplyOptions {
1141                dry_run,
1142                reverse: true,
1143                head_tree_oid: head_tree_oid(repo)?,
1144            },
1145        )?;
1146        outcome
1147            .restored
1148            .extend(apply.added.into_iter().chain(apply.modified));
1149        outcome.restored.sort();
1150        outcome.restored.dedup();
1151        outcome.restored_hunks = resolved.staged_hunks;
1152        outcome.restored_lines = resolved.staged_lines;
1153    }
1154    if restore_worktree {
1155        let resolved = resolve_selection_with_staged(
1156            repo,
1157            DiffSide::Unstaged,
1158            selection,
1159            pathspecs,
1160            diff_opts,
1161        )?;
1162        let planned = plan_worktree_reverse(repo, &resolved.patches)?;
1163        if !dry_run {
1164            commit_worktree_reverse(repo, &planned, &mut outcome)?;
1165        } else {
1166            for plan in &planned {
1167                push_restored_path(&mut outcome.restored, &plan.path);
1168            }
1169        }
1170        merge_restored_maps(&mut outcome.restored_hunks, resolved.staged_hunks);
1171        merge_restored_line_maps(&mut outcome.restored_lines, resolved.staged_lines);
1172    }
1173    Ok(outcome)
1174}
1175
1176fn merge_restored_maps(
1177    into: &mut BTreeMap<String, Vec<HunkId>>,
1178    other: BTreeMap<String, Vec<HunkId>>,
1179) {
1180    for (path, ids) in other {
1181        into.entry(path).or_default().extend(ids);
1182    }
1183}
1184
1185fn merge_restored_line_maps(
1186    into: &mut BTreeMap<String, Vec<String>>,
1187    other: BTreeMap<String, Vec<String>>,
1188) {
1189    for (path, ranges) in other {
1190        into.entry(path).or_default().extend(ranges);
1191    }
1192}
1193
1194fn push_restored_path(restored: &mut Vec<String>, path: &str) {
1195    if !restored.iter().any(|p| p == path) {
1196        restored.push(path.to_string());
1197    }
1198}
1199
1200struct WorktreeReversePlan {
1201    path: String,
1202    clean_bytes: Vec<u8>,
1203    mode: u32,
1204}
1205
1206fn plan_worktree_reverse(
1207    repo: &Repository,
1208    patches: &[FilePatch],
1209) -> std::result::Result<Vec<WorktreeReversePlan>, HunkSelectError> {
1210    let work_tree = repo
1211        .work_tree()
1212        .ok_or_else(|| HunkSelectError::Repository("restore hunks requires a work tree".into()))?;
1213    let index = repo.index_handle().load().map_err(HunkSelectError::from)?;
1214    let rules = WorktreeRules::from_repository(repo, &index).map_err(HunkSelectError::from)?;
1215    let config = repo.config().map_err(HunkSelectError::from)?;
1216    let mut out = Vec::with_capacity(patches.len());
1217    for patch in patches {
1218        let path = patch
1219            .effective_path()
1220            .ok_or_else(|| HunkSelectError::Repository("patch has no path".into()))?
1221            .to_string();
1222        if patch.involves_gitlink() {
1223            return Err(HunkSelectError::Repository(format!(
1224                "submodule/gitlink restore is not supported for '{path}'"
1225            )));
1226        }
1227        let entry = index.get(path.as_bytes(), 0);
1228        let mode = entry.map(|e| e.mode).unwrap_or(MODE_REGULAR);
1229        if mode != MODE_REGULAR && mode != MODE_EXECUTABLE {
1230            return Err(HunkSelectError::Binary { path: path.clone() });
1231        }
1232        let abs = crate::repo_relpath::worktree_abs(work_tree, &path);
1233        let clean = if patch.is_deleted || !abs.exists() {
1234            Vec::new()
1235        } else {
1236            read_worktree_side_bytes_for_diff(
1237                repo.odb(),
1238                work_tree,
1239                &path,
1240                mode,
1241                config.as_ref(),
1242                entry,
1243                Some(&rules),
1244            )
1245            .map_err(HunkSelectError::from)?
1246        };
1247        let new_clean = apply_hunks_to_blob(&clean, &patch.hunks, &ApplyOptions { reverse: true })
1248            .map_err(HunkSelectError::Apply)?;
1249        out.push(WorktreeReversePlan {
1250            path,
1251            clean_bytes: new_clean,
1252            mode,
1253        });
1254    }
1255    Ok(out)
1256}
1257
1258fn commit_worktree_reverse(
1259    repo: &Repository,
1260    planned: &[WorktreeReversePlan],
1261    outcome: &mut RestoreHunksOutcome,
1262) -> std::result::Result<(), HunkSelectError> {
1263    let work_tree = repo
1264        .work_tree()
1265        .ok_or_else(|| HunkSelectError::Repository("restore hunks requires a work tree".into()))?;
1266    let handle = repo.index_handle();
1267    let mut lock = handle.begin_update().map_err(HunkSelectError::from)?;
1268    let mut index = handle.load().map_err(HunkSelectError::from)?;
1269    let rules = WorktreeRules::from_repository(repo, &index).map_err(HunkSelectError::from)?;
1270    let mut dir_cache = LeadingDirCache::new();
1271    for plan in planned {
1272        // The restored content only lives in the worktree, so it is hashed (for `ident` and
1273        // filter metadata) but not stored.
1274        let oid = repo
1275            .odb()
1276            .hash_algo()
1277            .hash_object(ObjectKind::Blob, &plan.clean_bytes)
1278            .map_err(HunkSelectError::from)?;
1279        let worktree_bytes = smudge_bytes_for_worktree_path(
1280            repo,
1281            &rules,
1282            &plan.path,
1283            plan.mode,
1284            &plan.clean_bytes,
1285            &oid,
1286        )
1287        .map_err(HunkSelectError::from)?;
1288        write_to_worktree_cached(
1289            work_tree,
1290            &plan.path,
1291            &worktree_bytes,
1292            plan.mode,
1293            &mut dir_cache,
1294        )
1295        .map_err(|e| HunkSelectError::Repository(e.to_string()))?;
1296        push_restored_path(&mut outcome.restored, &plan.path);
1297    }
1298    refresh_partial_restore_index_stats(repo, work_tree, &mut index, &outcome.restored)?;
1299    handle
1300        .commit_update(&mut lock, &mut index, false)
1301        .map_err(|e| HunkSelectError::Repository(e.to_string()))?;
1302    Ok(())
1303}
1304
1305fn head_tree_oid(repo: &Repository) -> std::result::Result<Option<ObjectId>, HunkSelectError> {
1306    match resolve_head_for_repository(repo)
1307        .map_err(|e| HunkSelectError::Repository(e.to_string()))?
1308    {
1309        HeadState::Branch { oid: Some(oid), .. } | HeadState::Detached { oid } => {
1310            let obj = repo
1311                .odb()
1312                .read(&oid)
1313                .map_err(|e| HunkSelectError::Repository(e.to_string()))?;
1314            let commit =
1315                parse_commit(&obj.data).map_err(|e| HunkSelectError::Repository(e.to_string()))?;
1316            Ok(Some(commit.tree))
1317        }
1318        HeadState::Branch { .. } => Ok(None),
1319        HeadState::Invalid => Err(HunkSelectError::Repository(
1320            "HEAD is in an unknown state".into(),
1321        )),
1322    }
1323}
1324
1325fn refresh_partial_restore_index_stats(
1326    repo: &Repository,
1327    work_tree: &StdPath,
1328    index: &mut crate::index::Index,
1329    restored: &[String],
1330) -> std::result::Result<(), HunkSelectError> {
1331    if restored.is_empty() {
1332        return Ok(());
1333    }
1334    let touched: BTreeSet<&str> = restored.iter().map(String::as_str).collect();
1335    let index_mtime = index_file_mtime(&repo.index_path());
1336    let rules = WorktreeRules::from_repository(repo, index).ok();
1337    let rules_arc = rules.map(|r| std::sync::Arc::new(std::sync::Mutex::new(r)));
1338    let mut subset = index.clone();
1339    subset
1340        .entries
1341        .retain(|e| e.stage() == 0 && touched.contains(String::from_utf8_lossy(&e.path).as_ref()));
1342    if subset.entries.is_empty() {
1343        return Ok(());
1344    }
1345    let _ = refresh_index_stat_content_verified_with_rules(
1346        repo.odb(),
1347        repo.git_dir(),
1348        &mut subset,
1349        work_tree,
1350        index_mtime,
1351        repo.config().ok().as_deref(),
1352        None,
1353        rules_arc.as_ref(),
1354    )
1355    .map_err(|e| HunkSelectError::Repository(e.to_string()))?;
1356    for entry in subset.entries {
1357        index.stage_file(entry);
1358    }
1359    Ok(())
1360}
1361
1362/// Options for [`reverse_apply_patches_to_worktree`].
1363#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1364pub struct WorktreeReverseApplyOptions {
1365    /// When true, validate patches without writing the worktree or new blobs.
1366    pub dry_run: bool,
1367}
1368
1369/// Reverse-apply unstaged [`FilePatch`] rows onto the worktree (undo selected local edits).
1370///
1371/// Used after partial `stash push` so only the stashed hunks disappear from disk while the
1372/// index and other unstaged edits stay put. Reports
1373/// [`crate::progress::ProgressPhase::CheckingOutFiles`], one unit per patch, and polls `cancel`
1374/// before each file; files already rewritten stay rewritten.
1375///
1376/// # Errors
1377///
1378/// [`ApplyError`] when a patch cannot be reversed on the worktree, and
1379/// [`ApplyError::Cancelled`] when `cancel` or [`ProgressSink::should_cancel`] fired.
1380pub fn reverse_apply_patches_to_worktree(
1381    repo: &Repository,
1382    patches: &[FilePatch],
1383    opts: &WorktreeReverseApplyOptions,
1384    progress: &mut dyn ProgressSink,
1385    cancel: &dyn Cancel,
1386) -> std::result::Result<(), ApplyError> {
1387    let work_tree = repo.work_tree().ok_or_else(|| ApplyError::Failed {
1388        path: String::new(),
1389        message: "worktree reverse apply requires a working tree".into(),
1390    })?;
1391    let index = repo.load_index().map_err(|e| ApplyError::Failed {
1392        path: String::new(),
1393        message: e.to_string(),
1394    })?;
1395    let config = repo.config().map_err(|e| ApplyError::Failed {
1396        path: String::new(),
1397        message: e.to_string(),
1398    })?;
1399    let rules = WorktreeRules::from_repository(repo, &index).map_err(|e| ApplyError::Failed {
1400        path: String::new(),
1401        message: e.to_string(),
1402    })?;
1403    progress.start(ProgressPhase::CheckingOutFiles, Some(patches.len() as u64));
1404    for patch in patches {
1405        poll_cancel(progress, cancel).map_err(|_| ApplyError::Cancelled)?;
1406        progress.inc(1);
1407        if patch.involves_gitlink() {
1408            let path = patch.effective_path().unwrap_or("<unknown>").to_string();
1409            return Err(ApplyError::Failed {
1410                path,
1411                message: "submodule/gitlink patches are not supported".into(),
1412            });
1413        }
1414        let path = patch
1415            .target_path()
1416            .map(str::to_string)
1417            .or_else(|| patch.effective_path().map(str::to_string))
1418            .ok_or_else(|| ApplyError::Failed {
1419                path: String::new(),
1420                message: "patch has no target path".into(),
1421            })?;
1422
1423        if patch.is_deleted {
1424            let Some(entry) = index.get(path.as_bytes(), 0) else {
1425                return Err(ApplyError::NotInIndex { path: path.clone() });
1426            };
1427            if entry.mode == MODE_GITLINK {
1428                continue;
1429            }
1430            if !opts.dry_run {
1431                write_smudged_blob_to_worktree(
1432                    repo, work_tree, &rules, &path, &entry.oid, entry.mode,
1433                )
1434                .map_err(|e| ApplyError::Failed {
1435                    path: path.clone(),
1436                    message: e.to_string(),
1437                })?;
1438            }
1439            continue;
1440        }
1441
1442        if patch.is_new {
1443            if !opts.dry_run {
1444                let abs = crate::repo_relpath::worktree_abs(work_tree, &path);
1445                if let Ok(meta) = fs::symlink_metadata(&abs) {
1446                    if meta.is_dir() {
1447                        let _ = fs::remove_dir_all(&abs);
1448                    } else {
1449                        fs::remove_file(&abs).map_err(|e| ApplyError::Failed {
1450                            path: path.clone(),
1451                            message: e.to_string(),
1452                        })?;
1453                    }
1454                    remove_empty_parent_dirs(work_tree, &abs);
1455                }
1456            }
1457            continue;
1458        }
1459
1460        let mode = index
1461            .get(path.as_bytes(), 0)
1462            .map(|e| e.mode)
1463            .unwrap_or(MODE_REGULAR);
1464        if mode == MODE_GITLINK {
1465            continue;
1466        }
1467        let index_entry = index.get(path.as_bytes(), 0);
1468        let base = read_worktree_side_bytes_for_diff(
1469            repo.odb(),
1470            work_tree,
1471            &path,
1472            mode,
1473            config.as_ref(),
1474            index_entry,
1475            Some(&rules),
1476        )
1477        .map_err(|e| ApplyError::Failed {
1478            path: path.clone(),
1479            message: e.to_string(),
1480        })?;
1481        let new_clean = apply_hunks_to_blob(&base, &patch.hunks, &ApplyOptions { reverse: true })?;
1482        if opts.dry_run {
1483            continue;
1484        }
1485        let oid = repo
1486            .odb()
1487            .write(ObjectKind::Blob, &new_clean)
1488            .map_err(|e| ApplyError::Failed {
1489                path: path.clone(),
1490                message: e.to_string(),
1491            })?;
1492        write_smudged_blob_to_worktree(repo, work_tree, &rules, &path, &oid, mode).map_err(
1493            |e| ApplyError::Failed {
1494                path: path.clone(),
1495                message: e.to_string(),
1496            },
1497        )?;
1498    }
1499    progress.finish();
1500    Ok(())
1501}
1502
1503/// Diff options for hunk id resolution (same hunk boundaries as `grit diff --json`).
1504///
1505/// # Errors
1506///
1507/// Returns repository config errors.
1508pub fn diff_options_for_hunk_select(repo: &Repository) -> std::result::Result<DiffOptions, Error> {
1509    let config = repo.config()?;
1510    Ok(DiffOptions::from_config(config.as_ref()))
1511}
1512
1513/// Stage the hunks in `selection` on `side` (typically unstaged for `grit add --hunks`).
1514///
1515/// `diff_options` must match the options used when the hunk ids were produced (`grit diff --json`,
1516/// including `-U` / config `diff.context`).
1517///
1518/// # Errors
1519///
1520/// [`HunkSelectError`] for resolution failures; [`ApplyError`] for index apply failures.
1521pub fn stage_hunks(
1522    repo: &Repository,
1523    side: DiffSide,
1524    selection: &HunkSelection,
1525    pathspecs: &[String],
1526    diff_options: DiffOptions,
1527    dry_run: bool,
1528) -> std::result::Result<StageHunksOutcome, HunkSelectError> {
1529    let resolved = resolve_selection_with_staged(repo, side, selection, pathspecs, diff_options)?;
1530    let apply = apply_patch_to_index_and_write(
1531        repo,
1532        &resolved.patches,
1533        &IndexApplyOptions {
1534            dry_run,
1535            ..IndexApplyOptions::default()
1536        },
1537    )?;
1538    Ok(StageHunksOutcome::from_apply(
1539        dry_run,
1540        resolved.staged_hunks,
1541        resolved.staged_lines,
1542        apply,
1543    ))
1544}
1545
1546fn map_repo_err(err: Error) -> HunkSelectError {
1547    HunkSelectError::Repository(err.to_string())
1548}
1549
1550fn map_parse_err(_err: Error) -> HunkSelectError {
1551    HunkSelectError::PatchBuild
1552}
1553
1554/// Unstaged hunk ids per path (same assignment rules as `grit diff --json` on `--unstaged`).
1555///
1556/// # Errors
1557///
1558/// Propagates repository and diff errors.
1559pub fn list_unstaged_hunk_ids(
1560    repo: &Repository,
1561    pathspecs: &[String],
1562    diff_options: DiffOptions,
1563) -> std::result::Result<BTreeMap<String, Vec<HunkId>>, Error> {
1564    let (changes, peer_hunks) =
1565        unstaged_raw_changes_with_peer_hunks(repo, pathspecs, diff_options.clone())?;
1566    let mut out = BTreeMap::new();
1567    for change in &changes {
1568        if change.binary {
1569            continue;
1570        }
1571        let peer = peer_hunks
1572            .get(&change.path)
1573            .map(|h| h.as_slice())
1574            .unwrap_or(&[]);
1575        let old_text = side_text(&change.old_bytes);
1576        let new_text = side_text(&change.new_bytes);
1577        let body =
1578            unified_hunk_body_for_diff_options(old_text.as_ref(), new_text.as_ref(), &diff_options);
1579        let slices = imara_unified_hunk_slices(&body);
1580        let ids = crate::diff::hunk_id::hunk_ids_for_unified_slices(
1581            DiffSide::Unstaged,
1582            &change.path,
1583            &slices,
1584            line_count(old_text.as_ref()),
1585            line_count(new_text.as_ref()),
1586            old_text.as_ref().ends_with('\n'),
1587            new_text.as_ref().ends_with('\n'),
1588            peer,
1589        );
1590        out.insert(change.path.clone(), ids);
1591    }
1592    Ok(out)
1593}
1594
1595#[cfg(test)]
1596mod tests {
1597    use super::*;
1598    use crate::diff::hunk_id::hunk_ids;
1599    use crate::index::write_tree::write_tree_from_index;
1600    use crate::porcelain::add::{stage, StageOptions};
1601    use crate::progress::NullProgress;
1602    use crate::repo::{InitOptions, Repository};
1603    use std::process::Command;
1604
1605    fn git(dir: &std::path::Path, args: &[&str]) {
1606        let out = Command::new("git")
1607            .current_dir(dir)
1608            .args(args)
1609            .env("GIT_CONFIG_GLOBAL", "/dev/null")
1610            .env("GIT_CONFIG_SYSTEM", "/dev/null")
1611            .output()
1612            .expect("git");
1613        assert!(
1614            out.status.success(),
1615            "git {args:?}: {}",
1616            String::from_utf8_lossy(&out.stderr)
1617        );
1618    }
1619
1620    fn hunk_diff_opts() -> DiffOptions {
1621        DiffOptions::default()
1622    }
1623
1624    fn init_repo() -> (tempfile::TempDir, Repository) {
1625        let dir = tempfile::tempdir().expect("tempdir");
1626        git(dir.path(), &["init", "-q", "-b", "main"]);
1627        let repo = Repository::init(dir.path(), &InitOptions::default()).expect("init");
1628        (dir, repo)
1629    }
1630
1631    fn spaced_two_hunk_body() -> (String, String) {
1632        three_hunk_body()
1633    }
1634
1635    fn three_hunk_body() -> (String, String) {
1636        let mut base = String::new();
1637        for i in 0..30 {
1638            base.push_str(&format!("line{i}\n"));
1639        }
1640        let mut edited = base.clone();
1641        edited = edited.replacen("line5\n", "LINE5\n", 1);
1642        edited = edited.replacen("line13\n", "LINE13\n", 1);
1643        edited = edited.replacen("line21\n", "LINE21\n", 1);
1644        (base, edited)
1645    }
1646
1647    #[cfg_attr(
1648        not(feature = "full-tests"),
1649        ignore = "starts system git or is heavy; run with --features full-tests"
1650    )]
1651    #[test]
1652    fn stage_hunks_subset_matches_git_apply_cached() {
1653        let (dir, repo) = init_repo();
1654        let root = dir.path();
1655        let (base, edited) = spaced_two_hunk_body();
1656        std::fs::write(root.join("f.txt"), base).unwrap();
1657        git(root, &["add", "f.txt"]);
1658        git(root, &["commit", "-qm", "init"]);
1659
1660        std::fs::write(root.join("f.txt"), edited).unwrap();
1661        let (files, peers) =
1662            unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
1663        assert_eq!(files.len(), 1);
1664        let ids = hunk_ids_with_duplicate_peers(
1665            DiffSide::Unstaged,
1666            "f.txt",
1667            &files[0].hunks,
1668            peers.get("f.txt").map(|v| v.as_slice()).unwrap_or(&[]),
1669        );
1670        assert!(ids.len() >= 2);
1671        let selection = HunkSelection {
1672            ids: vec![ids[0].clone()],
1673            ranges: Vec::new(),
1674        };
1675
1676        let patch = resolve_selection(&repo, DiffSide::Unstaged, &selection, &[], hunk_diff_opts())
1677            .unwrap();
1678        let mut patch_text = String::new();
1679        for p in &patch {
1680            patch_text.push_str(&format_patch_for_git(p));
1681        }
1682
1683        stage_hunks(
1684            &repo,
1685            DiffSide::Unstaged,
1686            &selection,
1687            &[],
1688            hunk_diff_opts(),
1689            false,
1690        )
1691        .unwrap();
1692        let grit_tree =
1693            write_tree_from_index(repo.odb(), &repo.index_handle().load().unwrap(), "").unwrap();
1694
1695        git(root, &["read-tree", "HEAD"]);
1696        let patch_path = root.join("partial.diff");
1697        std::fs::write(&patch_path, patch_text).unwrap();
1698        git(root, &["apply", "--cached", patch_path.to_str().unwrap()]);
1699        let git_tree = Command::new("git")
1700            .current_dir(root)
1701            .args(["write-tree"])
1702            .output()
1703            .expect("write-tree");
1704        let git_tree_hex = String::from_utf8_lossy(&git_tree.stdout).trim().to_string();
1705        assert_eq!(grit_tree.to_hex(), git_tree_hex);
1706        git(root, &["fsck"]);
1707    }
1708
1709    fn write_hunk_line(out: &mut String, prefix: char, text: &str) {
1710        out.push(prefix);
1711        if text.ends_with('\n') {
1712            out.push_str(text);
1713        } else {
1714            out.push_str(text);
1715            out.push('\n');
1716        }
1717    }
1718
1719    fn format_patch_for_git(patch: &FilePatch) -> String {
1720        let mut out = String::new();
1721        let path = patch.effective_path().unwrap_or("f");
1722        out.push_str(&format!("diff --git a/{path} b/{path}\n"));
1723        if patch.is_new {
1724            out.push_str("new file mode 100644\n");
1725        }
1726        let old = if patch.is_new {
1727            "/dev/null".to_string()
1728        } else {
1729            format!("a/{}", patch.old_path.as_deref().unwrap_or(path))
1730        };
1731        let new = if patch.is_deleted {
1732            "/dev/null".to_string()
1733        } else {
1734            format!("b/{}", patch.new_path.as_deref().unwrap_or(path))
1735        };
1736        out.push_str(&format!("--- {old}\n"));
1737        out.push_str(&format!("+++ {new}\n"));
1738        for h in &patch.hunks {
1739            out.push_str(&format!(
1740                "@@ -{},{} +{},{} @@\n",
1741                h.old_start, h.old_count, h.new_start, h.new_count
1742            ));
1743            for line in &h.lines {
1744                match line {
1745                    HunkLine::Context(s) => write_hunk_line(&mut out, ' ', s),
1746                    HunkLine::Add(s) => write_hunk_line(&mut out, '+', s),
1747                    HunkLine::Remove(s) => write_hunk_line(&mut out, '-', s),
1748                    HunkLine::NoNewline => out.push_str("\\ No newline at end of file\n"),
1749                }
1750            }
1751        }
1752        out
1753    }
1754
1755    #[cfg_attr(
1756        not(feature = "full-tests"),
1757        ignore = "starts system git or is heavy; run with --features full-tests"
1758    )]
1759    #[test]
1760    fn stage_hunks_sequential_ids_stay_valid() {
1761        let (dir, repo) = init_repo();
1762        let root = dir.path();
1763        let (base, edited) = spaced_two_hunk_body();
1764        std::fs::write(root.join("f.txt"), base).unwrap();
1765        git(root, &["add", "f.txt"]);
1766        git(root, &["commit", "-qm", "init"]);
1767        std::fs::write(root.join("f.txt"), edited).unwrap();
1768
1769        let (files, peers) =
1770            unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
1771        let ids = hunk_ids_with_duplicate_peers(
1772            DiffSide::Unstaged,
1773            "f.txt",
1774            &files[0].hunks,
1775            peers.get("f.txt").map(|v| v.as_slice()).unwrap_or(&[]),
1776        );
1777        assert_eq!(ids.len(), 3, "expected three hunks, got {}", ids.len());
1778        let id0 = ids[0].clone();
1779        let id2 = ids[2].clone();
1780        stage_hunks(
1781            &repo,
1782            DiffSide::Unstaged,
1783            &HunkSelection {
1784                ids: vec![id0],
1785                ranges: Vec::new(),
1786            },
1787            &[],
1788            hunk_diff_opts(),
1789            false,
1790        )
1791        .unwrap();
1792        stage_hunks(
1793            &repo,
1794            DiffSide::Unstaged,
1795            &HunkSelection {
1796                ids: vec![id2],
1797                ranges: Vec::new(),
1798            },
1799            &[],
1800            hunk_diff_opts(),
1801            false,
1802        )
1803        .unwrap();
1804        git(root, &["fsck"]);
1805    }
1806
1807    #[cfg_attr(
1808        not(feature = "full-tests"),
1809        ignore = "starts system git or is heavy; run with --features full-tests"
1810    )]
1811    #[test]
1812    fn stage_hunks_inter_hunk_context_matches_diff_ids() {
1813        let (dir, repo) = init_repo();
1814        let root = dir.path();
1815        git(root, &["config", "diff.interHunkContext", "1"]);
1816        let (base, _edited) = three_hunk_body();
1817        let mut edited_two = base.clone();
1818        edited_two = edited_two.replacen("line5\n", "LINE5\n", 1);
1819        edited_two = edited_two.replacen("line13\n", "LINE13\n", 1);
1820        std::fs::write(root.join("f.txt"), base).unwrap();
1821        git(root, &["add", "f.txt"]);
1822        git(root, &["commit", "-qm", "init"]);
1823        std::fs::write(root.join("f.txt"), edited_two).unwrap();
1824
1825        let opts = DiffOptions::from_config(repo.config().unwrap().as_ref());
1826        let (files, peers) = unstaged_file_diffs_with_peer_hunks(&repo, &[], opts.clone()).unwrap();
1827        let ids = hunk_ids_with_duplicate_peers(
1828            DiffSide::Unstaged,
1829            "f.txt",
1830            &files[0].hunks,
1831            peers.get("f.txt").map(|v| v.as_slice()).unwrap_or(&[]),
1832        );
1833        assert_eq!(
1834            ids.len(),
1835            1,
1836            "interHunkContext=1 should merge edits at lines 5 and 13"
1837        );
1838        stage_hunks(
1839            &repo,
1840            DiffSide::Unstaged,
1841            &HunkSelection {
1842                ids: vec![ids[0].clone()],
1843                ranges: Vec::new(),
1844            },
1845            &[],
1846            opts,
1847            false,
1848        )
1849        .unwrap();
1850        git(root, &["fsck"]);
1851    }
1852
1853    #[cfg_attr(
1854        not(feature = "full-tests"),
1855        ignore = "starts system git or is heavy; run with --features full-tests"
1856    )]
1857    #[test]
1858    fn stage_hunks_text_to_binary_returns_binary_error() {
1859        let (dir, repo) = init_repo();
1860        let root = dir.path();
1861        let (base, edited) = spaced_two_hunk_body();
1862        std::fs::write(root.join("f.txt"), base).unwrap();
1863        git(root, &["add", "f.txt"]);
1864        git(root, &["commit", "-qm", "init"]);
1865        std::fs::write(root.join("f.txt"), edited).unwrap();
1866        let (files, peers) =
1867            unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
1868        let ids = hunk_ids_with_duplicate_peers(
1869            DiffSide::Unstaged,
1870            "f.txt",
1871            &files[0].hunks,
1872            peers.get("f.txt").map(|v| v.as_slice()).unwrap_or(&[]),
1873        );
1874        let id = ids[0].clone();
1875        let binary = b"before\0after".to_vec();
1876        std::fs::write(root.join("f.txt"), &binary).unwrap();
1877        let err = stage_hunks(
1878            &repo,
1879            DiffSide::Unstaged,
1880            &HunkSelection {
1881                ids: vec![id],
1882                ranges: Vec::new(),
1883            },
1884            &["f.txt".into()],
1885            hunk_diff_opts(),
1886            false,
1887        )
1888        .unwrap_err();
1889        assert!(
1890            matches!(err, HunkSelectError::Binary { .. }),
1891            "expected Binary, got {err:?}"
1892        );
1893    }
1894
1895    #[cfg_attr(
1896        not(feature = "full-tests"),
1897        ignore = "starts system git or is heavy; run with --features full-tests"
1898    )]
1899    #[test]
1900    fn stage_hunks_stale_id_rejected_index_untouched() {
1901        let (dir, repo) = init_repo();
1902        let root = dir.path();
1903        std::fs::write(root.join("f.txt"), b"one\n").unwrap();
1904        git(root, &["add", "f.txt"]);
1905        git(root, &["commit", "-qm", "init"]);
1906        std::fs::write(root.join("f.txt"), b"two\n").unwrap();
1907        let before = repo.index_handle().load().unwrap();
1908        let stale = HunkId::from_str("000000000001").unwrap();
1909        let err = stage_hunks(
1910            &repo,
1911            DiffSide::Unstaged,
1912            &HunkSelection {
1913                ids: vec![stale.clone()],
1914                ranges: Vec::new(),
1915            },
1916            &[],
1917            hunk_diff_opts(),
1918            false,
1919        )
1920        .unwrap_err();
1921        assert!(matches!(err, HunkSelectError::StaleId { .. }));
1922        let after = repo.index_handle().load().unwrap();
1923        assert_eq!(before.entries.len(), after.entries.len());
1924    }
1925
1926    #[cfg_attr(
1927        not(feature = "full-tests"),
1928        ignore = "starts system git or is heavy; run with --features full-tests"
1929    )]
1930    #[test]
1931    fn stage_hunks_staged_side_id_rejected() {
1932        let (dir, repo) = init_repo();
1933        let root = dir.path();
1934        std::fs::write(root.join("f.txt"), b"one\n").unwrap();
1935        git(root, &["add", "f.txt"]);
1936        git(root, &["commit", "-qm", "init"]);
1937        std::fs::write(root.join("f.txt"), b"two\n").unwrap();
1938        stage(
1939            &repo,
1940            &StageOptions::default(),
1941            &mut NullProgress,
1942            &crate::progress::NeverCancel,
1943        )
1944        .unwrap();
1945        let staged_files =
1946            collect_files_for_target(&repo, &DiffTarget::IndexVsHead, &[], DiffOptions::default())
1947                .unwrap();
1948        let staged_id = hunk_ids(DiffSide::Staged, "f.txt", &staged_files[0].hunks)[0].clone();
1949        std::fs::write(root.join("f.txt"), b"three\n").unwrap();
1950        let err = stage_hunks(
1951            &repo,
1952            DiffSide::Unstaged,
1953            &HunkSelection {
1954                ids: vec![staged_id],
1955                ranges: Vec::new(),
1956            },
1957            &[],
1958            hunk_diff_opts(),
1959            false,
1960        )
1961        .unwrap_err();
1962        assert!(matches!(err, HunkSelectError::WrongSide { .. }));
1963    }
1964
1965    #[cfg_attr(
1966        not(feature = "full-tests"),
1967        ignore = "starts system git or is heavy; run with --features full-tests"
1968    )]
1969    #[test]
1970    fn stage_hunks_new_file_whole() {
1971        let (dir, repo) = init_repo();
1972        let root = dir.path();
1973        std::fs::write(root.join("base.txt"), b"keep\n").unwrap();
1974        git(root, &["add", "base.txt"]);
1975        git(root, &["commit", "-qm", "init"]);
1976        std::fs::write(root.join("new.txt"), b"hello\n").unwrap();
1977        stage(
1978            &repo,
1979            &StageOptions {
1980                pathspecs: vec!["new.txt".into()],
1981                intent_to_add: true,
1982                ..StageOptions::default()
1983            },
1984            &mut NullProgress,
1985            &crate::progress::NeverCancel,
1986        )
1987        .unwrap();
1988        let (files, peers) =
1989            unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
1990        let new_file = files
1991            .iter()
1992            .find(|f| f.path == "new.txt")
1993            .expect("new file in unstaged diff");
1994        assert!(
1995            !new_file.hunks.is_empty(),
1996            "new file should have structured hunks"
1997        );
1998        let ids = hunk_ids_with_duplicate_peers(
1999            DiffSide::Unstaged,
2000            "new.txt",
2001            &new_file.hunks,
2002            peers.get("new.txt").map(|v| v.as_slice()).unwrap_or(&[]),
2003        );
2004        stage_hunks(
2005            &repo,
2006            DiffSide::Unstaged,
2007            &HunkSelection {
2008                ids: ids.clone(),
2009                ranges: Vec::new(),
2010            },
2011            &[],
2012            hunk_diff_opts(),
2013            false,
2014        )
2015        .unwrap();
2016        assert!(repo
2017            .index_handle()
2018            .load()
2019            .unwrap()
2020            .get(b"new.txt", 0)
2021            .is_some());
2022        git(root, &["fsck"]);
2023    }
2024
2025    fn stage_lines_and_compare_git(
2026        repo: &Repository,
2027        root: &std::path::Path,
2028        path: &str,
2029        ranges: Vec<LineRange>,
2030    ) {
2031        let selection = HunkSelection {
2032            ids: Vec::new(),
2033            ranges,
2034        };
2035        let patch = resolve_selection(
2036            &repo,
2037            DiffSide::Unstaged,
2038            &selection,
2039            &[path.into()],
2040            hunk_diff_opts(),
2041        )
2042        .unwrap();
2043        let mut patch_text = String::new();
2044        for p in &patch {
2045            patch_text.push_str(&format_patch_for_git(p));
2046        }
2047        stage_hunks(
2048            &repo,
2049            DiffSide::Unstaged,
2050            &selection,
2051            &[path.into()],
2052            hunk_diff_opts(),
2053            false,
2054        )
2055        .unwrap();
2056        let grit_tree =
2057            write_tree_from_index(repo.odb(), &repo.index_handle().load().unwrap(), "").unwrap();
2058        git(root, &["read-tree", "HEAD"]);
2059        let patch_path = root.join("partial.diff");
2060        std::fs::write(&patch_path, patch_text).unwrap();
2061        git(root, &["apply", "--cached", patch_path.to_str().unwrap()]);
2062        let git_tree = Command::new("git")
2063            .current_dir(root)
2064            .args(["write-tree"])
2065            .output()
2066            .expect("write-tree");
2067        let git_tree_hex = String::from_utf8_lossy(&git_tree.stdout).trim().to_string();
2068        assert_eq!(grit_tree.to_hex(), git_tree_hex);
2069        git(root, &["fsck"]);
2070    }
2071
2072    #[cfg_attr(
2073        not(feature = "full-tests"),
2074        ignore = "starts system git or is heavy; run with --features full-tests"
2075    )]
2076    #[test]
2077    fn stage_hunks_whole_file_deletion_removes_index_entry() {
2078        let (dir, repo) = init_repo();
2079        let root = dir.path();
2080        std::fs::write(root.join("gone.txt"), b"keep\n").unwrap();
2081        git(root, &["add", "gone.txt"]);
2082        git(root, &["commit", "-qm", "init"]);
2083        std::fs::remove_file(root.join("gone.txt")).unwrap();
2084        let ids = list_unstaged_hunk_ids(&repo, &[], hunk_diff_opts())
2085            .unwrap()
2086            .remove("gone.txt")
2087            .expect("deletion in unstaged diff");
2088        stage_hunks(
2089            &repo,
2090            DiffSide::Unstaged,
2091            &HunkSelection {
2092                ids,
2093                ranges: Vec::new(),
2094            },
2095            &[],
2096            hunk_diff_opts(),
2097            false,
2098        )
2099        .unwrap();
2100        assert!(
2101            repo.index_handle()
2102                .load()
2103                .unwrap()
2104                .get(b"gone.txt", 0)
2105                .is_none(),
2106            "index entry removed after staging delete hunk"
2107        );
2108    }
2109
2110    #[cfg_attr(
2111        not(feature = "full-tests"),
2112        ignore = "starts system git or is heavy; run with --features full-tests"
2113    )]
2114    #[test]
2115    fn directory_pathspec_keeps_staged_peer_hunk_ids() {
2116        let (dir, repo) = init_repo();
2117        let root = dir.path();
2118        std::fs::create_dir_all(root.join("pkg")).unwrap();
2119        let (base, edited) = spaced_two_hunk_body();
2120        std::fs::write(root.join("pkg/a.txt"), &base).unwrap();
2121        git(root, &["add", "pkg/a.txt"]);
2122        git(root, &["commit", "-qm", "init"]);
2123        std::fs::write(root.join("pkg/a.txt"), &edited).unwrap();
2124        let (files, peers) =
2125            unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
2126        let file = files.iter().find(|f| f.path == "pkg/a.txt").expect("a.txt");
2127        let ids = hunk_ids_with_duplicate_peers(
2128            DiffSide::Unstaged,
2129            "pkg/a.txt",
2130            &file.hunks,
2131            peers.get("pkg/a.txt").map(|v| v.as_slice()).unwrap_or(&[]),
2132        );
2133        stage_hunks(
2134            &repo,
2135            DiffSide::Unstaged,
2136            &HunkSelection {
2137                ids: vec![ids[0].clone()],
2138                ranges: Vec::new(),
2139            },
2140            &[],
2141            hunk_diff_opts(),
2142            false,
2143        )
2144        .unwrap();
2145        let file_pathspec = vec!["pkg/a.txt".to_string()];
2146        let dir_pathspec = vec!["pkg/".to_string()];
2147        let opts = hunk_diff_opts();
2148        let by_file = list_unstaged_hunk_ids(&repo, &file_pathspec, opts.clone()).unwrap();
2149        let by_dir = list_unstaged_hunk_ids(&repo, &dir_pathspec, opts).unwrap();
2150        assert_eq!(
2151            by_file.get("pkg/a.txt"),
2152            by_dir.get("pkg/a.txt"),
2153            "directory pathspec must include staged peer hunks when assigning ids"
2154        );
2155    }
2156
2157    #[cfg_attr(
2158        not(feature = "full-tests"),
2159        ignore = "starts system git or is heavy; run with --features full-tests"
2160    )]
2161    #[test]
2162    fn lines_selects_part_of_hunk() {
2163        let (dir, repo) = init_repo();
2164        let root = dir.path();
2165        std::fs::write(root.join("f.txt"), b"a\nb\nc\nd\n").unwrap();
2166        git(root, &["add", "f.txt"]);
2167        git(root, &["commit", "-qm", "init"]);
2168        std::fs::write(root.join("f.txt"), b"a\nB\nc\nD\n").unwrap();
2169        stage_lines_and_compare_git(
2170            &repo,
2171            root,
2172            "f.txt",
2173            vec![LineRange {
2174                path: "f.txt".into(),
2175                start: 2,
2176                end: 2,
2177            }],
2178        );
2179    }
2180
2181    #[cfg_attr(
2182        not(feature = "full-tests"),
2183        ignore = "starts system git or is heavy; run with --features full-tests"
2184    )]
2185    #[test]
2186    fn lines_unselected_deletion_becomes_context() {
2187        let (dir, repo) = init_repo();
2188        let root = dir.path();
2189        std::fs::write(root.join("f.txt"), b"keep\nremove\nstay\n").unwrap();
2190        git(root, &["add", "f.txt"]);
2191        git(root, &["commit", "-qm", "init"]);
2192        std::fs::write(root.join("f.txt"), b"keep\nstay\n").unwrap();
2193        stage_lines_and_compare_git(
2194            &repo,
2195            root,
2196            "f.txt",
2197            vec![LineRange {
2198                path: "f.txt".into(),
2199                start: 1,
2200                end: 1,
2201            }],
2202        );
2203    }
2204
2205    #[cfg_attr(
2206        not(feature = "full-tests"),
2207        ignore = "starts system git or is heavy; run with --features full-tests"
2208    )]
2209    #[test]
2210    fn lines_selected_deletion_at_range_edge() {
2211        let (dir, repo) = init_repo();
2212        let root = dir.path();
2213        std::fs::write(root.join("f.txt"), b"a\nb\nc\n").unwrap();
2214        git(root, &["add", "f.txt"]);
2215        git(root, &["commit", "-qm", "init"]);
2216        std::fs::write(root.join("f.txt"), b"a\nc\n").unwrap();
2217        stage_lines_and_compare_git(
2218            &repo,
2219            root,
2220            "f.txt",
2221            vec![LineRange {
2222                path: "f.txt".into(),
2223                start: 1,
2224                end: 1,
2225            }],
2226        );
2227    }
2228
2229    #[cfg_attr(
2230        not(feature = "full-tests"),
2231        ignore = "starts system git or is heavy; run with --features full-tests"
2232    )]
2233    #[test]
2234    fn lines_range_spanning_two_hunks() {
2235        let (dir, repo) = init_repo();
2236        let root = dir.path();
2237        let (base, edited) = three_hunk_body();
2238        std::fs::write(root.join("f.txt"), base).unwrap();
2239        git(root, &["add", "f.txt"]);
2240        git(root, &["commit", "-qm", "init"]);
2241        std::fs::write(root.join("f.txt"), edited).unwrap();
2242        stage_lines_and_compare_git(
2243            &repo,
2244            root,
2245            "f.txt",
2246            vec![LineRange {
2247                path: "f.txt".into(),
2248                start: 5,
2249                end: 14,
2250            }],
2251        );
2252    }
2253
2254    #[cfg_attr(
2255        not(feature = "full-tests"),
2256        ignore = "starts system git or is heavy; run with --features full-tests"
2257    )]
2258    #[test]
2259    fn glob_pathspec_includes_file_in_hunk_bundle() {
2260        let (dir, repo) = init_repo();
2261        let root = dir.path();
2262        std::fs::write(root.join("f.txt"), b"a\nb\n").unwrap();
2263        git(root, &["add", "f.txt"]);
2264        git(root, &["commit", "-qm", "init"]);
2265        std::fs::write(root.join("f.txt"), b"a\nB\n").unwrap();
2266        let bundle = hunk_diff_bundle(&repo, &[":(glob)*.txt".into()], DiffOptions::default())
2267            .expect("bundle");
2268        assert!(
2269            bundle.unstaged.iter().any(|f| f.path == "f.txt"),
2270            "expected f.txt in bundle, got {:?}",
2271            bundle
2272                .unstaged
2273                .iter()
2274                .map(|f| f.path.as_str())
2275                .collect::<Vec<_>>()
2276        );
2277    }
2278
2279    #[cfg_attr(
2280        not(feature = "full-tests"),
2281        ignore = "starts system git or is heavy; run with --features full-tests"
2282    )]
2283    #[test]
2284    fn lines_pathspec_glob_stages() {
2285        let (dir, repo) = init_repo();
2286        let root = dir.path();
2287        std::fs::write(root.join("f.txt"), b"a\nb\n").unwrap();
2288        git(root, &["add", "f.txt"]);
2289        git(root, &["commit", "-qm", "init"]);
2290        std::fs::write(root.join("f.txt"), b"a\nB\n").unwrap();
2291        let selection = HunkSelection {
2292            ids: Vec::new(),
2293            ranges: vec![LineRange {
2294                path: "f.txt".into(),
2295                start: 2,
2296                end: 2,
2297            }],
2298        };
2299        stage_hunks(
2300            &repo,
2301            DiffSide::Unstaged,
2302            &selection,
2303            &[":(glob)*.txt".into()],
2304            hunk_diff_opts(),
2305            false,
2306        )
2307        .unwrap();
2308        git(root, &["fsck"]);
2309    }
2310
2311    #[cfg_attr(
2312        not(feature = "full-tests"),
2313        ignore = "starts system git or is heavy; run with --features full-tests"
2314    )]
2315    #[test]
2316    fn lines_mix_hunk_id_overlap_stages_via_id() {
2317        let (dir, repo) = init_repo();
2318        let root = dir.path();
2319        std::fs::write(root.join("f.txt"), b"one\n").unwrap();
2320        git(root, &["add", "f.txt"]);
2321        git(root, &["commit", "-qm", "init"]);
2322        std::fs::write(root.join("f.txt"), b"two\n").unwrap();
2323        let (files, peers) =
2324            unstaged_file_diffs_with_peer_hunks(&repo, &[], DiffOptions::default()).unwrap();
2325        let ids = hunk_ids_with_duplicate_peers(
2326            DiffSide::Unstaged,
2327            "f.txt",
2328            &files[0].hunks,
2329            peers.get("f.txt").map(|v| v.as_slice()).unwrap_or(&[]),
2330        );
2331        let selection = HunkSelection {
2332            ids: vec![ids[0].clone()],
2333            ranges: vec![LineRange {
2334                path: "f.txt".into(),
2335                start: 1,
2336                end: 1,
2337            }],
2338        };
2339        stage_hunks(
2340            &repo,
2341            DiffSide::Unstaged,
2342            &selection,
2343            &["f.txt".into()],
2344            hunk_diff_opts(),
2345            false,
2346        )
2347        .unwrap();
2348        git(root, &["fsck"]);
2349    }
2350
2351    #[cfg_attr(
2352        not(feature = "full-tests"),
2353        ignore = "starts system git or is heavy; run with --features full-tests"
2354    )]
2355    #[test]
2356    fn lines_unchanged_line_beyond_hunk_is_selects_nothing() {
2357        let (dir, repo) = init_repo();
2358        let root = dir.path();
2359        let mut base = String::new();
2360        for i in 1..=100 {
2361            base.push_str(&format!("line{i}\n"));
2362        }
2363        std::fs::write(root.join("f.txt"), &base).unwrap();
2364        git(root, &["add", "f.txt"]);
2365        git(root, &["commit", "-qm", "init"]);
2366        let mut edited = base.clone();
2367        edited = edited.replacen("line1\n", "LINE1\n", 1);
2368        std::fs::write(root.join("f.txt"), edited).unwrap();
2369        let err = stage_hunks(
2370            &repo,
2371            DiffSide::Unstaged,
2372            &HunkSelection {
2373                ids: Vec::new(),
2374                ranges: vec![LineRange {
2375                    path: "f.txt".into(),
2376                    start: 100,
2377                    end: 100,
2378                }],
2379            },
2380            &[],
2381            hunk_diff_opts(),
2382            false,
2383        )
2384        .unwrap_err();
2385        assert!(
2386            matches!(err, HunkSelectError::RangeSelectsNothing { .. }),
2387            "expected RangeSelectsNothing, got {err:?}"
2388        );
2389    }
2390
2391    #[cfg_attr(
2392        not(feature = "full-tests"),
2393        ignore = "starts system git or is heavy; run with --features full-tests"
2394    )]
2395    #[test]
2396    fn lines_out_of_bounds_error() {
2397        let (dir, repo) = init_repo();
2398        let root = dir.path();
2399        std::fs::write(root.join("f.txt"), b"one\n").unwrap();
2400        git(root, &["add", "f.txt"]);
2401        git(root, &["commit", "-qm", "init"]);
2402        std::fs::write(root.join("f.txt"), b"two\n").unwrap();
2403        let err = stage_hunks(
2404            &repo,
2405            DiffSide::Unstaged,
2406            &HunkSelection {
2407                ids: Vec::new(),
2408                ranges: vec![LineRange {
2409                    path: "f.txt".into(),
2410                    start: 1,
2411                    end: 99,
2412                }],
2413            },
2414            &[],
2415            hunk_diff_opts(),
2416            false,
2417        )
2418        .unwrap_err();
2419        assert!(matches!(err, HunkSelectError::RangeOutOfBounds { .. }));
2420    }
2421
2422    #[cfg(unix)]
2423    #[cfg_attr(
2424        not(feature = "full-tests"),
2425        ignore = "starts system git or is heavy; run with --features full-tests"
2426    )]
2427    #[test]
2428    fn lines_symlink_dangling_target_matches_git_apply_cached() {
2429        use std::os::unix::fs::symlink;
2430
2431        let (dir, repo) = init_repo();
2432        let root = dir.path();
2433        symlink("old-target", root.join("link")).expect("symlink");
2434        git(root, &["add", "link"]);
2435        git(root, &["commit", "-qm", "init"]);
2436        std::fs::remove_file(root.join("link")).expect("remove link");
2437        symlink("new-target", root.join("link")).expect("retarget");
2438        stage_lines_and_compare_git(
2439            &repo,
2440            root,
2441            "link",
2442            vec![LineRange {
2443                path: "link".into(),
2444                start: 1,
2445                end: 1,
2446            }],
2447        );
2448    }
2449
2450    #[cfg_attr(
2451        not(feature = "full-tests"),
2452        ignore = "starts system git or is heavy; run with --features full-tests"
2453    )]
2454    #[test]
2455    fn lines_no_eof_newline() {
2456        let (dir, repo) = init_repo();
2457        let root = dir.path();
2458        std::fs::write(root.join("f.txt"), b"old").unwrap();
2459        git(root, &["add", "f.txt"]);
2460        git(root, &["commit", "-qm", "init"]);
2461        std::fs::write(root.join("f.txt"), b"new").unwrap();
2462        stage_lines_and_compare_git(
2463            &repo,
2464            root,
2465            "f.txt",
2466            vec![LineRange {
2467                path: "f.txt".into(),
2468                start: 1,
2469                end: 1,
2470            }],
2471        );
2472    }
2473}