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