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