Skip to main content

journey/backend/
patch.rs

1//! Reconstructs a minimal unified-diff patch covering only a *selected* range
2//! of lines, so the commit screen can stage or unstage part of a file instead
3//! of the whole thing — the engine behind the Stage/Unstage button that floats
4//! over a highlighted region in the diff view.
5//!
6//! The displayed diff (one file, with its `diff --git` / hunk headers and `+`/
7//! `-`/context body) is filtered down to the rows the user highlighted, and the
8//! touched hunks are rebuilt with corrected line counts. The result is always
9//! oriented to apply **forward** to the index (`git apply --cached`), so the
10//! direction (stage vs. unstage) is baked into the patch rather than left to the
11//! caller — see [`RepoBackend::apply_to_index`](super::RepoBackend::apply_to_index).
12
13use std::collections::BTreeSet;
14
15use super::{Diff, DiffLineKind};
16
17/// Which way a partial patch runs. Both produce a patch applied forward to the
18/// index; the difference is which displayed diff it is built from and how the
19/// selected `+`/`-` lines are mapped.
20#[derive(Clone, Copy, PartialEq, Eq, Debug)]
21pub enum PartialMode {
22    /// Stage selected lines, built from the unstaged (index→workdir) diff: the
23    /// selected changes are carried straight through.
24    Stage,
25    /// Unstage selected lines, built from the staged (HEAD→index) diff and
26    /// reversed, so applying it to the index backs the changes out.
27    Unstage,
28}
29
30/// Whether a diff row is an actual change (`+`/`-`) — the only kind of row a
31/// partial stage/unstage can act on. Header and context rows may fall inside a
32/// selection but never make it stageable on their own.
33pub fn is_change_line(kind: DiffLineKind) -> bool {
34    matches!(kind, DiffLineKind::Addition | DiffLineKind::Deletion)
35}
36
37/// Build a patch staging/unstaging exactly the `selected` rows of `diff` (row
38/// indices into `diff.lines`). Returns `None` when the selection covers no
39/// changed line, so the caller can treat it as a no-op.
40pub fn build_partial_patch(
41    diff: &Diff,
42    selected: &BTreeSet<usize>,
43    mode: PartialMode,
44) -> Option<String> {
45    let lines = &diff.lines;
46    let mut out = String::new();
47    let mut emitted_any = false;
48
49    // Header lines for the file currently being processed (verbatim, minus the
50    // `index` line — see `keep_header_line`), written lazily before the file's
51    // first emitted hunk so files with no selected change produce no output.
52    let mut file_header: Vec<&str> = Vec::new();
53    let mut file_header_written = false;
54    // Running new-line offset accumulated over the hunks emitted for this file,
55    // so each rebuilt hunk's `+` start stays consistent with the ones before it.
56    let mut delta: i64 = 0;
57
58    let mut i = 0;
59    while i < lines.len() {
60        match lines[i].kind {
61            DiffLineKind::FileHeader => {
62                file_header.clear();
63                file_header_written = false;
64                delta = 0;
65                while i < lines.len() && lines[i].kind == DiffLineKind::FileHeader {
66                    if keep_header_line(&lines[i].text) {
67                        file_header.push(&lines[i].text);
68                    }
69                    i += 1;
70                }
71            }
72            DiffLineKind::HunkHeader => {
73                let parsed = parse_hunk_header(&lines[i].text);
74                let body_start = i + 1;
75                let mut body_end = body_start;
76                while body_end < lines.len()
77                    && !matches!(
78                        lines[body_end].kind,
79                        DiffLineKind::HunkHeader | DiffLineKind::FileHeader
80                    )
81                {
82                    body_end += 1;
83                }
84
85                if let (Some((old_a, new_c)), Some(hunk)) = (
86                    parsed,
87                    rebuild_hunk(lines, body_start, body_end, selected, mode),
88                ) {
89                    if !file_header_written {
90                        for h in &file_header {
91                            out.push_str(h);
92                            out.push('\n');
93                        }
94                        file_header_written = true;
95                    }
96                    // The patch applies forward to the index, so its "old" side
97                    // is the index: the unstaged diff's `-` start when staging,
98                    // the staged diff's `+` start when unstaging.
99                    let old_start = match mode {
100                        PartialMode::Stage => old_a,
101                        PartialMode::Unstage => new_c,
102                    };
103                    let new_start = (old_start as i64 + delta).max(0);
104                    out.push_str(&format!(
105                        "@@ -{},{} +{},{} @@\n",
106                        old_start, hunk.old_count, new_start, hunk.new_count
107                    ));
108                    out.push_str(&hunk.body);
109                    delta += hunk.new_count as i64 - hunk.old_count as i64;
110                    emitted_any = true;
111                }
112                i = body_end;
113            }
114            // Stray rows outside any hunk (e.g. the commit-detail header the
115            // browse view prepends) are never part of a stageable file diff.
116            _ => i += 1,
117        }
118    }
119
120    emitted_any.then_some(out)
121}
122
123/// A single rebuilt hunk: its body text and the line counts for its header.
124struct RebuiltHunk {
125    body: String,
126    old_count: usize,
127    new_count: usize,
128}
129
130/// Rebuild one hunk body (`lines[start..end]`) keeping only the selected
131/// changes; unselected changes are folded into context or dropped per `mode`.
132/// Returns `None` if no changed line in the hunk was selected.
133fn rebuild_hunk(
134    lines: &[super::DiffLine],
135    start: usize,
136    end: usize,
137    selected: &BTreeSet<usize>,
138    mode: PartialMode,
139) -> Option<RebuiltHunk> {
140    let mut body = String::new();
141    let mut old_count = 0;
142    let mut new_count = 0;
143    let mut has_change = false;
144    let mut prev_emitted = false;
145
146    for (idx, line) in lines.iter().enumerate().take(end).skip(start) {
147        // A "\ No newline at end of file" marker rides along with the line it
148        // annotates: keep it only when that line was emitted.
149        if line.kind == DiffLineKind::Meta {
150            if prev_emitted && line.text.starts_with('\\') {
151                body.push_str(&line.text);
152                body.push('\n');
153            }
154            continue;
155        }
156
157        let selected_here = selected.contains(&idx);
158        let new_origin = map_origin(line.kind, selected_here, mode);
159        let Some(origin) = new_origin else {
160            prev_emitted = false;
161            continue;
162        };
163
164        if selected_here && is_change_line(line.kind) {
165            has_change = true;
166        }
167        // Body rows always carry a leading origin byte (' '/'+'/'-'); swap it
168        // for the rebuilt one.
169        let content = &line.text[1..];
170        body.push(origin);
171        body.push_str(content);
172        body.push('\n');
173        match origin {
174            ' ' => {
175                old_count += 1;
176                new_count += 1;
177            }
178            '-' => old_count += 1,
179            '+' => new_count += 1,
180            _ => {}
181        }
182        prev_emitted = true;
183    }
184
185    has_change.then_some(RebuiltHunk {
186        body,
187        old_count,
188        new_count,
189    })
190}
191
192/// The origin character a body row gets in the rebuilt (forward-to-index)
193/// patch, or `None` to drop the row entirely.
194fn map_origin(kind: DiffLineKind, selected: bool, mode: PartialMode) -> Option<char> {
195    match (kind, mode) {
196        (DiffLineKind::Context, _) => Some(' '),
197        // Stage: selected changes carry through; an unselected deletion stays
198        // present (context), an unselected addition isn't staged (dropped).
199        (DiffLineKind::Addition, PartialMode::Stage) => selected.then_some('+'),
200        (DiffLineKind::Deletion, PartialMode::Stage) => Some(if selected { '-' } else { ' ' }),
201        // Unstage reverses the staged diff: a selected addition is removed from
202        // the index (`-`), a selected deletion is restored (`+`); unselected
203        // additions stay (context) and unselected deletions are already gone.
204        (DiffLineKind::Addition, PartialMode::Unstage) => Some(if selected { '-' } else { ' ' }),
205        (DiffLineKind::Deletion, PartialMode::Unstage) => selected.then_some('+'),
206        _ => None,
207    }
208}
209
210/// Keep every file-header line except `index <old>..<new>`: the rebuilt content
211/// won't match those blob OIDs, and libgit2's apply is happy without it for text
212/// patches (the `new file` / `deleted file` mode lines, which it does need, stay).
213fn keep_header_line(text: &str) -> bool {
214    !text.starts_with("index ")
215}
216
217/// Parse the old/new *start* line numbers from a `@@ -a,b +c,d @@` header.
218/// Counts are ignored (rebuilt hunks recompute them); a missing `,count` is
219/// fine. Returns `None` for a malformed header.
220fn parse_hunk_header(text: &str) -> Option<(usize, usize)> {
221    let rest = text.strip_prefix("@@ ")?;
222    let mut parts = rest.split_whitespace();
223    let old = parts.next()?.strip_prefix('-')?;
224    let new = parts.next()?.strip_prefix('+')?;
225    let a = old.split(',').next()?.parse().ok()?;
226    let c = new.split(',').next()?.parse().ok()?;
227    Some((a, c))
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::backend::DiffLine;
234
235    fn diff(rows: &[(DiffLineKind, &str)]) -> Diff {
236        Diff {
237            lines: rows
238                .iter()
239                .map(|(k, t)| DiffLine::new(*k, t.to_string()))
240                .collect(),
241        }
242    }
243
244    // A modified file with one hunk: one deletion, two additions, plus context.
245    fn sample() -> Diff {
246        use DiffLineKind::*;
247        diff(&[
248            (FileHeader, "diff --git a/src/x.rs b/src/x.rs"),
249            (FileHeader, "index 1111111..2222222 100644"),
250            (FileHeader, "--- a/src/x.rs"),
251            (FileHeader, "+++ b/src/x.rs"),
252            (HunkHeader, "@@ -10,4 +10,5 @@ fn f() {"),
253            (Context, "     let a = 1;"),
254            (Deletion, "-    let b = 2;"),
255            (Addition, "+    let b = 3;"),
256            (Addition, "+    let c = 4;"),
257            (Context, "     done();"),
258        ])
259    }
260
261    fn rows(range: std::ops::RangeInclusive<usize>) -> BTreeSet<usize> {
262        range.collect()
263    }
264
265    #[test]
266    fn empty_selection_yields_nothing() {
267        assert!(build_partial_patch(&sample(), &BTreeSet::new(), PartialMode::Stage).is_none());
268        // A selection that only covers header/context rows is not stageable.
269        assert!(build_partial_patch(&sample(), &rows(0..=5), PartialMode::Stage).is_none());
270    }
271
272    #[test]
273    fn stage_only_the_deletion_turns_additions_into_nothing() {
274        // Select just the `-` row (index 6).
275        let patch = build_partial_patch(&sample(), &rows(6..=6), PartialMode::Stage).unwrap();
276        let expected = "\
277diff --git a/src/x.rs b/src/x.rs
278--- a/src/x.rs
279+++ b/src/x.rs
280@@ -10,3 +10,2 @@
281     let a = 1;
282-    let b = 2;
283     done();
284";
285        assert_eq!(patch, expected);
286    }
287
288    #[test]
289    fn stage_only_the_additions_keeps_deletion_as_context() {
290        // Select the two `+` rows (indices 7,8) but not the `-`.
291        let patch = build_partial_patch(&sample(), &rows(7..=8), PartialMode::Stage).unwrap();
292        let expected = "\
293diff --git a/src/x.rs b/src/x.rs
294--- a/src/x.rs
295+++ b/src/x.rs
296@@ -10,3 +10,5 @@
297     let a = 1;
298     let b = 2;
299+    let b = 3;
300+    let c = 4;
301     done();
302";
303        assert_eq!(patch, expected);
304    }
305
306    #[test]
307    fn unstage_reverses_origins() {
308        // Same displayed hunk, but interpret it as the staged diff and unstage
309        // the two additions: they become deletions, the deletion (unselected)
310        // drops out, and the hunk's old side is the index (`+10`).
311        let patch = build_partial_patch(&sample(), &rows(7..=8), PartialMode::Unstage).unwrap();
312        let expected = "\
313diff --git a/src/x.rs b/src/x.rs
314--- a/src/x.rs
315+++ b/src/x.rs
316@@ -10,4 +10,2 @@
317     let a = 1;
318-    let b = 3;
319-    let c = 4;
320     done();
321";
322        assert_eq!(patch, expected);
323    }
324
325    #[test]
326    fn second_hunk_new_start_tracks_prior_emitted_delta() {
327        use DiffLineKind::*;
328        let d = diff(&[
329            (FileHeader, "diff --git a/f b/f"),
330            (FileHeader, "--- a/f"),
331            (FileHeader, "+++ b/f"),
332            (HunkHeader, "@@ -10,2 +10,3 @@"),
333            (Context, " keep1"),
334            (Addition, "+added-a"),
335            (Context, " keep2"),
336            (HunkHeader, "@@ -50,2 +51,3 @@"),
337            (Context, " keep3"),
338            (Addition, "+added-b"),
339            (Context, " keep4"),
340        ]);
341        // Select both additions (indices 5 and 9).
342        let mut sel = BTreeSet::new();
343        sel.insert(5);
344        sel.insert(9);
345        let patch = build_partial_patch(&d, &sel, PartialMode::Stage).unwrap();
346        // First hunk adds one line (delta +1), so the second hunk's new start is
347        // its old start (50) shifted by that delta → 51.
348        let expected = "\
349diff --git a/f b/f
350--- a/f
351+++ b/f
352@@ -10,2 +10,3 @@
353 keep1
354+added-a
355 keep2
356@@ -50,2 +51,3 @@
357 keep3
358+added-b
359 keep4
360";
361        assert_eq!(patch, expected);
362    }
363
364    #[test]
365    fn unselected_hunk_is_omitted_entirely() {
366        use DiffLineKind::*;
367        let d = diff(&[
368            (FileHeader, "diff --git a/f b/f"),
369            (FileHeader, "--- a/f"),
370            (FileHeader, "+++ b/f"),
371            (HunkHeader, "@@ -10,2 +10,3 @@"),
372            (Context, " keep1"),
373            (Addition, "+added-a"),
374            (Context, " keep2"),
375            (HunkHeader, "@@ -50,2 +51,3 @@"),
376            (Context, " keep3"),
377            (Addition, "+added-b"),
378            (Context, " keep4"),
379        ]);
380        // Select only the second hunk's addition (index 9).
381        let patch = build_partial_patch(&d, &rows(9..=9), PartialMode::Stage).unwrap();
382        let expected = "\
383diff --git a/f b/f
384--- a/f
385+++ b/f
386@@ -50,2 +50,3 @@
387 keep3
388+added-b
389 keep4
390";
391        assert_eq!(patch, expected);
392    }
393}