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