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