Skip to main content

grit_lib/porcelain/
hunk_select.rs

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