Skip to main content

vcs_git/
parse.rs

1//! Pure parsers for git's machine-readable output. No process execution, so the
2//! tests here are hermetic and run on CI.
3
4use std::path::PathBuf;
5
6/// One entry from `git status --porcelain=v1 -z` (`XY <path>`, NUL-delimited).
7#[derive(Debug, Clone, PartialEq, Eq)]
8#[non_exhaustive]
9pub struct StatusEntry {
10    /// Two-character status code, e.g. `" M"`, `"??"`, `"A "`, `"R "`.
11    pub code: String,
12    /// Path the status applies to (the *new* path for a rename/copy). Raw bytes
13    /// from `-z` — no C-quoting/escaping to undo, even for paths with spaces.
14    pub path: String,
15    /// For a rename/copy, the original path; `None` otherwise.
16    pub orig_path: Option<String>,
17}
18
19/// A commit, parsed from a `\x1f`-delimited `git log` line.
20#[derive(Debug, Clone, PartialEq, Eq)]
21#[non_exhaustive]
22pub struct Commit {
23    /// Full commit hash (`%H`).
24    pub hash: String,
25    /// Abbreviated commit hash (`%h`).
26    pub short_hash: String,
27    /// Author name (`%an`).
28    pub author: String,
29    /// Author date, strict ISO-8601 (`%aI`), e.g. `2026-05-31T10:00:00+00:00`.
30    pub date: String,
31    /// Subject line (`%s`).
32    pub subject: String,
33}
34
35/// A local branch from `git branch`.
36#[derive(Debug, Clone, PartialEq, Eq)]
37#[non_exhaustive]
38pub struct Branch {
39    /// Branch name.
40    pub name: String,
41    /// Whether this is the checked-out branch (the `*` marker).
42    pub current: bool,
43}
44
45/// A worktree from `git worktree list --porcelain`.
46#[derive(Debug, Clone, PartialEq, Eq)]
47#[non_exhaustive]
48pub struct Worktree {
49    /// Absolute path to the worktree.
50    pub path: PathBuf,
51    /// Short branch name (`refs/heads/` stripped); `None` when detached or bare.
52    pub branch: Option<String>,
53    /// The checked-out commit (`HEAD <sha>`); `None` for a bare entry.
54    pub head: Option<String>,
55    /// The main worktree of a bare repository.
56    pub bare: bool,
57    /// Checked out at a detached HEAD (no branch).
58    pub detached: bool,
59    /// Locked against pruning.
60    pub locked: bool,
61}
62
63/// Aggregate line/file counts from `git diff --shortstat`.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
65#[non_exhaustive]
66pub struct DiffStat {
67    /// Number of files changed.
68    pub files_changed: usize,
69    /// Lines added (`insertions(+)`).
70    pub insertions: usize,
71    /// Lines removed (`deletions(-)`).
72    pub deletions: usize,
73}
74
75/// How a file changed in a unified diff.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77#[non_exhaustive]
78pub enum ChangeKind {
79    /// A new file (`new file mode …`).
80    Added,
81    /// An existing file's contents changed.
82    Modified,
83    /// The file was removed (`deleted file mode …`).
84    Deleted,
85    /// The file was renamed (`rename from …` / `rename to …`).
86    Renamed,
87}
88
89/// One line inside a [`Hunk`], tagged by its role. The stored text excludes the
90/// leading ` `/`+`/`-` marker.
91#[derive(Debug, Clone, PartialEq, Eq)]
92#[non_exhaustive]
93pub enum DiffLine {
94    /// Unchanged context line (leading ` `).
95    Context(String),
96    /// Added line (leading `+`).
97    Added(String),
98    /// Removed line (leading `-`).
99    Removed(String),
100}
101
102/// A single `@@ … @@` hunk within a [`FileDiff`].
103#[derive(Debug, Clone, PartialEq, Eq)]
104#[non_exhaustive]
105pub struct Hunk {
106    /// Start line in the old file (the `-<start>` of the `@@` header).
107    pub old_start: usize,
108    /// Line count in the old file (defaults to 1 when the `,<count>` is omitted).
109    pub old_lines: usize,
110    /// Start line in the new file (the `+<start>` of the `@@` header).
111    pub new_start: usize,
112    /// Line count in the new file (defaults to 1 when the `,<count>` is omitted).
113    pub new_lines: usize,
114    /// Text after the closing `@@` (the function/section heading); empty when none.
115    pub section: String,
116    /// The hunk body, one entry per `+`/`-`/` ` line.
117    pub lines: Vec<DiffLine>,
118}
119
120/// One file's entry in a parsed git-format unified diff (`git diff` or
121/// `jj diff --git`).
122#[derive(Debug, Clone, PartialEq, Eq)]
123#[non_exhaustive]
124pub struct FileDiff {
125    /// How the file changed.
126    pub change: ChangeKind,
127    /// The file's path — the *new* path for a rename — forward-slash normalised.
128    pub path: String,
129    /// For a rename, the original path (forward-slash normalised); `None` otherwise.
130    pub old_path: Option<String>,
131    /// The `@@` hunks; empty for a binary file or a pure rename with no edits.
132    pub hunks: Vec<Hunk>,
133    /// The verbatim diff section for this file (the `diff --git …` block through
134    /// to the next file), for callers that display the raw text.
135    pub raw: String,
136}
137
138/// Parse `git status --porcelain=v1 -z` output: NUL-delimited records, raw
139/// (unquoted) paths. A rename/copy entry is followed by its source path as the
140/// next NUL record (e.g. `R  new\0old\0`).
141pub(crate) fn parse_porcelain(output: &str) -> Vec<StatusEntry> {
142    let mut entries = Vec::new();
143    let mut records = output.split('\0').filter(|rec| !rec.is_empty());
144    while let Some(rec) = records.next() {
145        // "XY path": two ASCII code chars (always ASCII → byte-slicing is safe),
146        // a space, then a non-empty path.
147        if rec.len() < 4 {
148            continue;
149        }
150        // A rename/copy (R/C in the index column) carries its source path as the
151        // immediately following NUL record; consume it.
152        let orig_path = if matches!(rec.as_bytes()[0], b'R' | b'C') {
153            records.next().map(str::to_string)
154        } else {
155            None
156        };
157        entries.push(StatusEntry {
158            code: rec[..2].to_string(),
159            path: rec[3..].to_string(),
160            orig_path,
161        });
162    }
163    entries
164}
165
166/// Parse `git log -z --format=%H%x1f%h%x1f%an%x1f%aI%x1f%s` output: commits are
167/// NUL-separated (robust to multi-line fields), fields split on the ASCII unit
168/// separator.
169pub(crate) fn parse_log(output: &str) -> Vec<Commit> {
170    output
171        .split('\0')
172        .filter(|rec| !rec.is_empty())
173        .filter_map(|rec| {
174            let mut fields = rec.split('\u{1f}');
175            Some(Commit {
176                hash: fields.next()?.to_string(),
177                short_hash: fields.next()?.to_string(),
178                author: fields.next()?.to_string(),
179                date: fields.next()?.to_string(),
180                subject: fields.next().unwrap_or("").to_string(),
181            })
182        })
183        .collect()
184}
185
186/// Parse `git branch` output. The first column is the `* `/`  `/`+ ` marker.
187pub(crate) fn parse_branches(output: &str) -> Vec<Branch> {
188    output
189        .lines()
190        .filter(|line| !line.trim().is_empty())
191        .filter_map(|line| {
192            let current = line.starts_with('*');
193            let name = line.get(1..).unwrap_or("").trim();
194            // Skip the detached-HEAD pseudo-entry, e.g. "* (HEAD detached at …)".
195            if name.is_empty() || name.starts_with('(') {
196                return None;
197            }
198            Some(Branch {
199                name: name.to_string(),
200                current,
201            })
202        })
203        .collect()
204}
205
206/// Parse `git worktree list --porcelain`: records separated by a blank line,
207/// each a set of `label [value]` lines — `worktree <path>`, `HEAD <sha>`,
208/// `branch refs/heads/<name>`, plus the valueless attributes `bare` / `detached`
209/// / `locked`. Unknown labels (e.g. `prunable`) are ignored.
210pub(crate) fn parse_worktree_porcelain(output: &str) -> Vec<Worktree> {
211    let mut worktrees = Vec::new();
212    let mut current: Option<Worktree> = None;
213    let flush = |current: &mut Option<Worktree>, out: &mut Vec<Worktree>| {
214        if let Some(wt) = current.take() {
215            out.push(wt);
216        }
217    };
218    for line in output.lines() {
219        if line.is_empty() {
220            flush(&mut current, &mut worktrees);
221            continue;
222        }
223        let (label, value) = match line.split_once(' ') {
224            Some((l, v)) => (l, Some(v)),
225            None => (line, None),
226        };
227        match label {
228            // A new record begins; flush any record not closed by a blank line.
229            "worktree" => {
230                flush(&mut current, &mut worktrees);
231                current = Some(Worktree {
232                    path: PathBuf::from(value.unwrap_or("")),
233                    branch: None,
234                    head: None,
235                    bare: false,
236                    detached: false,
237                    locked: false,
238                });
239            }
240            "HEAD" => {
241                if let Some(wt) = current.as_mut() {
242                    wt.head = value.map(str::to_string);
243                }
244            }
245            "branch" => {
246                if let Some(wt) = current.as_mut() {
247                    // Value is a full ref (`refs/heads/main`); expose the short name.
248                    wt.branch =
249                        value.map(|v| v.strip_prefix("refs/heads/").unwrap_or(v).to_string());
250                }
251            }
252            "bare" => {
253                if let Some(wt) = current.as_mut() {
254                    wt.bare = true;
255                }
256            }
257            "detached" => {
258                if let Some(wt) = current.as_mut() {
259                    wt.detached = true;
260                }
261            }
262            "locked" => {
263                if let Some(wt) = current.as_mut() {
264                    wt.locked = true;
265                }
266            }
267            _ => {}
268        }
269    }
270    flush(&mut current, &mut worktrees);
271    worktrees
272}
273
274/// Parse `git diff --shortstat`, e.g. ` 3 files changed, 12 insertions(+), 4
275/// deletions(-)`. Any clause may be absent (a pure-insertion diff omits
276/// deletions; no changes yields an empty string → all zeros).
277pub(crate) fn parse_shortstat(output: &str) -> DiffStat {
278    let mut stat = DiffStat::default();
279    for part in output.split(',') {
280        let part = part.trim();
281        let n = part
282            .split_whitespace()
283            .next()
284            .and_then(|tok| tok.parse().ok())
285            .unwrap_or(0);
286        if part.contains("file") {
287            stat.files_changed = n;
288        } else if part.contains("insertion") {
289            stat.insertions = n;
290        } else if part.contains("deletion") {
291            stat.deletions = n;
292        }
293    }
294    stat
295}
296
297/// Parse `git ls-remote --heads <remote>` output — `<sha>\trefs/heads/<name>`
298/// per line — into the bare branch names.
299pub(crate) fn parse_ls_remote_heads(output: &str) -> Vec<String> {
300    output
301        .lines()
302        .filter_map(|line| {
303            let (_sha, refname) = line.split_once('\t')?;
304            refname
305                .trim()
306                .strip_prefix("refs/heads/")
307                .map(str::to_string)
308        })
309        .collect()
310}
311
312/// Parse a git-format unified diff into one [`FileDiff`] per file. Works on
313/// `git diff` and `jj diff --git` output alike. Public so a consumer can parse
314/// diff text it obtained by other means.
315///
316/// Paths are read from the unambiguous single-path lines (`+++ b/…`, `--- a/…`,
317/// `rename to …`) rather than the space-ambiguous `diff --git a/… b/…` header,
318/// and normalised to forward slashes. Ported from the `vcs-flow-commit` parser.
319pub fn parse_diff(diff: &str) -> Vec<FileDiff> {
320    diff_sections(diff).filter_map(parse_section).collect()
321}
322
323/// Slice a git-format diff into per-file sections (each starts at `diff --git`).
324fn diff_sections(full: &str) -> impl Iterator<Item = &str> {
325    let mut bounds = Vec::new();
326    let mut idx = 0;
327    for line in full.split_inclusive('\n') {
328        if line.starts_with("diff --git ") {
329            bounds.push(idx);
330        }
331        idx += line.len();
332    }
333    let ends = bounds
334        .iter()
335        .skip(1)
336        .copied()
337        .chain(std::iter::once(full.len()));
338    bounds
339        .clone()
340        .into_iter()
341        .zip(ends)
342        .map(move |(s, e)| &full[s..e])
343        .collect::<Vec<_>>()
344        .into_iter()
345}
346
347/// Determine the [`FileDiff`] for one `diff --git` section: change kind and path
348/// from the header lines, plus every `@@` hunk and its body.
349fn parse_section(section: &str) -> Option<FileDiff> {
350    let mut kind = ChangeKind::Modified;
351    let mut new_path = None;
352    let mut minus_path = None;
353    let mut rename_to = None;
354    let mut rename_from = None;
355    let mut hunks: Vec<Hunk> = Vec::new();
356    let mut current: Option<Hunk> = None;
357
358    for line in section.lines() {
359        if let Some(hunk) = parse_hunk_header(line) {
360            if let Some(done) = current.replace(hunk) {
361                hunks.push(done);
362            }
363            continue;
364        }
365        if let Some(hunk) = current.as_mut() {
366            // Inside a hunk body: classify by the leading marker. `\ No newline at
367            // end of file` annotations and any stray blank line are dropped.
368            match line.as_bytes().first() {
369                Some(b' ') => hunk.lines.push(DiffLine::Context(line[1..].to_string())),
370                Some(b'+') => hunk.lines.push(DiffLine::Added(line[1..].to_string())),
371                Some(b'-') => hunk.lines.push(DiffLine::Removed(line[1..].to_string())),
372                _ => {}
373            }
374            continue;
375        }
376        // Header region (before the first `@@`).
377        if line.starts_with("new file") {
378            kind = ChangeKind::Added;
379        } else if line.starts_with("deleted file") {
380            kind = ChangeKind::Deleted;
381        } else if let Some(p) = line.strip_prefix("rename to ") {
382            rename_to = Some(p.trim_end().to_string());
383        } else if let Some(p) = line.strip_prefix("rename from ") {
384            rename_from = Some(p.trim_end().to_string());
385        } else if let Some(p) = line.strip_prefix("+++ b/") {
386            new_path = Some(p.trim_end().to_string());
387        } else if let Some(p) = line.strip_prefix("--- a/") {
388            minus_path = Some(p.trim_end().to_string());
389        }
390    }
391    if let Some(done) = current.take() {
392        hunks.push(done);
393    }
394
395    let normalize = |p: String| p.replace('\\', "/");
396    // A rename keeps its old path so a caller can record the deletion too.
397    let old_path = if rename_to.is_some() {
398        kind = ChangeKind::Renamed;
399        rename_from.map(normalize)
400    } else {
401        None
402    };
403    let path = rename_to
404        .or(new_path)
405        .or(minus_path)
406        .or_else(|| header_b_path(section))?;
407    Some(FileDiff {
408        change: kind,
409        path: normalize(path),
410        old_path,
411        hunks,
412        raw: section.to_string(),
413    })
414}
415
416/// Parse a hunk header `@@ -<os>[,<ol>] +<ns>[,<nl>] @@[ <section>]` into an empty
417/// [`Hunk`]; `None` for any other line.
418fn parse_hunk_header(line: &str) -> Option<Hunk> {
419    let rest = line.strip_prefix("@@ ")?;
420    let (ranges, section) = rest.split_once(" @@")?;
421    let mut parts = ranges.split_whitespace();
422    let (old_start, old_lines) = parse_hunk_range(parts.next()?.strip_prefix('-')?);
423    let (new_start, new_lines) = parse_hunk_range(parts.next()?.strip_prefix('+')?);
424    Some(Hunk {
425        old_start,
426        old_lines,
427        new_start,
428        new_lines,
429        section: section.strip_prefix(' ').unwrap_or(section).to_string(),
430        lines: Vec::new(),
431    })
432}
433
434/// Parse a `<start>[,<count>]` hunk range; an omitted count means 1 line.
435fn parse_hunk_range(range: &str) -> (usize, usize) {
436    match range.split_once(',') {
437        Some((start, count)) => (start.parse().unwrap_or(0), count.parse().unwrap_or(0)),
438        None => (range.parse().unwrap_or(0), 1),
439    }
440}
441
442/// Fallback path extraction for sections with no `+++`/`---`/`rename` lines
443/// (e.g. binary files): the `b/<new>` of the `diff --git` header. Ambiguous only
444/// when a path contains the literal `" b/"`, which binary-with-spaces makes rare.
445fn header_b_path(section: &str) -> Option<String> {
446    let first = section.lines().next()?;
447    let s = first.strip_prefix("diff --git ")?;
448    let idx = s.find(" b/")?;
449    Some(s[idx + 1..].strip_prefix("b/").unwrap_or("").to_string())
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455
456    #[test]
457    fn porcelain_parses_codes_and_paths() {
458        // NUL-delimited records; the path with a space stays raw (no quoting).
459        let got = parse_porcelain(" M src/lib.rs\0?? new file.txt\0A  added.rs\0");
460        assert_eq!(
461            got,
462            vec![
463                StatusEntry {
464                    code: " M".into(),
465                    path: "src/lib.rs".into(),
466                    orig_path: None,
467                },
468                StatusEntry {
469                    code: "??".into(),
470                    path: "new file.txt".into(),
471                    orig_path: None,
472                },
473                StatusEntry {
474                    code: "A ".into(),
475                    path: "added.rs".into(),
476                    orig_path: None,
477                },
478            ]
479        );
480    }
481
482    #[test]
483    fn porcelain_parses_rename_with_orig_path() {
484        // `R  new\0old\0` — the source path is the next NUL record.
485        let got = parse_porcelain("R  new.rs\0old.rs\0 M other.rs\0");
486        assert_eq!(
487            got,
488            vec![
489                StatusEntry {
490                    code: "R ".into(),
491                    path: "new.rs".into(),
492                    orig_path: Some("old.rs".into()),
493                },
494                StatusEntry {
495                    code: " M".into(),
496                    path: "other.rs".into(),
497                    orig_path: None,
498                },
499            ]
500        );
501    }
502
503    #[test]
504    fn porcelain_ignores_blank_and_short_records() {
505        assert!(parse_porcelain("\0  \0X\0").is_empty());
506    }
507
508    #[test]
509    fn log_splits_unit_separated_fields() {
510        let input = "abc123\u{1f}abc\u{1f}Ada\u{1f}2026-05-31T10:00:00+00:00\u{1f}Add feature\0\
511                     def456\u{1f}def\u{1f}Linus\u{1f}2026-05-30T09:00:00+00:00\u{1f}Fix bug\0";
512        let got = parse_log(input);
513        assert_eq!(got.len(), 2);
514        assert_eq!(
515            got[0],
516            Commit {
517                hash: "abc123".into(),
518                short_hash: "abc".into(),
519                author: "Ada".into(),
520                date: "2026-05-31T10:00:00+00:00".into(),
521                subject: "Add feature".into(),
522            }
523        );
524        assert_eq!(got[1].subject, "Fix bug");
525    }
526
527    #[test]
528    fn log_tolerates_empty_subject() {
529        let got = parse_log("h\u{1f}h\u{1f}A\u{1f}2026-05-31T10:00:00+00:00\u{1f}\0");
530        assert_eq!(got[0].subject, "");
531    }
532
533    #[test]
534    fn branches_marks_current_and_skips_detached() {
535        let got = parse_branches("* main\n  feature\n  (HEAD detached at abc123)\n");
536        assert_eq!(
537            got,
538            vec![
539                Branch {
540                    name: "main".into(),
541                    current: true
542                },
543                Branch {
544                    name: "feature".into(),
545                    current: false
546                },
547            ]
548        );
549    }
550
551    #[test]
552    fn worktrees_parse_branch_detached_and_bare() {
553        let input = "worktree /repo\nHEAD abc123\nbranch refs/heads/main\n\
554                     \nworktree /repo/wt\nHEAD def456\ndetached\n\
555                     \nworktree /repo/bare\nbare\n";
556        let got = parse_worktree_porcelain(input);
557        assert_eq!(got.len(), 3);
558        assert_eq!(got[0].path, PathBuf::from("/repo"));
559        assert_eq!(got[0].branch.as_deref(), Some("main"));
560        assert_eq!(got[0].head.as_deref(), Some("abc123"));
561        assert!(got[1].detached && got[1].branch.is_none());
562        assert!(got[2].bare && got[2].head.is_none());
563    }
564
565    #[test]
566    fn worktrees_parse_last_record_without_trailing_blank() {
567        // The final record may not be followed by a blank line.
568        let got = parse_worktree_porcelain("worktree /only\nHEAD aaa\nbranch refs/heads/x\n");
569        assert_eq!(got.len(), 1);
570        assert_eq!(got[0].branch.as_deref(), Some("x"));
571    }
572
573    #[test]
574    fn shortstat_parses_all_clauses() {
575        let got = parse_shortstat(" 3 files changed, 12 insertions(+), 4 deletions(-)\n");
576        assert_eq!(
577            got,
578            DiffStat {
579                files_changed: 3,
580                insertions: 12,
581                deletions: 4
582            }
583        );
584    }
585
586    #[test]
587    fn shortstat_tolerates_missing_clauses_and_empty() {
588        // Pure-insertion diff omits deletions; no changes yields all zeros.
589        let only_ins = parse_shortstat(" 1 file changed, 2 insertions(+)\n");
590        assert_eq!(only_ins.insertions, 2);
591        assert_eq!(only_ins.deletions, 0);
592        assert_eq!(parse_shortstat(""), DiffStat::default());
593    }
594
595    #[test]
596    fn diff_covers_add_modify_delete_rename() {
597        // Add (new), modify (mod), delete (gone), and a directory-changing rename
598        // (old/f -> new/f). Ported from the vcs-flow section-parser test.
599        let full = concat!(
600            "diff --git a/new b/new\n",
601            "new file mode 100644\n--- /dev/null\n+++ b/new\n@@ -0,0 +1 @@\n+n\n",
602            "diff --git a/mod b/mod\n",
603            "--- a/mod\n+++ b/mod\n@@ -1 +1 @@\n-a\n+b\n",
604            "diff --git a/gone b/gone\n",
605            "deleted file mode 100644\n--- a/gone\n+++ /dev/null\n@@ -1 +0,0 @@\n-x\n",
606            "diff --git a/old/f.txt b/new/f.txt\n",
607            "similarity index 100%\nrename from old/f.txt\nrename to new/f.txt\n",
608        );
609        let files = parse_diff(full);
610        let kinds: Vec<_> = files.iter().map(|f| (f.path.as_str(), f.change)).collect();
611        assert_eq!(
612            kinds,
613            vec![
614                ("new", ChangeKind::Added),
615                ("mod", ChangeKind::Modified),
616                ("gone", ChangeKind::Deleted),
617                ("new/f.txt", ChangeKind::Renamed),
618            ]
619        );
620        // The rename carries its old path so the deletion is recorded too.
621        let rename = files
622            .iter()
623            .find(|f| f.change == ChangeKind::Renamed)
624            .unwrap();
625        assert_eq!(rename.old_path.as_deref(), Some("old/f.txt"));
626    }
627
628    #[test]
629    fn diff_handles_space_paths() {
630        // git appends a trailing tab to `+++`/`---` paths containing spaces; the
631        // path must survive intact (the `diff --git` header is ambiguous here).
632        let full = "diff --git a/a b/c.txt b/a b/c.txt\n--- a/a b/c.txt\t\n+++ b/a b/c.txt\t\n@@ -1 +1 @@\n-x\n+y\n";
633        let files = parse_diff(full);
634        assert_eq!(files.len(), 1);
635        assert_eq!(files[0].path, "a b/c.txt");
636    }
637
638    #[test]
639    fn diff_parses_hunk_ranges_and_body() {
640        let full = "diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -1,2 +1,3 @@ fn main()\n ctx\n-old\n+new\n+added\n";
641        let files = parse_diff(full);
642        assert_eq!(files.len(), 1);
643        // The verbatim section is preserved for display.
644        assert_eq!(files[0].raw, full);
645        let hunk = &files[0].hunks[0];
646        assert_eq!(
647            (
648                hunk.old_start,
649                hunk.old_lines,
650                hunk.new_start,
651                hunk.new_lines
652            ),
653            (1, 2, 1, 3)
654        );
655        assert_eq!(hunk.section, "fn main()");
656        assert_eq!(
657            hunk.lines,
658            vec![
659                DiffLine::Context("ctx".into()),
660                DiffLine::Removed("old".into()),
661                DiffLine::Added("new".into()),
662                DiffLine::Added("added".into()),
663            ]
664        );
665    }
666
667    #[test]
668    fn diff_omitted_count_defaults_to_one() {
669        // `@@ -3 +3 @@` (no `,count`) means a single line on each side.
670        let full = "diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -3 +3 @@\n-a\n+b\n";
671        let hunk = &parse_diff(full)[0].hunks[0];
672        assert_eq!((hunk.old_start, hunk.old_lines), (3, 1));
673        assert_eq!((hunk.new_start, hunk.new_lines), (3, 1));
674    }
675}