Skip to main content

vix_git/
lib.rs

1//! Git integration for Vix.
2//!
3//! Two layers:
4//!
5//! - **Pure logic** (unit-tested, no I/O): [`parse_status`] turns
6//!   `git status --porcelain` output into [`FileStatus`] rows, and [`diff_marks`]
7//!   computes per-line editor-gutter [`LineMark`]s between the committed text and
8//!   the current buffer (via the `similar` crate).
9//! - **Runners** (shell out to the `git` CLI): [`is_repo`], [`branch`],
10//!   [`status`], [`head_blob`], and the staging/commit helpers. Using the user's
11//!   own `git` means credential helpers, SSH agents, and hooks all behave exactly
12//!   as on the command line.
13
14#![forbid(unsafe_code)]
15#![deny(missing_docs)]
16#![warn(clippy::pedantic)]
17
18use std::path::Path;
19use std::process::Command;
20
21/// A kind of change to a file (in the index or the working tree).
22#[derive(Clone, Copy, PartialEq, Eq, Debug)]
23pub enum Change {
24    /// Newly tracked (staged add).
25    Added,
26    /// Contents changed.
27    Modified,
28    /// Removed.
29    Deleted,
30    /// Renamed.
31    Renamed,
32    /// Not tracked by git.
33    Untracked,
34    /// Unmerged / conflicted.
35    Conflicted,
36}
37
38impl Change {
39    /// A single-letter code for compact UI (e.g. file-explorer badges).
40    #[must_use]
41    pub fn letter(self) -> char {
42        match self {
43            Change::Added => 'A',
44            Change::Modified => 'M',
45            Change::Deleted => 'D',
46            Change::Renamed => 'R',
47            Change::Untracked => '?',
48            Change::Conflicted => 'U',
49        }
50    }
51
52    fn from_code(c: char) -> Option<Change> {
53        match c {
54            'A' => Some(Change::Added),
55            // 'C' (copied) is treated like a modification.
56            'M' | 'C' => Some(Change::Modified),
57            'D' => Some(Change::Deleted),
58            'R' => Some(Change::Renamed),
59            '?' => Some(Change::Untracked),
60            'U' => Some(Change::Conflicted),
61            _ => None,
62        }
63    }
64}
65
66/// One changed path from `git status`, with its staged (index) and unstaged
67/// (working-tree) change, if any.
68#[derive(Clone, PartialEq, Eq, Debug)]
69pub struct FileStatus {
70    /// Path relative to the repository root.
71    pub path: String,
72    /// The staged (index) change, if any.
73    pub staged: Option<Change>,
74    /// The unstaged (working-tree) change, if any.
75    pub unstaged: Option<Change>,
76}
77
78impl FileStatus {
79    /// Whether the path has staged changes.
80    #[must_use]
81    pub fn is_staged(&self) -> bool {
82        self.staged.is_some()
83    }
84
85    /// The most representative change for a one-letter badge: the staged change
86    /// if present, else the unstaged one.
87    #[must_use]
88    pub fn primary(&self) -> Option<Change> {
89        self.staged.or(self.unstaged)
90    }
91}
92
93/// Parse `git status --porcelain` (v1) output into one [`FileStatus`] per path.
94///
95/// Each line is `XY <path>`, where `X` is the index status and `Y` the
96/// working-tree status (`??` for untracked). Rename lines (`R  old -> new`) are
97/// recorded under the new path.
98#[must_use]
99pub fn parse_status(output: &str) -> Vec<FileStatus> {
100    let mut out = Vec::new();
101    for line in output.lines() {
102        if line.len() < 3 {
103            continue;
104        }
105        let bytes = line.as_bytes();
106        let x = bytes[0] as char;
107        let y = bytes[1] as char;
108        let rest = line[3..].trim_end();
109        // Rename/copy show "old -> new"; record the destination.
110        let path = match rest.rsplit_once(" -> ") {
111            Some((_, new)) => new.to_string(),
112            None => rest.to_string(),
113        };
114        if path.is_empty() {
115            continue;
116        }
117        let (staged, unstaged) = if x == '?' && y == '?' {
118            (None, Some(Change::Untracked))
119        } else {
120            (Change::from_code(x), Change::from_code(y))
121        };
122        out.push(FileStatus {
123            path,
124            staged,
125            unstaged,
126        });
127    }
128    out
129}
130
131/// A per-line change marker for the editor's diff gutter.
132#[derive(Clone, Copy, PartialEq, Eq, Debug)]
133pub enum LineMark {
134    /// The line is new since the committed version.
135    Added,
136    /// The line replaces a committed line (content changed).
137    Modified,
138    /// One or more committed lines were removed at this position.
139    Deleted,
140}
141
142/// Compute per-line gutter marks between the committed text (`head`) and the
143/// current buffer (`current`), keyed by **current** line index (0-based).
144///
145/// Uses a line diff: inserted runs are [`LineMark::Added`], replaced runs are
146/// [`LineMark::Modified`], and a deletion records a [`LineMark::Deleted`] at the
147/// current line where the removed text used to be.
148#[must_use]
149pub fn diff_marks(head: &str, current: &str) -> Vec<(usize, LineMark)> {
150    use similar::{Algorithm, DiffOp, TextDiff};
151
152    let diff = TextDiff::configure()
153        .algorithm(Algorithm::Myers)
154        .diff_lines(head, current);
155    let current_lines = current.lines().count();
156    let mut marks = Vec::new();
157    for op in diff.ops() {
158        match *op {
159            DiffOp::Insert {
160                new_index, new_len, ..
161            } => {
162                for i in new_index..new_index + new_len {
163                    marks.push((i, LineMark::Added));
164                }
165            }
166            DiffOp::Replace {
167                new_index, new_len, ..
168            } => {
169                for i in new_index..new_index + new_len {
170                    marks.push((i, LineMark::Modified));
171                }
172            }
173            DiffOp::Delete { new_index, .. } => {
174                let at = new_index.min(current_lines.saturating_sub(1));
175                marks.push((at, LineMark::Deleted));
176            }
177            DiffOp::Equal { .. } => {}
178        }
179    }
180    marks
181}
182
183/// A contiguous changed region between the committed text and the current
184/// buffer, for hunk navigation and revert.
185#[derive(Clone, PartialEq, Eq, Debug)]
186pub struct Hunk {
187    /// First current-buffer line (0-based) the hunk covers.
188    pub current_start: usize,
189    /// One past the last current line the hunk covers. Equal to `current_start`
190    /// for a pure deletion (nothing remains in the current buffer there).
191    pub current_end: usize,
192    /// First HEAD line (0-based) the hunk's committed text begins at — used to
193    /// locate the region when staging the hunk into the index.
194    pub head_start: usize,
195    /// The committed (HEAD) text for this region, with line endings, used to
196    /// restore the hunk on revert. Empty for a pure addition.
197    pub head_text: String,
198}
199
200impl Hunk {
201    /// Whether current line `line` falls within this hunk (a pure deletion is
202    /// matched at its single anchor line).
203    #[must_use]
204    pub fn contains(&self, line: usize) -> bool {
205        if self.current_start == self.current_end {
206            line == self.current_start
207        } else {
208            line >= self.current_start && line < self.current_end
209        }
210    }
211}
212
213/// Group the line diff between committed text (`head`) and the current buffer
214/// into [`Hunk`]s — maximal runs of adjacent changed lines — in current-line
215/// order. Pairs with [`diff_marks`] (which colors individual gutter lines).
216#[must_use]
217pub fn hunks(head: &str, current: &str) -> Vec<Hunk> {
218    use similar::{Algorithm, DiffOp, TextDiff};
219
220    let diff = TextDiff::configure()
221        .algorithm(Algorithm::Myers)
222        .diff_lines(head, current);
223    let head_lines: Vec<&str> = head.split_inclusive('\n').collect();
224    let mut out: Vec<Hunk> = Vec::new();
225    // Accumulated (new_lo, new_hi, old_lo, old_hi) for the run in progress.
226    let mut run: Option<(usize, usize, usize, usize)> = None;
227
228    let flush = |run: &mut Option<(usize, usize, usize, usize)>, out: &mut Vec<Hunk>| {
229        if let Some((nlo, nhi, olo, ohi)) = run.take() {
230            let head_text = head_lines
231                .get(olo..ohi)
232                .map(<[&str]>::concat)
233                .unwrap_or_default();
234            out.push(Hunk {
235                current_start: nlo,
236                current_end: nhi,
237                head_start: olo,
238                head_text,
239            });
240        }
241    };
242
243    for op in diff.ops() {
244        let (ni, nl, oi, ol) = match *op {
245            DiffOp::Equal { .. } => {
246                flush(&mut run, &mut out);
247                continue;
248            }
249            DiffOp::Insert {
250                new_index,
251                new_len,
252                old_index,
253                ..
254            } => (new_index, new_len, old_index, 0),
255            DiffOp::Delete {
256                new_index,
257                old_index,
258                old_len,
259                ..
260            } => (new_index, 0, old_index, old_len),
261            DiffOp::Replace {
262                new_index,
263                new_len,
264                old_index,
265                old_len,
266                ..
267            } => (new_index, new_len, old_index, old_len),
268        };
269        run = Some(match run {
270            Some((nlo, nhi, olo, ohi)) => {
271                (nlo.min(ni), nhi.max(ni + nl), olo.min(oi), ohi.max(oi + ol))
272            }
273            None => (ni, ni + nl, oi, oi + ol),
274        });
275    }
276    flush(&mut run, &mut out);
277    out
278}
279
280/// One line's `git blame` attribution: the short commit hash, author name,
281/// `YYYY-MM-DD` authored date, and the commit summary.
282#[derive(Clone, PartialEq, Eq, Debug, Default)]
283pub struct BlameLine {
284    /// Abbreviated commit hash (8 hex chars), or all-zero for uncommitted lines.
285    pub hash: String,
286    /// Author name (e.g. `Not Committed Yet` for unsaved/unstaged lines).
287    pub author: String,
288    /// Authored date as `YYYY-MM-DD`, in the author's own time zone.
289    pub date: String,
290    /// First line of the commit message.
291    pub summary: String,
292}
293
294impl BlameLine {
295    /// Whether this line is not yet committed (zero hash).
296    #[must_use]
297    pub fn is_uncommitted(&self) -> bool {
298        self.hash.chars().all(|c| c == '0')
299    }
300}
301
302/// Parse one entry of `git blame --line-porcelain` output into a [`BlameLine`].
303/// Returns `None` if no header (hash) line is present.
304#[must_use]
305pub fn parse_blame_porcelain(output: &str) -> Option<BlameLine> {
306    let mut hash = None;
307    let mut author = String::new();
308    let mut summary = String::new();
309    let mut time: i64 = 0;
310    let mut tz: i32 = 0;
311    for line in output.lines() {
312        if hash.is_none() {
313            // First line: "<40-hex> <orig> <final> [<count>]".
314            if let Some(h) = line.split(' ').next().filter(|h| h.len() == 40) {
315                hash = Some(h.chars().take(8).collect::<String>());
316            }
317            continue;
318        }
319        if let Some(name) = line.strip_prefix("author ") {
320            author = name.to_string();
321        } else if let Some(secs) = line.strip_prefix("author-time ") {
322            time = secs.trim().parse().unwrap_or(0);
323        } else if let Some(off) = line.strip_prefix("author-tz ") {
324            tz = parse_tz_offset(off.trim());
325        } else if let Some(s) = line.strip_prefix("summary ") {
326            summary = s.to_string();
327        }
328    }
329    let hash = hash?;
330    Some(BlameLine {
331        hash,
332        author,
333        date: epoch_to_date(time, tz),
334        summary,
335    })
336}
337
338/// Parse a git tz offset like `+0200` / `-0500` into seconds east of UTC.
339fn parse_tz_offset(tz: &str) -> i32 {
340    let sign = if tz.starts_with('-') { -1 } else { 1 };
341    let digits: String = tz.chars().filter(char::is_ascii_digit).collect();
342    if digits.len() < 4 {
343        return 0;
344    }
345    let hours: i32 = digits[0..2].parse().unwrap_or(0);
346    let mins: i32 = digits[2..4].parse().unwrap_or(0);
347    sign * (hours * 3600 + mins * 60)
348}
349
350/// Convert a Unix `secs` (+ `tz_offset` seconds east of UTC) to a `YYYY-MM-DD`
351/// calendar date, via Howard Hinnant's `civil_from_days` algorithm.
352#[must_use]
353fn epoch_to_date(secs: i64, tz_offset: i32) -> String {
354    let days = (secs + i64::from(tz_offset)).div_euclid(86_400);
355    let z = days + 719_468;
356    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
357    let doe = z - era * 146_097;
358    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
359    let y = yoe + era * 400;
360    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
361    let mp = (5 * doy + 2) / 153;
362    let d = doy - (153 * mp + 2) / 5 + 1;
363    let m = if mp < 10 { mp + 3 } else { mp - 9 };
364    let y = y + i64::from(m <= 2);
365    format!("{y:04}-{m:02}-{d:02}")
366}
367
368/// `git blame` for a single 1-based line of a repo-relative path, parsed into a
369/// [`BlameLine`]. `None` when not a repo, the path is untracked, or on error.
370#[must_use]
371pub fn blame_line(dir: &Path, rel_path: &str, line: usize) -> Option<BlameLine> {
372    let spec = format!("{line},{line}");
373    let out = git_stdout(
374        dir,
375        &["blame", "--line-porcelain", "-L", &spec, "--", rel_path],
376    )?;
377    parse_blame_porcelain(&out)
378}
379
380// ----- runners (shell out to `git`) --------------------------------------
381
382fn git(dir: &Path, args: &[&str]) -> std::io::Result<std::process::Output> {
383    Command::new("git").current_dir(dir).args(args).output()
384}
385
386/// Run `git` and return trimmed stdout on success, or `None` on any failure.
387fn git_stdout(dir: &Path, args: &[&str]) -> Option<String> {
388    let out = git(dir, args).ok()?;
389    if out.status.success() {
390        Some(String::from_utf8_lossy(&out.stdout).trim_end().to_string())
391    } else {
392        None
393    }
394}
395
396/// Whether `dir` is inside a git working tree.
397#[must_use]
398pub fn is_repo(dir: &Path) -> bool {
399    git_stdout(dir, &["rev-parse", "--is-inside-work-tree"]).as_deref() == Some("true")
400}
401
402/// The current branch name, or `None` when detached / not a repo. A detached
403/// HEAD yields the short commit hash via the caller's discretion (here `None`).
404#[must_use]
405pub fn branch(dir: &Path) -> Option<String> {
406    let name = git_stdout(dir, &["rev-parse", "--abbrev-ref", "HEAD"])?;
407    if name == "HEAD" {
408        // Detached: fall back to a short hash.
409        git_stdout(dir, &["rev-parse", "--short", "HEAD"])
410    } else {
411        Some(name)
412    }
413}
414
415/// Whether the working tree has any staged or unstaged changes (incl. untracked).
416#[must_use]
417pub fn is_dirty(dir: &Path) -> bool {
418    git_stdout(dir, &["status", "--porcelain"]).is_some_and(|s| !s.is_empty())
419}
420
421/// The changed files reported by `git status --porcelain`.
422#[must_use]
423pub fn status(dir: &Path) -> Vec<FileStatus> {
424    git_stdout(dir, &["status", "--porcelain"])
425        .map(|s| parse_status(&s))
426        .unwrap_or_default()
427}
428
429/// The committed (`HEAD`) contents of a repo-relative path, or `None` if the path
430/// is not in HEAD (newly added/untracked) or on any error.
431#[must_use]
432pub fn head_blob(dir: &Path, rel_path: &str) -> Option<String> {
433    // Use the raw (untrimmed) output: a blob's trailing newline is significant
434    // for line-accurate diffing, gutter marks, and hunk revert.
435    let out = git(dir, &["show", &format!("HEAD:{rel_path}")]).ok()?;
436    out.status
437        .success()
438        .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
439}
440
441/// Stage a path (`git add -- <path>`). Returns whether the command succeeded.
442#[must_use]
443pub fn stage(dir: &Path, rel_path: &str) -> bool {
444    git(dir, &["add", "--", rel_path]).is_ok_and(|o| o.status.success())
445}
446
447/// The staged (index) version of `rel_path` (`git show :path`), trailing newline
448/// preserved. `None` when the file is not in the index.
449#[must_use]
450pub fn index_blob(dir: &Path, rel_path: &str) -> Option<String> {
451    let out = git(dir, &["show", &format!(":{rel_path}")]).ok()?;
452    out.status
453        .success()
454        .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
455}
456
457/// Replace the index entry for `rel_path` with exactly `content` (used to stage
458/// a single hunk): hash the content into a blob and point the index at it,
459/// keeping the working tree untouched.
460///
461/// # Errors
462/// Returns an error when the git plumbing commands fail.
463pub fn stage_content(dir: &Path, rel_path: &str, content: &str) -> Result<(), String> {
464    let hash = git_stdin(
465        dir,
466        &["hash-object", "-w", "--path", rel_path, "--stdin"],
467        content,
468    )?;
469    let sha = hash.trim();
470    // Preserve the file's existing index mode when known; default to a regular
471    // non-executable file.
472    let mode = git_stdout(dir, &["ls-files", "-s", "--", rel_path])
473        .and_then(|s| s.split_whitespace().next().map(str::to_string))
474        .unwrap_or_else(|| "100644".to_string());
475    let info = format!("{mode},{sha},{rel_path}");
476    let out =
477        git(dir, &["update-index", "--add", "--cacheinfo", &info]).map_err(|e| e.to_string())?;
478    if out.status.success() {
479        Ok(())
480    } else {
481        Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
482    }
483}
484
485/// Run a git command feeding `input` on stdin; return its stdout on success.
486fn git_stdin(dir: &Path, args: &[&str], input: &str) -> Result<String, String> {
487    use std::io::Write;
488    use std::process::Stdio;
489    let mut child = Command::new("git")
490        .current_dir(dir)
491        .args(args)
492        .stdin(Stdio::piped())
493        .stdout(Stdio::piped())
494        .stderr(Stdio::piped())
495        .spawn()
496        .map_err(|e| e.to_string())?;
497    child
498        .stdin
499        .take()
500        .ok_or_else(|| "no stdin".to_string())?
501        .write_all(input.as_bytes())
502        .map_err(|e| e.to_string())?;
503    let out = child.wait_with_output().map_err(|e| e.to_string())?;
504    if out.status.success() {
505        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
506    } else {
507        Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
508    }
509}
510
511/// Unstage a path (`git restore --staged -- <path>`). Returns success.
512#[must_use]
513pub fn unstage(dir: &Path, rel_path: &str) -> bool {
514    git(dir, &["restore", "--staged", "--", rel_path]).is_ok_and(|o| o.status.success())
515}
516
517/// The number of commits reachable from HEAD (`git rev-list --count HEAD`), or
518/// `None` when not a repo or there are no commits yet.
519#[must_use]
520pub fn commit_count(dir: &Path) -> Option<u64> {
521    git_stdout(dir, &["rev-list", "--count", "HEAD"])?
522        .trim()
523        .parse()
524        .ok()
525}
526
527/// The local branch names, current branch first when it can be determined.
528#[must_use]
529pub fn local_branches(dir: &Path) -> Vec<String> {
530    let out = git_stdout(dir, &["branch", "--format=%(refname:short)"]).unwrap_or_default();
531    let mut names: Vec<String> = out
532        .lines()
533        .map(str::trim)
534        .filter(|s| !s.is_empty())
535        .map(String::from)
536        .collect();
537    if let Some(cur) = branch(dir)
538        && let Some(pos) = names.iter().position(|n| *n == cur)
539    {
540        names.swap(0, pos);
541    }
542    names
543}
544
545/// Check out an existing branch (`git switch <branch>`). Returns `Ok(())` on
546/// success, or the captured stderr on failure (e.g. a conflicting dirty tree).
547///
548/// # Errors
549/// Returns the trimmed stderr text when `git switch` exits non-zero or cannot run.
550pub fn checkout(dir: &Path, branch: &str) -> Result<(), String> {
551    match git(dir, &["switch", branch]) {
552        Ok(o) if o.status.success() => Ok(()),
553        Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
554        Err(e) => Err(e.to_string()),
555    }
556}
557
558/// Create a new branch and switch to it (`git switch -c <name>`). Returns
559/// `Ok(())` on success, or the captured stderr on failure (e.g. the name already
560/// exists or is invalid).
561///
562/// # Errors
563/// Returns the trimmed stderr text when `git switch -c` exits non-zero or cannot run.
564pub fn create_branch(dir: &Path, name: &str) -> Result<(), String> {
565    match git(dir, &["switch", "-c", name]) {
566        Ok(o) if o.status.success() => Ok(()),
567        Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
568        Err(e) => Err(e.to_string()),
569    }
570}
571
572/// Commit the staged changes with `message`. Returns `Ok(())` on success, or the
573/// captured stderr on failure (e.g. nothing staged, hook rejected).
574///
575/// # Errors
576/// Returns the trimmed stderr text when `git commit` exits non-zero or cannot run.
577pub fn commit(dir: &Path, message: &str) -> Result<(), String> {
578    run_ok(dir, &["commit", "-m", message])
579}
580
581/// Amend the previous commit, folding in currently-staged changes and keeping
582/// its message (`git commit --amend --no-edit`).
583///
584/// # Errors
585/// Returns git's stderr on failure (e.g. no commit to amend).
586pub fn commit_amend(dir: &Path) -> Result<(), String> {
587    run_ok(dir, &["commit", "--amend", "--no-edit"])
588}
589
590/// Stash the working tree and index (`git stash push`).
591///
592/// # Errors
593/// Returns git's stderr on failure.
594pub fn stash_push(dir: &Path) -> Result<(), String> {
595    run_ok(dir, &["stash", "push"])
596}
597
598/// Restore the most recent stash (`git stash pop`).
599///
600/// # Errors
601/// Returns git's stderr on failure (e.g. no stash, or a conflict).
602pub fn stash_pop(dir: &Path) -> Result<(), String> {
603    run_ok(dir, &["stash", "pop"])
604}
605
606/// Run a git command, mapping a non-zero exit to its trimmed stderr.
607fn run_ok(dir: &Path, args: &[&str]) -> Result<(), String> {
608    match git(dir, args) {
609        Ok(o) if o.status.success() => Ok(()),
610        Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
611        Err(e) => Err(e.to_string()),
612    }
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618
619    #[test]
620    fn hunks_groups_a_modified_line() {
621        let h = hunks("a\nb\nc\n", "a\nB\nc\n");
622        assert_eq!(h.len(), 1);
623        assert_eq!(h[0].current_start, 1);
624        assert_eq!(h[0].current_end, 2);
625        assert_eq!(h[0].head_text, "b\n");
626        assert!(h[0].contains(1));
627        assert!(!h[0].contains(0));
628    }
629
630    #[test]
631    fn hunks_addition_has_empty_head_text() {
632        let h = hunks("a\nc\n", "a\nb1\nb2\nc\n");
633        assert_eq!(h.len(), 1);
634        assert_eq!((h[0].current_start, h[0].current_end), (1, 3));
635        assert_eq!(h[0].head_text, ""); // pure addition restores to nothing
636    }
637
638    #[test]
639    fn hunks_deletion_anchors_a_zero_width_range() {
640        let h = hunks("a\nb\nc\n", "a\nc\n");
641        assert_eq!(h.len(), 1);
642        assert_eq!(h[0].current_start, h[0].current_end); // pure deletion
643        assert_eq!(h[0].head_text, "b\n"); // reverting re-inserts the committed line
644        assert!(h[0].contains(h[0].current_start));
645    }
646
647    #[test]
648    fn hunks_finds_multiple_regions() {
649        let h = hunks("a\nb\nc\nd\ne\n", "A\nb\nc\nD\ne\n");
650        assert_eq!(h.len(), 2);
651        assert_eq!(h[0].current_start, 0);
652        assert_eq!(h[1].current_start, 3);
653    }
654
655    #[test]
656    fn hunks_identical_text_is_empty() {
657        assert!(hunks("x\ny\n", "x\ny\n").is_empty());
658    }
659
660    #[test]
661    fn parse_status_reads_xy_codes_and_paths() {
662        let out = " M src/app.rs\nA  src/new.rs\n?? notes.txt\nMM both.rs\nD  gone.rs\n";
663        let rows = parse_status(out);
664        assert_eq!(rows.len(), 5);
665
666        assert_eq!(rows[0].path, "src/app.rs");
667        assert_eq!(rows[0].staged, None);
668        assert_eq!(rows[0].unstaged, Some(Change::Modified));
669
670        assert_eq!(rows[1].path, "src/new.rs");
671        assert_eq!(rows[1].staged, Some(Change::Added));
672        assert_eq!(rows[1].unstaged, None);
673
674        assert_eq!(rows[2].path, "notes.txt");
675        assert_eq!(rows[2].primary(), Some(Change::Untracked));
676
677        assert_eq!(rows[3].staged, Some(Change::Modified));
678        assert_eq!(rows[3].unstaged, Some(Change::Modified));
679
680        assert_eq!(rows[4].path, "gone.rs");
681        assert_eq!(rows[4].staged, Some(Change::Deleted));
682    }
683
684    #[test]
685    fn parse_status_records_rename_destination() {
686        let rows = parse_status("R  old/name.rs -> new/name.rs\n");
687        assert_eq!(rows.len(), 1);
688        assert_eq!(rows[0].path, "new/name.rs");
689        assert_eq!(rows[0].staged, Some(Change::Renamed));
690    }
691
692    #[test]
693    fn parse_status_skips_blank_and_short_lines() {
694        assert!(parse_status("\n\nx\n").is_empty());
695    }
696
697    #[test]
698    fn diff_marks_flags_added_modified_and_deleted() {
699        let head = "alpha\nbeta\ngamma\n";
700        // Modify line 2, add a new line 3, keep the rest.
701        let current = "alpha\nBETA\ngamma\ndelta\n";
702        let marks = diff_marks(head, current);
703        assert!(
704            marks.contains(&(1, LineMark::Modified)),
705            "line 2 modified: {marks:?}"
706        );
707        assert!(
708            marks.contains(&(3, LineMark::Added)),
709            "line 4 added: {marks:?}"
710        );
711    }
712
713    #[test]
714    fn diff_marks_flags_deletion() {
715        let head = "a\nb\nc\n";
716        let current = "a\nc\n"; // removed "b"
717        let marks = diff_marks(head, current);
718        assert!(
719            marks.iter().any(|&(_, m)| m == LineMark::Deleted),
720            "a deletion is marked: {marks:?}"
721        );
722    }
723
724    #[test]
725    fn diff_marks_empty_when_identical() {
726        assert!(diff_marks("x\ny\n", "x\ny\n").is_empty());
727    }
728
729    #[test]
730    fn parse_blame_porcelain_extracts_fields() {
731        let out = "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b 12 12 1\n\
732            author Ada Lovelace\n\
733            author-mail <ada@example.com>\n\
734            author-time 1700000000\n\
735            author-tz +0000\n\
736            committer Ada Lovelace\n\
737            summary Add the analytical engine\n\
738            filename engine.rs\n\
739            \tlet x = 1;\n";
740        let b = parse_blame_porcelain(out).expect("parsed");
741        assert_eq!(b.hash, "1a2b3c4d");
742        assert_eq!(b.author, "Ada Lovelace");
743        assert_eq!(b.date, "2023-11-14");
744        assert_eq!(b.summary, "Add the analytical engine");
745        assert!(!b.is_uncommitted());
746    }
747
748    #[test]
749    fn parse_blame_porcelain_flags_uncommitted() {
750        let out = "0000000000000000000000000000000000000000 1 1 1\n\
751            author Not Committed Yet\n\
752            author-time 1700000000\n\
753            author-tz +0000\n\
754            summary Version of engine.rs from engine.rs\n\
755            \tlet y = 2;\n";
756        let b = parse_blame_porcelain(out).expect("parsed");
757        assert!(b.is_uncommitted());
758        assert_eq!(b.author, "Not Committed Yet");
759    }
760
761    #[test]
762    fn parse_blame_porcelain_none_without_header() {
763        assert!(parse_blame_porcelain("not a blame\n").is_none());
764    }
765
766    #[test]
767    fn epoch_to_date_applies_tz_offset() {
768        // 1700000000 is 2023-11-14 22:13:20 UTC; +0200 rolls it into the 15th.
769        assert_eq!(epoch_to_date(1_700_000_000, 0), "2023-11-14");
770        assert_eq!(epoch_to_date(1_700_000_000, 2 * 3600), "2023-11-15");
771        assert_eq!(epoch_to_date(0, 0), "1970-01-01");
772    }
773
774    #[test]
775    fn change_letters() {
776        assert_eq!(Change::Modified.letter(), 'M');
777        assert_eq!(Change::Untracked.letter(), '?');
778    }
779
780    // Runner smoke test against this very repository. Ignored by default because
781    // it assumes `git` is installed and the tests run inside the work tree.
782    #[test]
783    #[ignore = "needs git and an in-tree checkout"]
784    fn runners_report_branch_in_this_repo() {
785        let dir = Path::new(env!("CARGO_MANIFEST_DIR"));
786        assert!(is_repo(dir));
787        assert!(branch(dir).is_some());
788    }
789}