Skip to main content

grit_lib/porcelain/
hunk_select.rs

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