Skip to main content

xei_core/
git_ops.rs

1//! Shared git CLI helpers for SCM + Git workbench.
2
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6#[derive(Debug, Clone)]
7pub struct BranchInfo {
8    pub name: String,
9    pub current: bool,
10    pub remote: bool,
11    /// ahead/behind vs upstream when known
12    pub upstream: Option<String>,
13}
14
15#[derive(Debug, Clone)]
16pub struct DiffLine {
17    pub kind: DiffLineKind,
18    /// Full raw line from `git diff` (includes leading `+`/`-`/` ` when applicable).
19    pub text: String,
20    /// Old-file line number (left gutter). `None` for headers / pure adds.
21    pub old_no: Option<u32>,
22    /// New-file line number (right gutter). `None` for headers / pure deletes.
23    pub new_no: Option<u32>,
24}
25
26impl DiffLine {
27    pub fn new(kind: DiffLineKind, text: impl Into<String>) -> Self {
28        Self {
29            kind,
30            text: text.into(),
31            old_no: None,
32            new_no: None,
33        }
34    }
35
36    /// Content without the leading diff marker (`+`/`-`/` `).
37    pub fn content(&self) -> &str {
38        match self.kind {
39            DiffLineKind::Add | DiffLineKind::Del | DiffLineKind::Context => {
40                self.text.get(1..).unwrap_or(self.text.as_str())
41            }
42            _ => self.text.as_str(),
43        }
44    }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum DiffLineKind {
49    Header,
50    Hunk,
51    Add,
52    Del,
53    Context,
54    Meta,
55}
56
57pub fn find_git_root(hint: Option<&Path>) -> Option<PathBuf> {
58    let start = hint
59        .and_then(|p| {
60            if p.is_file() {
61                p.parent().map(|x| x.to_path_buf())
62            } else {
63                Some(p.to_path_buf())
64            }
65        })
66        .or_else(|| std::env::current_dir().ok())?;
67
68    let mut cur = start;
69    for _ in 0..24 {
70        if cur.join(".git").exists() {
71            return Some(cur);
72        }
73        if !cur.pop() {
74            break;
75        }
76    }
77    None
78}
79
80pub fn run_git(root: &Path, args: &[&str]) -> Result<String, String> {
81    let output = Command::new("git")
82        .args(args)
83        .current_dir(root)
84        .output()
85        .map_err(|e| format!("git failed to start: {e}"))?;
86    if !output.status.success() {
87        let err = String::from_utf8_lossy(&output.stderr);
88        let err = err.trim();
89        if err.is_empty() {
90            return Err(format!("git {} failed", args.first().unwrap_or(&"")));
91        }
92        return Err(err.lines().next().unwrap_or("git error").to_string());
93    }
94    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
95}
96
97/// Best-effort; stderr on failure still returned as Err.
98pub fn run_git_ok(root: &Path, args: &[&str]) -> Result<String, String> {
99    run_git(root, args)
100}
101
102pub fn list_branches(root: &Path) -> Result<Vec<BranchInfo>, String> {
103    // Local branches
104    let local = run_git(
105        root,
106        &[
107            "for-each-ref",
108            "--format=%(refname:short)%00%(upstream:short)%00%(HEAD)",
109            "refs/heads",
110        ],
111    )?;
112    let mut out = Vec::new();
113    for line in local.lines() {
114        if line.is_empty() {
115            continue;
116        }
117        let parts: Vec<&str> = line.split('\0').collect();
118        let name = parts.first().copied().unwrap_or("").to_string();
119        if name.is_empty() {
120            continue;
121        }
122        let upstream = parts
123            .get(1)
124            .map(|s| s.trim())
125            .filter(|s| !s.is_empty())
126            .map(|s| s.to_string());
127        let current = parts.get(2).map(|s| *s == "*").unwrap_or(false);
128        out.push(BranchInfo {
129            name,
130            current,
131            remote: false,
132            upstream,
133        });
134    }
135
136    // Remote branches (no current)
137    if let Ok(remote) = run_git(
138        root,
139        &[
140            "for-each-ref",
141            "--format=%(refname:short)",
142            "refs/remotes",
143        ],
144    ) {
145        for line in remote.lines() {
146            let name = line.trim();
147            if name.is_empty() || name.ends_with("/HEAD") {
148                continue;
149            }
150            // skip if already have local with same short name? keep remotes as remote/foo
151            if out.iter().any(|b| b.name == name) {
152                continue;
153            }
154            out.push(BranchInfo {
155                name: name.to_string(),
156                current: false,
157                remote: true,
158                upstream: None,
159            });
160        }
161    }
162
163    // Current first, then local, then remote
164    out.sort_by(|a, b| {
165        b.current
166            .cmp(&a.current)
167            .then_with(|| a.remote.cmp(&b.remote))
168            .then_with(|| a.name.cmp(&b.name))
169    });
170    Ok(out)
171}
172
173pub fn checkout_branch(root: &Path, name: &str) -> Result<String, String> {
174    // Remote-only: checkout -b local --track remote
175    if name.contains('/') && name.starts_with("origin/") {
176        let local = name.trim_start_matches("origin/");
177        // try create tracking branch
178        match run_git(root, &["checkout", "-B", local, "--track", name]) {
179            Ok(o) => return Ok(o.lines().next().unwrap_or("Checked out").to_string()),
180            Err(_) => {
181                // already exists
182                return run_git(root, &["checkout", local])
183                    .map(|o| o.lines().next().unwrap_or("Checked out").to_string());
184            }
185        }
186    }
187    run_git(root, &["checkout", name])
188        .map(|o| o.lines().next().unwrap_or("Checked out").to_string())
189}
190
191pub fn create_branch(root: &Path, name: &str) -> Result<String, String> {
192    run_git(root, &["checkout", "-b", name])
193        .map(|o| o.lines().next().unwrap_or("Created branch").to_string())
194}
195
196pub fn delete_branch(root: &Path, name: &str, force: bool) -> Result<String, String> {
197    let flag = if force { "-D" } else { "-d" };
198    run_git(root, &["branch", flag, name])
199        .map(|_| format!("Deleted branch {name}"))
200}
201
202pub fn stage_all(root: &Path) -> Result<String, String> {
203    run_git(root, &["add", "-A"]).map(|_| "Staged all changes".into())
204}
205
206pub fn unstage_all(root: &Path) -> Result<String, String> {
207    run_git(root, &["restore", "--staged", "."]).map(|_| "Unstaged all".into())
208}
209
210pub fn discard_file(root: &Path, path: &str) -> Result<String, String> {
211    // Untracked: remove file; tracked: restore from HEAD
212    let status = run_git(root, &["status", "--porcelain", "--", path])?;
213    let line = status.lines().next().unwrap_or("");
214    if line.starts_with("??") {
215        let p = root.join(path);
216        if p.is_file() {
217            std::fs::remove_file(&p).map_err(|e| e.to_string())?;
218        } else if p.is_dir() {
219            std::fs::remove_dir_all(&p).map_err(|e| e.to_string())?;
220        }
221        return Ok(format!("Removed untracked {path}"));
222    }
223    // unstage then restore worktree
224    let _ = run_git(root, &["restore", "--staged", "--", path]);
225    run_git(root, &["restore", "--", path]).map(|_| format!("Discarded {path}"))
226}
227
228pub fn cherry_pick(root: &Path, hash: &str) -> Result<String, String> {
229    run_git(root, &["cherry-pick", hash]).map(|o| {
230        o.lines()
231            .next()
232            .unwrap_or("Cherry-picked")
233            .to_string()
234    })
235}
236
237pub fn revert_commit(root: &Path, hash: &str) -> Result<String, String> {
238    run_git(root, &["revert", "--no-edit", hash]).map(|o| {
239        o.lines()
240            .next()
241            .unwrap_or("Reverted")
242            .to_string()
243    })
244}
245
246pub fn pull_rebase(root: &Path) -> Result<String, String> {
247    run_git(root, &["pull", "--rebase"]).map(|o| {
248        let t = o.trim();
249        if t.is_empty() {
250            "Pulled (rebase)".into()
251        } else {
252            t.lines().next().unwrap_or("Pulled (rebase)").to_string()
253        }
254    })
255}
256
257pub fn stash_list(root: &Path) -> Result<Vec<String>, String> {
258    let out = run_git(root, &["stash", "list"])?;
259    Ok(out
260        .lines()
261        .filter(|l| !l.is_empty())
262        .map(|s| s.to_string())
263        .collect())
264}
265
266pub fn remotes(root: &Path) -> Result<Vec<(String, String)>, String> {
267    let out = run_git(root, &["remote", "-v"])?;
268    let mut v = Vec::new();
269    for line in out.lines() {
270        let mut parts = line.split_whitespace();
271        let name = parts.next().unwrap_or("").to_string();
272        let url = parts.next().unwrap_or("").to_string();
273        let kind = parts.next().unwrap_or("");
274        if kind.contains("fetch") && !name.is_empty() {
275            v.push((name, url));
276        }
277    }
278    Ok(v)
279}
280
281pub fn log_file(root: &Path, path: &str, limit: usize) -> Result<Vec<CommitSummary>, String> {
282    let n = limit.clamp(5, 100).to_string();
283    let out = run_git(
284        root,
285        &[
286            "log",
287            "-n",
288            &n,
289            "--pretty=format:%H%x00%h%x00%s%x00%an%x00%ae%x00%ar%x00%P",
290            "--",
291            path,
292        ],
293    )?;
294    let mut commits = Vec::new();
295    for line in out.lines() {
296        if line.is_empty() {
297            continue;
298        }
299        let p: Vec<&str> = line.split('\0').collect();
300        if p.len() < 6 {
301            continue;
302        }
303        commits.push(CommitSummary {
304            hash: p[0].to_string(),
305            short: p[1].to_string(),
306            subject: p[2].to_string(),
307            author: p[3].to_string(),
308            email: p[4].to_string(),
309            when: p[5].to_string(),
310            parents: p
311                .get(6)
312                .unwrap_or(&"")
313                .split_whitespace()
314                .filter(|s| !s.is_empty())
315                .map(|s| s.to_string())
316                .collect(),
317        });
318    }
319    Ok(commits)
320}
321
322pub fn fetch(root: &Path) -> Result<String, String> {
323    run_git(root, &["fetch", "--all", "--prune"])
324        .map(|_| "Fetched".into())
325}
326
327pub fn pull(root: &Path) -> Result<String, String> {
328    run_git(root, &["pull", "--ff-only"])
329        .or_else(|_| run_git(root, &["pull"]))
330        .map(|o| {
331            let t = o.trim();
332            if t.is_empty() {
333                "Pulled".into()
334            } else {
335                t.lines().next().unwrap_or("Pulled").to_string()
336            }
337        })
338}
339
340pub fn push(root: &Path) -> Result<String, String> {
341    run_git(root, &["push"]).map(|o| {
342        let t = o.trim();
343        if t.is_empty() {
344            // push often writes to stderr even on success — try -u
345            "Pushed".into()
346        } else {
347            t.lines().next().unwrap_or("Pushed").to_string()
348        }
349    }).or_else(|e| {
350        // first push may need -u
351        if e.contains("no upstream") || e.contains("has no upstream") {
352            run_git(root, &["push", "-u", "origin", "HEAD"]).map(|_| "Pushed (set upstream)".into())
353        } else {
354            // git push writes progress to stderr; re-run capturing both
355            let output = Command::new("git")
356                .args(["push"])
357                .current_dir(root)
358                .output()
359                .map_err(|err| format!("git push: {err}"))?;
360            if output.status.success() {
361                Ok("Pushed".into())
362            } else {
363                let err = String::from_utf8_lossy(&output.stderr);
364                Err(err.lines().next().unwrap_or("push failed").to_string())
365            }
366        }
367    })
368}
369
370pub fn file_diff(root: &Path, path: &str, staged: bool) -> Result<Vec<DiffLine>, String> {
371    let args: Vec<&str> = if staged {
372        vec!["diff", "--no-color", "--cached", "--", path]
373    } else {
374        vec!["diff", "--no-color", "HEAD", "--", path]
375    };
376    // Untracked: no HEAD diff — show as all adds via /dev/null
377    let out = match run_git(root, &args) {
378        Ok(o) if !o.trim().is_empty() => o,
379        _ => {
380            // try unstaged only
381            let o2 = run_git(root, &["diff", "--no-color", "--", path]).unwrap_or_default();
382            if o2.trim().is_empty() {
383                // untracked file
384                let full = root.join(path);
385                if full.is_file() {
386                    let content = std::fs::read_to_string(&full).unwrap_or_default();
387                    let mut lines = vec![DiffLine::new(
388                        DiffLineKind::Header,
389                        format!("diff -- untracked a/{path} b/{path}"),
390                    )];
391                    let mut n = 1u32;
392                    for l in content.lines() {
393                        lines.push(DiffLine {
394                            kind: DiffLineKind::Add,
395                            text: format!("+{l}"),
396                            old_no: None,
397                            new_no: Some(n),
398                        });
399                        n += 1;
400                    }
401                    if lines.len() == 1 {
402                        lines.push(DiffLine::new(DiffLineKind::Meta, "(empty file)"));
403                    }
404                    return Ok(lines);
405                }
406                return Ok(vec![DiffLine::new(DiffLineKind::Meta, "No diff")]);
407            }
408            o2
409        }
410    };
411    Ok(parse_diff(&out))
412}
413
414/// Parse unified diff text and attach old/new line numbers from hunk headers.
415pub fn parse_diff(text: &str) -> Vec<DiffLine> {
416    let mut out = Vec::new();
417    let mut old_ln: u32 = 0;
418    let mut new_ln: u32 = 0;
419    for line in text.lines() {
420        if line.starts_with("diff ") || line.starts_with("index ") {
421            out.push(DiffLine::new(DiffLineKind::Header, line));
422        } else if line.starts_with("@@") {
423            // @@ -old_start,old_count +new_start,new_count @@
424            if let Some((o, n)) = parse_hunk_starts(line) {
425                old_ln = o;
426                new_ln = n;
427            }
428            out.push(DiffLine::new(DiffLineKind::Hunk, line));
429        } else if line.starts_with('+') && !line.starts_with("+++") {
430            out.push(DiffLine {
431                kind: DiffLineKind::Add,
432                text: line.to_string(),
433                old_no: None,
434                new_no: Some(new_ln),
435            });
436            new_ln = new_ln.saturating_add(1);
437        } else if line.starts_with('-') && !line.starts_with("---") {
438            out.push(DiffLine {
439                kind: DiffLineKind::Del,
440                text: line.to_string(),
441                old_no: Some(old_ln),
442                new_no: None,
443            });
444            old_ln = old_ln.saturating_add(1);
445        } else if line.starts_with("+++") || line.starts_with("---") {
446            out.push(DiffLine::new(DiffLineKind::Meta, line));
447        } else if line.starts_with(' ') || (line.is_empty() && (old_ln > 0 || new_ln > 0)) {
448            // Context line (leading space) or blank context after a hunk.
449            let old_no = if old_ln > 0 { Some(old_ln) } else { None };
450            let new_no = if new_ln > 0 { Some(new_ln) } else { None };
451            if old_ln > 0 {
452                old_ln = old_ln.saturating_add(1);
453            }
454            if new_ln > 0 {
455                new_ln = new_ln.saturating_add(1);
456            }
457            out.push(DiffLine {
458                kind: DiffLineKind::Context,
459                text: if line.is_empty() {
460                    " ".into()
461                } else {
462                    line.to_string()
463                },
464                old_no,
465                new_no,
466            });
467        } else {
468            out.push(DiffLine::new(DiffLineKind::Meta, line));
469        }
470    }
471    if out.is_empty() {
472        out.push(DiffLine::new(DiffLineKind::Meta, "No changes"));
473    }
474    out
475}
476
477/// Extract old/new start line numbers from a `@@ -a,b +c,d @@` header.
478fn parse_hunk_starts(hunk: &str) -> Option<(u32, u32)> {
479    // Find "-N" and "+M"
480    let mut old = None;
481    let mut new = None;
482    for part in hunk.split_whitespace() {
483        if let Some(rest) = part.strip_prefix('-') {
484            let num = rest.split(',').next()?.parse::<u32>().ok()?;
485            old = Some(num.max(1));
486        } else if let Some(rest) = part.strip_prefix('+') {
487            if rest.starts_with('+') {
488                continue; // +++
489            }
490            let num = rest.split(',').next()?.parse::<u32>().ok()?;
491            new = Some(num.max(1));
492        }
493    }
494    Some((old?, new?))
495}
496
497pub fn stash_push(root: &Path) -> Result<String, String> {
498    run_git(root, &["stash", "push", "-u"]).map(|o| {
499        o.lines()
500            .next()
501            .unwrap_or("Stashed")
502            .to_string()
503    })
504}
505
506pub fn stash_pop(root: &Path) -> Result<String, String> {
507    run_git(root, &["stash", "pop"]).map(|o| {
508        o.lines()
509            .next()
510            .unwrap_or("Stash applied")
511            .to_string()
512    })
513}
514
515pub fn stash_apply(root: &Path, index: usize) -> Result<String, String> {
516    let refname = format!("stash@{{{index}}}");
517    run_git(root, &["stash", "apply", &refname]).map(|o| {
518        o.lines()
519            .next()
520            .unwrap_or("Stash applied")
521            .to_string()
522    })
523}
524
525pub fn stash_drop(root: &Path, index: usize) -> Result<String, String> {
526    let refname = format!("stash@{{{index}}}");
527    run_git(root, &["stash", "drop", &refname]).map(|o| {
528        o.lines()
529            .next()
530            .unwrap_or("Stash dropped")
531            .to_string()
532    })
533}
534
535pub fn stash_show(root: &Path, index: usize) -> Result<String, String> {
536    let refname = format!("stash@{{{index}}}");
537    run_git(root, &["stash", "show", "-p", "--stat", &refname])
538}
539
540pub fn current_branch(root: &Path) -> String {
541    run_git(root, &["branch", "--show-current"])
542        .map(|s| s.trim().to_string())
543        .unwrap_or_default()
544}
545
546// ── Commit history (GitHub-style) ───────────────────────
547
548#[derive(Debug, Clone)]
549pub struct CommitSummary {
550    pub hash: String,
551    pub short: String,
552    pub subject: String,
553    pub author: String,
554    pub email: String,
555    pub when: String,
556    pub parents: Vec<String>,
557}
558
559#[derive(Debug, Clone)]
560pub struct CommitFileChange {
561    pub path: String,
562    /// A/M/D/R/C/T/?
563    pub status: char,
564    pub insertions: u32,
565    pub deletions: u32,
566}
567
568#[derive(Debug, Clone)]
569pub struct CommitDetail {
570    pub hash: String,
571    pub short: String,
572    pub subject: String,
573    /// Full body (may be empty)
574    pub body: String,
575    pub author: String,
576    pub email: String,
577    pub date: String,
578    pub files: Vec<CommitFileChange>,
579    pub insertions: u32,
580    pub deletions: u32,
581}
582
583/// Newest-first commit list (`git log --all` when `all` is true).
584pub fn list_commits(root: &Path, limit: usize, all: bool) -> Result<Vec<CommitSummary>, String> {
585    let n = limit.clamp(20, 2000).to_string();
586    let mut args = vec![
587        "log",
588        "-n",
589        n.as_str(),
590        "--pretty=format:%H%x00%h%x00%s%x00%an%x00%ae%x00%ar%x00%P",
591    ];
592    if all {
593        args.insert(1, "--all");
594    }
595    let out = run_git(root, &args)?;
596    let mut commits = Vec::new();
597    for line in out.lines() {
598        if line.is_empty() {
599            continue;
600        }
601        let p: Vec<&str> = line.split('\0').collect();
602        if p.len() < 6 {
603            continue;
604        }
605        let parents = p
606            .get(6)
607            .unwrap_or(&"")
608            .split_whitespace()
609            .filter(|s| !s.is_empty())
610            .map(|s| s.to_string())
611            .collect();
612        commits.push(CommitSummary {
613            hash: p[0].to_string(),
614            short: p[1].to_string(),
615            subject: p[2].to_string(),
616            author: p[3].to_string(),
617            email: p[4].to_string(),
618            when: p[5].to_string(),
619            parents,
620        });
621    }
622    Ok(commits)
623}
624
625/// Message + file list + numstat for one commit (GitHub commit page).
626pub fn commit_detail(root: &Path, hash: &str) -> Result<CommitDetail, String> {
627    // Metadata: subject, body, author, date
628    let meta = run_git(
629        root,
630        &[
631            "show",
632            "-s",
633            "--format=%H%x00%h%x00%s%x00%b%x00%an%x00%ae%x00%aI",
634            hash,
635        ],
636    )?;
637    // git show -s with body can be multi-line; use null-separated carefully.
638    // Body may contain newlines but not NULs. Format uses %x00 between fields;
639    // body is field 3 and can have newlines until next field... actually
640    // pretty format with %b then %x00 is tricky with newlines.
641    // Safer: separate calls.
642    let head = run_git(
643        root,
644        &[
645            "show",
646            "-s",
647            "--format=%H%x00%h%x00%s%x00%an%x00%ae%x00%aI%x00%P",
648            hash,
649        ],
650    )?;
651    let line = head.lines().next().unwrap_or("");
652    let p: Vec<&str> = line.split('\0').collect();
653    let full = p.first().unwrap_or(&hash).to_string();
654    let short = p.get(1).unwrap_or(&"").to_string();
655    let subject = p.get(2).unwrap_or(&"").to_string();
656    let author = p.get(3).unwrap_or(&"").to_string();
657    let email = p.get(4).unwrap_or(&"").to_string();
658    let date = p.get(5).unwrap_or(&"").to_string();
659
660    let body = run_git(root, &["log", "-1", "--format=%b", hash])
661        .map(|s| s.trim_end().to_string())
662        .unwrap_or_default();
663    let _ = meta;
664
665    // name-status
666    let ns = run_git(root, &["show", "--name-status", "--format=", hash]).unwrap_or_default();
667    // numstat
668    let num = run_git(root, &["show", "--numstat", "--format=", hash]).unwrap_or_default();
669
670    let mut stats: std::collections::HashMap<String, (u32, u32)> = std::collections::HashMap::new();
671    for line in num.lines() {
672        let line = line.trim();
673        if line.is_empty() {
674            continue;
675        }
676        let parts: Vec<&str> = line.split('\t').collect();
677        if parts.len() < 3 {
678            continue;
679        }
680        let ins: u32 = parts[0].parse().unwrap_or(0);
681        let del: u32 = parts[1].parse().unwrap_or(0);
682        let path = parts[2].to_string();
683        // renames: old => new
684        let path = if let Some((_, n)) = path.split_once(" => ") {
685            n.to_string()
686        } else {
687            path
688        };
689        stats.insert(path, (ins, del));
690    }
691
692    let mut files = Vec::new();
693    let mut total_ins = 0u32;
694    let mut total_del = 0u32;
695    for line in ns.lines() {
696        let line = line.trim();
697        if line.is_empty() {
698            continue;
699        }
700        let mut parts = line.splitn(2, char::is_whitespace);
701        let st = parts.next().unwrap_or("M");
702        let path_raw = parts.next().unwrap_or("").trim();
703        if path_raw.is_empty() {
704            continue;
705        }
706        let status = st.chars().next().unwrap_or('M');
707        let path = if let Some((_, n)) = path_raw.split_once(" => ") {
708            n.to_string()
709        } else if let Some((_, n)) = path_raw.split_once('\t') {
710            // R100\told\tnew
711            n.to_string()
712        } else {
713            path_raw.to_string()
714        };
715        // name-status for rename: R100\told\tnew
716        let path = {
717            let bits: Vec<&str> = line.split('\t').collect();
718            if bits.len() >= 3 {
719                bits[2].to_string()
720            } else if bits.len() == 2 {
721                bits[1].to_string()
722            } else {
723                path
724            }
725        };
726        let (ins, del) = stats.get(&path).copied().unwrap_or((0, 0));
727        total_ins += ins;
728        total_del += del;
729        files.push(CommitFileChange {
730            path,
731            status,
732            insertions: ins,
733            deletions: del,
734        });
735    }
736
737    Ok(CommitDetail {
738        hash: full,
739        short,
740        subject,
741        body,
742        author,
743        email,
744        date,
745        files,
746        insertions: total_ins,
747        deletions: total_del,
748    })
749}
750
751/// Diff of one file in a commit vs its first parent.
752pub fn commit_file_diff(root: &Path, hash: &str, path: &str) -> Result<Vec<DiffLine>, String> {
753    let out = run_git(
754        root,
755        &["show", "--no-color", "--format=", hash, "--", path],
756    )?;
757    if out.trim().is_empty() {
758        return Ok(vec![DiffLine::new(
759            DiffLineKind::Meta,
760            "No diff for this file",
761        )]);
762    }
763    Ok(parse_diff(&out))
764}
765
766#[cfg(test)]
767mod tests {
768    use super::*;
769
770    #[test]
771    fn parse_diff_kinds() {
772        let d = parse_diff(
773            "diff --git a/x b/x\n--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new\n context\n",
774        );
775        assert!(d.iter().any(|l| l.kind == DiffLineKind::Add));
776        assert!(d.iter().any(|l| l.kind == DiffLineKind::Del));
777        assert!(d.iter().any(|l| l.kind == DiffLineKind::Hunk));
778    }
779
780    #[test]
781    fn parse_diff_line_numbers() {
782        let d = parse_diff(
783            "@@ -10,3 +20,4 @@ fn foo\n context a\n-removed\n+added1\n+added2\n context b\n",
784        );
785        let del = d.iter().find(|l| l.kind == DiffLineKind::Del).unwrap();
786        assert_eq!(del.old_no, Some(11));
787        assert_eq!(del.new_no, None);
788        let adds: Vec<_> = d.iter().filter(|l| l.kind == DiffLineKind::Add).collect();
789        assert_eq!(adds[0].new_no, Some(21));
790        assert_eq!(adds[1].new_no, Some(22));
791        let ctx: Vec<_> = d
792            .iter()
793            .filter(|l| l.kind == DiffLineKind::Context)
794            .collect();
795        assert_eq!(ctx[0].old_no, Some(10));
796        assert_eq!(ctx[0].new_no, Some(20));
797    }
798}