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
18#[cfg(feature = "process")]
19use std::ffi::OsString;
20#[cfg(feature = "process")]
21use std::path::Path;
22#[cfg(feature = "process")]
23use std::process::Command;
24#[cfg(feature = "process")]
25use std::sync::OnceLock;
26
27/// A kind of change to a file (in the index or the working tree).
28#[derive(Clone, Copy, PartialEq, Eq, Debug)]
29pub enum Change {
30    /// Newly tracked (staged add).
31    Added,
32    /// Contents changed.
33    Modified,
34    /// Removed.
35    Deleted,
36    /// Renamed.
37    Renamed,
38    /// Not tracked by git.
39    Untracked,
40    /// Unmerged / conflicted.
41    Conflicted,
42}
43
44impl Change {
45    /// A single-letter code for compact UI (e.g. file-explorer badges).
46    #[must_use]
47    pub fn letter(self) -> char {
48        match self {
49            Change::Added => 'A',
50            Change::Modified => 'M',
51            Change::Deleted => 'D',
52            Change::Renamed => 'R',
53            Change::Untracked => '?',
54            Change::Conflicted => 'U',
55        }
56    }
57
58    fn from_code(c: char) -> Option<Change> {
59        match c {
60            'A' => Some(Change::Added),
61            // 'C' (copied) is treated like a modification.
62            'M' | 'C' => Some(Change::Modified),
63            'D' => Some(Change::Deleted),
64            'R' => Some(Change::Renamed),
65            '?' => Some(Change::Untracked),
66            'U' => Some(Change::Conflicted),
67            _ => None,
68        }
69    }
70}
71
72/// One changed path from `git status`, with its staged (index) and unstaged
73/// (working-tree) change, if any.
74#[derive(Clone, PartialEq, Eq, Debug)]
75pub struct FileStatus {
76    /// Path relative to the repository root.
77    pub path: String,
78    /// The staged (index) change, if any.
79    pub staged: Option<Change>,
80    /// The unstaged (working-tree) change, if any.
81    pub unstaged: Option<Change>,
82}
83
84impl FileStatus {
85    /// Whether the path has staged changes.
86    #[must_use]
87    pub fn is_staged(&self) -> bool {
88        self.staged.is_some()
89    }
90
91    /// The most representative change for a one-letter badge: the staged change
92    /// if present, else the unstaged one.
93    #[must_use]
94    pub fn primary(&self) -> Option<Change> {
95        self.staged.or(self.unstaged)
96    }
97}
98
99/// Parse `git status --porcelain` (v1) output into one [`FileStatus`] per path.
100///
101/// Each line is `XY <path>`, where `X` is the index status and `Y` the
102/// working-tree status (`??` for untracked). Rename lines (`R  old -> new`) are
103/// recorded under the new path.
104#[must_use]
105pub fn parse_status(output: &str) -> Vec<FileStatus> {
106    let mut out = Vec::new();
107    for line in output.lines() {
108        if line.len() < 3 {
109            continue;
110        }
111        let bytes = line.as_bytes();
112        let x = bytes[0] as char;
113        let y = bytes[1] as char;
114        let rest = line[3..].trim_end();
115        // Rename/copy show "old -> new"; record the destination.
116        let path = match rest.rsplit_once(" -> ") {
117            Some((_, new)) => new.to_string(),
118            None => rest.to_string(),
119        };
120        if path.is_empty() {
121            continue;
122        }
123        let (staged, unstaged) = if x == '?' && y == '?' {
124            (None, Some(Change::Untracked))
125        } else {
126            (Change::from_code(x), Change::from_code(y))
127        };
128        out.push(FileStatus {
129            path,
130            staged,
131            unstaged,
132        });
133    }
134    out
135}
136
137/// A per-line change marker for the editor's diff gutter.
138#[derive(Clone, Copy, PartialEq, Eq, Debug)]
139pub enum LineMark {
140    /// The line is new since the committed version.
141    Added,
142    /// The line replaces a committed line (content changed).
143    Modified,
144    /// One or more committed lines were removed at this position.
145    Deleted,
146}
147
148/// Buffers longer than this get no gutter diff marks (T574).
149const GUTTER_DIFF_MAX_LINES: usize = 50_000;
150
151/// Time budget for one gutter diff before `similar` falls back to a coarse
152/// result (T574).
153const GUTTER_DIFF_DEADLINE: std::time::Duration = std::time::Duration::from_millis(40);
154
155/// Compute per-line gutter marks between the committed text (`head`) and the
156/// current buffer (`current`), keyed by **current** line index (0-based).
157///
158/// Uses a line diff: inserted runs are [`LineMark::Added`], replaced runs are
159/// [`LineMark::Modified`], and a deletion records a [`LineMark::Deleted`] at the
160/// current line where the removed text used to be.
161#[must_use]
162pub fn diff_marks(head: &str, current: &str) -> Vec<(usize, LineMark)> {
163    use similar::{Algorithm, DiffOp, TextDiff};
164
165    let current_lines = current.lines().count();
166    // T574: this runs on the UI thread every refresh. Skip the gutter for
167    // huge buffers and put a deadline on Myers (O((N+M)·D)) so a heavily
168    // rewritten file degrades to a coarse diff instead of stalling a frame.
169    if current_lines.max(head.lines().count()) > GUTTER_DIFF_MAX_LINES {
170        return Vec::new();
171    }
172    let diff = TextDiff::configure()
173        .algorithm(Algorithm::Myers)
174        .timeout(GUTTER_DIFF_DEADLINE)
175        .diff_lines(head, current);
176    let mut marks = Vec::new();
177    for op in diff.ops() {
178        match *op {
179            DiffOp::Insert {
180                new_index, new_len, ..
181            } => {
182                for i in new_index..new_index + new_len {
183                    marks.push((i, LineMark::Added));
184                }
185            }
186            DiffOp::Replace {
187                new_index, new_len, ..
188            } => {
189                for i in new_index..new_index + new_len {
190                    marks.push((i, LineMark::Modified));
191                }
192            }
193            DiffOp::Delete { new_index, .. } => {
194                let at = new_index.min(current_lines.saturating_sub(1));
195                marks.push((at, LineMark::Deleted));
196            }
197            DiffOp::Equal { .. } => {}
198        }
199    }
200    marks
201}
202
203/// A contiguous changed region between the committed text and the current
204/// buffer, for hunk navigation and revert.
205#[derive(Clone, PartialEq, Eq, Debug)]
206pub struct Hunk {
207    /// First current-buffer line (0-based) the hunk covers.
208    pub current_start: usize,
209    /// One past the last current line the hunk covers. Equal to `current_start`
210    /// for a pure deletion (nothing remains in the current buffer there).
211    pub current_end: usize,
212    /// First HEAD line (0-based) the hunk's committed text begins at — used to
213    /// locate the region when staging the hunk into the index.
214    pub head_start: usize,
215    /// The committed (HEAD) text for this region, with line endings, used to
216    /// restore the hunk on revert. Empty for a pure addition.
217    pub head_text: String,
218}
219
220impl Hunk {
221    /// Whether current line `line` falls within this hunk (a pure deletion is
222    /// matched at its single anchor line).
223    #[must_use]
224    pub fn contains(&self, line: usize) -> bool {
225        if self.current_start == self.current_end {
226            line == self.current_start
227        } else {
228            line >= self.current_start && line < self.current_end
229        }
230    }
231}
232
233/// Group the line diff between committed text (`head`) and the current buffer
234/// into [`Hunk`]s — maximal runs of adjacent changed lines — in current-line
235/// order. Pairs with [`diff_marks`] (which colors individual gutter lines).
236#[must_use]
237pub fn hunks(head: &str, current: &str) -> Vec<Hunk> {
238    use similar::{Algorithm, DiffOp, TextDiff};
239
240    let diff = TextDiff::configure()
241        .algorithm(Algorithm::Myers)
242        .diff_lines(head, current);
243    let head_lines: Vec<&str> = head.split_inclusive('\n').collect();
244    let mut out: Vec<Hunk> = Vec::new();
245    // Accumulated (new_lo, new_hi, old_lo, old_hi) for the run in progress.
246    let mut run: Option<(usize, usize, usize, usize)> = None;
247
248    let flush = |run: &mut Option<(usize, usize, usize, usize)>, out: &mut Vec<Hunk>| {
249        if let Some((nlo, nhi, olo, ohi)) = run.take() {
250            let head_text = head_lines
251                .get(olo..ohi)
252                .map(<[&str]>::concat)
253                .unwrap_or_default();
254            out.push(Hunk {
255                current_start: nlo,
256                current_end: nhi,
257                head_start: olo,
258                head_text,
259            });
260        }
261    };
262
263    for op in diff.ops() {
264        let (ni, nl, oi, ol) = match *op {
265            DiffOp::Equal { .. } => {
266                flush(&mut run, &mut out);
267                continue;
268            }
269            DiffOp::Insert {
270                new_index,
271                new_len,
272                old_index,
273                ..
274            } => (new_index, new_len, old_index, 0),
275            DiffOp::Delete {
276                new_index,
277                old_index,
278                old_len,
279                ..
280            } => (new_index, 0, old_index, old_len),
281            DiffOp::Replace {
282                new_index,
283                new_len,
284                old_index,
285                old_len,
286                ..
287            } => (new_index, new_len, old_index, old_len),
288        };
289        run = Some(match run {
290            Some((nlo, nhi, olo, ohi)) => {
291                (nlo.min(ni), nhi.max(ni + nl), olo.min(oi), ohi.max(oi + ol))
292            }
293            None => (ni, ni + nl, oi, oi + ol),
294        });
295    }
296    flush(&mut run, &mut out);
297    out
298}
299
300/// One line's `git blame` attribution: the short commit hash, author name,
301/// `YYYY-MM-DD` authored date, and the commit summary.
302#[derive(Clone, PartialEq, Eq, Debug, Default)]
303pub struct BlameLine {
304    /// Abbreviated commit hash (8 hex chars), or all-zero for uncommitted lines.
305    pub hash: String,
306    /// Author name (e.g. `Not Committed Yet` for unsaved/unstaged lines).
307    pub author: String,
308    /// Authored date as `YYYY-MM-DD`, in the author's own time zone.
309    pub date: String,
310    /// First line of the commit message.
311    pub summary: String,
312}
313
314impl BlameLine {
315    /// Whether this line is not yet committed (zero hash).
316    #[must_use]
317    pub fn is_uncommitted(&self) -> bool {
318        self.hash.chars().all(|c| c == '0')
319    }
320}
321
322/// Parse one entry of `git blame --line-porcelain` output into a [`BlameLine`].
323/// Returns `None` if no header (hash) line is present.
324#[must_use]
325pub fn parse_blame_porcelain(output: &str) -> Option<BlameLine> {
326    let mut hash = None;
327    let mut author = String::new();
328    let mut summary = String::new();
329    let mut time: i64 = 0;
330    let mut tz: i32 = 0;
331    for line in output.lines() {
332        if hash.is_none() {
333            // First line: "<40-hex> <orig> <final> [<count>]".
334            if let Some(h) = line.split(' ').next().filter(|h| h.len() == 40) {
335                hash = Some(h.chars().take(8).collect::<String>());
336            }
337            continue;
338        }
339        if let Some(name) = line.strip_prefix("author ") {
340            author = name.to_string();
341        } else if let Some(secs) = line.strip_prefix("author-time ") {
342            time = secs.trim().parse().unwrap_or(0);
343        } else if let Some(off) = line.strip_prefix("author-tz ") {
344            tz = parse_tz_offset(off.trim());
345        } else if let Some(s) = line.strip_prefix("summary ") {
346            summary = s.to_string();
347        }
348    }
349    let hash = hash?;
350    Some(BlameLine {
351        hash,
352        author,
353        date: epoch_to_date(time, tz),
354        summary,
355    })
356}
357
358/// Parse a git tz offset like `+0200` / `-0500` into seconds east of UTC.
359fn parse_tz_offset(tz: &str) -> i32 {
360    let sign = if tz.starts_with('-') { -1 } else { 1 };
361    let digits: String = tz.chars().filter(char::is_ascii_digit).collect();
362    if digits.len() < 4 {
363        return 0;
364    }
365    let hours: i32 = digits[0..2].parse().unwrap_or(0);
366    let mins: i32 = digits[2..4].parse().unwrap_or(0);
367    sign * (hours * 3600 + mins * 60)
368}
369
370/// Convert a Unix `secs` (+ `tz_offset` seconds east of UTC) to a `YYYY-MM-DD`
371/// calendar date.
372///
373/// Delegates to `vix-civil-date` (Run H, T520) — this crate,
374/// `vix-file-information-panel`, and `vix-org` used to each hand-roll an
375/// independent copy of the same day-count-to-calendar-date algorithm.
376#[must_use]
377fn epoch_to_date(secs: i64, tz_offset: i32) -> String {
378    let days = (secs + i64::from(tz_offset)).div_euclid(86_400);
379    let (y, m, d) = vix_civil_date::civil_from_days(days);
380    format!("{y:04}-{m:02}-{d:02}")
381}
382
383#[cfg(feature = "process")]
384/// `git blame` for a single 1-based line of a repo-relative path, parsed into a
385/// [`BlameLine`]. `None` when not a repo, the path is untracked, or on error.
386#[must_use]
387pub fn blame_line(dir: &Path, rel_path: &str, line: usize) -> Option<BlameLine> {
388    let spec = format!("{line},{line}");
389    let out = git_stdout(
390        dir,
391        &["blame", "--line-porcelain", "-L", &spec, "--", rel_path],
392    )?;
393    parse_blame_porcelain(&out)
394}
395
396// ----- runners (shell out to `git`) --------------------------------------
397
398#[cfg(feature = "process")]
399/// Absolute path to the `git` executable, resolved from `PATH` exactly once.
400///
401/// Every git runner sets `current_dir(dir)` to the opened repository, which is
402/// attacker-controllable content. On Windows, `Command::new("git")` (a bare
403/// name) is resolved by `CreateProcessW` using a search order that includes the
404/// child's current directory — so a repo shipping a `git.exe`/`git.bat` in its
405/// root would run that planted binary. Resolving against `PATH` only (never the
406/// cwd) closes this binary-planting vector. Falls back to the bare name if the
407/// lookup fails (behaviour unchanged on systems where `git` isn't on `PATH`).
408fn git_program() -> &'static OsString {
409    static PROG: OnceLock<OsString> = OnceLock::new();
410    PROG.get_or_init(|| which_on_path("git").unwrap_or_else(|| "git".into()))
411}
412
413#[cfg(feature = "process")]
414/// Find `name` on `PATH` (honouring `PATHEXT` on Windows), returning its
415/// absolute path. Never consults the current directory.
416fn which_on_path(name: &str) -> Option<OsString> {
417    let exts: Vec<String> = if cfg!(windows) {
418        std::env::var("PATHEXT")
419            .unwrap_or_else(|_| ".EXE".into())
420            .split(';')
421            .map(str::to_string)
422            .collect()
423    } else {
424        vec![String::new()]
425    };
426    let path = std::env::var_os("PATH")?;
427    for dir in std::env::split_paths(&path) {
428        if dir.as_os_str().is_empty() {
429            continue; // an empty PATH entry means cwd — never search it
430        }
431        for ext in &exts {
432            let candidate = dir.join(format!("{name}{ext}"));
433            if candidate.is_file() {
434                return Some(candidate.into_os_string());
435            }
436        }
437    }
438    None
439}
440
441#[cfg(feature = "process")]
442/// Whether `name` is safe to pass to `git` as a positional ref argument: not
443/// empty, not option-shaped (a leading `-` would be parsed as a flag), and free
444/// of whitespace/control characters. git itself forbids most of these in real
445/// ref names, but validating before spawning stops argument injection through
446/// crafted `.git/refs` entries or prompt input.
447fn valid_ref_name(name: &str) -> bool {
448    !name.is_empty()
449        && !name.starts_with('-')
450        && !name.chars().any(|c| c.is_whitespace() || c.is_control())
451}
452
453#[cfg(feature = "process")]
454fn git(dir: &Path, args: &[&str]) -> std::io::Result<std::process::Output> {
455    Command::new(git_program())
456        .current_dir(dir)
457        .args(args)
458        .output()
459}
460
461#[cfg(feature = "process")]
462/// Run `git` and return trimmed stdout on success, or `None` on any failure.
463fn git_stdout(dir: &Path, args: &[&str]) -> Option<String> {
464    let out = git(dir, args).ok()?;
465    if out.status.success() {
466        Some(String::from_utf8_lossy(&out.stdout).trim_end().to_string())
467    } else {
468        None
469    }
470}
471
472#[cfg(feature = "process")]
473/// Whether `dir` is inside a git working tree.
474#[must_use]
475pub fn is_repo(dir: &Path) -> bool {
476    git_stdout(dir, &["rev-parse", "--is-inside-work-tree"]).as_deref() == Some("true")
477}
478
479#[cfg(feature = "process")]
480/// The current branch name, or `None` when detached / not a repo. A detached
481/// HEAD yields the short commit hash via the caller's discretion (here `None`).
482#[must_use]
483pub fn branch(dir: &Path) -> Option<String> {
484    let name = git_stdout(dir, &["rev-parse", "--abbrev-ref", "HEAD"])?;
485    if name == "HEAD" {
486        // Detached: fall back to a short hash.
487        git_stdout(dir, &["rev-parse", "--short", "HEAD"])
488    } else {
489        Some(name)
490    }
491}
492
493#[cfg(feature = "process")]
494/// Whether the working tree has any staged or unstaged changes (incl. untracked).
495#[must_use]
496pub fn is_dirty(dir: &Path) -> bool {
497    git_stdout(dir, &["status", "--porcelain"]).is_some_and(|s| !s.is_empty())
498}
499
500#[cfg(feature = "process")]
501/// The changed files reported by `git status --porcelain`.
502#[must_use]
503pub fn status(dir: &Path) -> Vec<FileStatus> {
504    git_stdout(dir, &["status", "--porcelain"])
505        .map(|s| parse_status(&s))
506        .unwrap_or_default()
507}
508
509#[cfg(feature = "process")]
510/// The committed (`HEAD`) contents of a repo-relative path, or `None` if the path
511/// is not in HEAD (newly added/untracked) or on any error.
512#[must_use]
513pub fn head_blob(dir: &Path, rel_path: &str) -> Option<String> {
514    // Use the raw (untrimmed) output: a blob's trailing newline is significant
515    // for line-accurate diffing, gutter marks, and hunk revert.
516    let out = git(dir, &["show", &format!("HEAD:{rel_path}")]).ok()?;
517    out.status
518        .success()
519        .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
520}
521
522#[cfg(feature = "process")]
523/// Stage every change in the working tree (`git add -A`).
524///
525/// # Errors
526///
527/// Returns git's own stderr on failure.
528pub fn stage_all(dir: &Path) -> Result<(), String> {
529    let out = git(dir, &["add", "-A"]).map_err(|e| e.to_string())?;
530    if out.status.success() {
531        Ok(())
532    } else {
533        Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
534    }
535}
536
537#[cfg(feature = "process")]
538/// Stage a path (`git add -- <path>`).
539///
540/// # Errors
541///
542/// Returns git's own stderr on failure (Run H, T540 — this used to collapse
543/// to a bare `bool`, so the caller could only show a static "git failed"
544/// with no reason, inconsistent with the sibling [`stage_content`], which
545/// already reports the real error).
546pub fn stage(dir: &Path, rel_path: &str) -> Result<(), String> {
547    let out = git(dir, &["add", "--", rel_path]).map_err(|e| e.to_string())?;
548    if out.status.success() {
549        Ok(())
550    } else {
551        Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
552    }
553}
554
555#[cfg(feature = "process")]
556/// The staged (index) version of `rel_path` (`git show :path`), trailing newline
557/// preserved. `None` when the file is not in the index.
558#[must_use]
559pub fn index_blob(dir: &Path, rel_path: &str) -> Option<String> {
560    let out = git(dir, &["show", &format!(":{rel_path}")]).ok()?;
561    out.status
562        .success()
563        .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
564}
565
566#[cfg(feature = "process")]
567/// Replace the index entry for `rel_path` with exactly `content` (used to stage
568/// a single hunk): hash the content into a blob and point the index at it,
569/// keeping the working tree untouched.
570///
571/// # Errors
572/// Returns an error when the git plumbing commands fail.
573pub fn stage_content(dir: &Path, rel_path: &str, content: &str) -> Result<(), String> {
574    let hash = git_stdin(
575        dir,
576        &["hash-object", "-w", "--path", rel_path, "--stdin"],
577        content,
578    )?;
579    let sha = hash.trim();
580    // Preserve the file's existing index mode when known; default to a regular
581    // non-executable file.
582    let mode = git_stdout(dir, &["ls-files", "-s", "--", rel_path])
583        .and_then(|s| s.split_whitespace().next().map(str::to_string))
584        .unwrap_or_else(|| "100644".to_string());
585    let info = format!("{mode},{sha},{rel_path}");
586    let out =
587        git(dir, &["update-index", "--add", "--cacheinfo", &info]).map_err(|e| e.to_string())?;
588    if out.status.success() {
589        Ok(())
590    } else {
591        Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
592    }
593}
594
595#[cfg(feature = "process")]
596/// Run a git command feeding `input` on stdin; return its stdout on success.
597fn git_stdin(dir: &Path, args: &[&str], input: &str) -> Result<String, String> {
598    use std::io::Write;
599    use std::process::Stdio;
600    let mut child = Command::new(git_program())
601        .current_dir(dir)
602        .args(args)
603        .stdin(Stdio::piped())
604        .stdout(Stdio::piped())
605        .stderr(Stdio::piped())
606        .spawn()
607        .map_err(|e| e.to_string())?;
608    child
609        .stdin
610        .take()
611        .ok_or_else(|| "no stdin".to_string())?
612        .write_all(input.as_bytes())
613        .map_err(|e| e.to_string())?;
614    let out = child.wait_with_output().map_err(|e| e.to_string())?;
615    if out.status.success() {
616        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
617    } else {
618        Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
619    }
620}
621
622#[cfg(feature = "process")]
623/// Unstage a path (`git restore --staged -- <path>`).
624///
625/// # Errors
626///
627/// Returns git's own stderr on failure (Run H, T540 — see [`stage`]'s doc
628/// comment for why).
629pub fn unstage(dir: &Path, rel_path: &str) -> Result<(), String> {
630    let out = git(dir, &["restore", "--staged", "--", rel_path]).map_err(|e| e.to_string())?;
631    if out.status.success() {
632        Ok(())
633    } else {
634        Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
635    }
636}
637
638#[cfg(feature = "process")]
639/// The staged diff (`git diff --staged`), for feeding to an AI commit-message
640/// generator (T125) — `None` on any failure, or when nothing is staged
641/// (an empty diff is not useful context and would only prompt a generic
642/// reply).
643#[must_use]
644pub fn staged_diff(dir: &Path) -> Option<String> {
645    git_stdout(dir, &["diff", "--staged"]).filter(|d| !d.is_empty())
646}
647
648#[cfg(feature = "process")]
649/// The number of commits reachable from HEAD (`git rev-list --count HEAD`), or
650/// `None` when not a repo or there are no commits yet.
651#[must_use]
652pub fn commit_count(dir: &Path) -> Option<u64> {
653    git_stdout(dir, &["rev-list", "--count", "HEAD"])?
654        .trim()
655        .parse()
656        .ok()
657}
658
659#[cfg(feature = "process")]
660/// The local branch names, current branch first when it can be determined.
661#[must_use]
662pub fn local_branches(dir: &Path) -> Vec<String> {
663    let out = git_stdout(dir, &["branch", "--format=%(refname:short)"]).unwrap_or_default();
664    let mut names: Vec<String> = out
665        .lines()
666        .map(str::trim)
667        .filter(|s| !s.is_empty())
668        .map(String::from)
669        .collect();
670    if let Some(cur) = branch(dir)
671        && let Some(pos) = names.iter().position(|n| *n == cur)
672    {
673        names.swap(0, pos);
674    }
675    names
676}
677
678#[cfg(feature = "process")]
679/// Check out an existing branch (`git switch <branch>`). Returns `Ok(())` on
680/// success, or the captured stderr on failure (e.g. a conflicting dirty tree).
681///
682/// # Errors
683/// Returns the trimmed stderr text when `git switch` exits non-zero or cannot run.
684pub fn checkout(dir: &Path, branch: &str) -> Result<(), String> {
685    if !valid_ref_name(branch) {
686        return Err(format!("invalid branch name: {branch:?}"));
687    }
688    // `--end-of-options` guarantees the ref can never be read as a flag, even
689    // if it slipped past `valid_ref_name`.
690    match git(dir, &["switch", "--end-of-options", branch]) {
691        Ok(o) if o.status.success() => Ok(()),
692        Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
693        Err(e) => Err(e.to_string()),
694    }
695}
696
697#[cfg(feature = "process")]
698/// Create a new branch and switch to it (`git switch -c <name>`). Returns
699/// `Ok(())` on success, or the captured stderr on failure (e.g. the name already
700/// exists or is invalid).
701///
702/// # Errors
703/// Returns the trimmed stderr text when `git switch -c` exits non-zero or cannot run.
704pub fn create_branch(dir: &Path, name: &str) -> Result<(), String> {
705    if !valid_ref_name(name) {
706        return Err(format!("invalid branch name: {name:?}"));
707    }
708    match git(dir, &["switch", "-c", name]) {
709        Ok(o) if o.status.success() => Ok(()),
710        Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
711        Err(e) => Err(e.to_string()),
712    }
713}
714
715#[cfg(feature = "process")]
716/// Commit the staged changes with `message`. Returns `Ok(())` on success, or the
717/// captured stderr on failure (e.g. nothing staged, hook rejected).
718///
719/// # Errors
720/// Returns the trimmed stderr text when `git commit` exits non-zero or cannot run.
721pub fn commit(dir: &Path, message: &str) -> Result<(), String> {
722    run_ok(dir, &["commit", "-m", message])
723}
724
725/// Whether a `gpg-agent.conf` selects a terminal-based pinentry
726/// (`pinentry-curses`, `pinentry-tty`): the last `pinentry-program` line wins.
727/// Such a program draws on and reads from `/dev/tty` directly, so running it
728/// under Vix's raw-mode screen corrupts the display and steals keystrokes
729/// (T570).
730#[must_use]
731pub fn pinentry_wants_tty(agent_conf: &str) -> bool {
732    agent_conf
733        .lines()
734        .filter_map(|l| {
735            let l = l.trim();
736            if l.starts_with('#') {
737                return None;
738            }
739            l.strip_prefix("pinentry-program")
740                .filter(|rest| rest.starts_with(char::is_whitespace))
741                .map(str::trim)
742        })
743        .next_back()
744        .is_some_and(|program| {
745            let name = program.rsplit(['/', '\\']).next().unwrap_or(program);
746            name.contains("curses") || name.contains("tty")
747        })
748}
749
750#[cfg(feature = "process")]
751/// Whether committing in `dir` would sign with a terminal pinentry, which
752/// cannot run under Vix's raw-mode screen (T570): `commit.gpgsign` is true, the
753/// signing format is GPG, and the user's `gpg-agent.conf` selects a
754/// curses/tty pinentry.
755#[must_use]
756pub fn commit_signing_needs_tty(dir: &Path) -> bool {
757    if git_stdout(dir, &["config", "--bool", "--get", "commit.gpgsign"]).as_deref() != Some("true")
758    {
759        return false;
760    }
761    if git_stdout(dir, &["config", "--get", "gpg.format"]).as_deref() == Some("ssh") {
762        return false;
763    }
764    let home = std::env::var_os("GNUPGHOME")
765        .map(std::path::PathBuf::from)
766        .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".gnupg")));
767    home.and_then(|h| std::fs::read_to_string(h.join("gpg-agent.conf")).ok())
768        .is_some_and(|conf| pinentry_wants_tty(&conf))
769}
770
771#[cfg(feature = "process")]
772/// Amend the previous commit, folding in currently-staged changes and keeping
773/// its message (`git commit --amend --no-edit`).
774///
775/// # Errors
776/// Returns git's stderr on failure (e.g. no commit to amend).
777pub fn commit_amend(dir: &Path) -> Result<(), String> {
778    run_ok(dir, &["commit", "--amend", "--no-edit"])
779}
780
781#[cfg(feature = "process")]
782/// Stash the working tree and index (`git stash push`).
783///
784/// # Errors
785/// Returns git's stderr on failure.
786pub fn stash_push(dir: &Path) -> Result<(), String> {
787    run_ok(dir, &["stash", "push"])
788}
789
790// ----- Log / history (T207) --------------------------------------------
791
792/// One `git log` row.
793#[derive(Clone, Debug, PartialEq, Eq)]
794pub struct LogEntry {
795    /// Full commit hash.
796    pub sha: String,
797    /// Abbreviated commit hash, as git itself would print it.
798    pub abbrev: String,
799    /// Author name.
800    pub author: String,
801    /// Commit date (`YYYY-MM-DD`, the author date).
802    pub date: String,
803    /// The commit message's first line.
804    pub subject: String,
805}
806
807/// Where a [`LogPanel`]'s entries came from, and what its Enter should diff
808/// against.
809#[derive(Clone, Debug, PartialEq, Eq)]
810pub enum LogScope {
811    /// The whole repository's history (**Git → Log**).
812    Repo,
813    /// One file's history (**Git → File History**), by its
814    /// workspace-relative path -- so Enter can pass it to [`show_commit`]
815    /// and diff just that file, not the whole commit.
816    File(String),
817}
818
819/// A commit-list overlay: [`log`]/[`file_log`] entries, a highlighted row,
820/// and a scroll offset.
821pub struct LogPanel {
822    /// Commit rows, most recent first (as `git log` orders them).
823    pub entries: Vec<LogEntry>,
824    /// Where these entries came from.
825    pub scope: LogScope,
826    /// Index of the highlighted row.
827    pub selected: usize,
828    /// First visible row.
829    pub scroll: usize,
830}
831
832impl LogPanel {
833    /// A fresh panel over `entries`, selecting the first (most recent) row.
834    #[must_use]
835    pub fn new(entries: Vec<LogEntry>, scope: LogScope) -> Self {
836        LogPanel {
837            entries,
838            scope,
839            selected: 0,
840            scroll: 0,
841        }
842    }
843
844    /// Move the highlight up one row, stopping at the top.
845    pub fn up(&mut self) {
846        self.selected = vix_list_state::up(self.selected);
847    }
848
849    /// Move the highlight down one row, stopping at the bottom.
850    pub fn down(&mut self) {
851        self.selected = vix_list_state::down(self.selected, self.entries.len());
852    }
853
854    /// Move the highlight up one page, stopping at the top.
855    pub fn page_up(&mut self, page: usize) {
856        self.selected = vix_list_state::page_up(self.selected, page);
857    }
858
859    /// Move the highlight down one page, stopping at the bottom.
860    pub fn page_down(&mut self, page: usize) {
861        self.selected = vix_list_state::page_down(self.selected, page, self.entries.len());
862    }
863
864    /// Keep the highlighted row within a window of `height` visible rows.
865    pub fn ensure_visible(&mut self, height: usize) {
866        self.scroll =
867            vix_list_state::ensure_visible(self.selected, self.scroll, height, self.entries.len());
868    }
869
870    /// The highlighted commit's full hash, if any.
871    #[must_use]
872    pub fn selected_sha(&self) -> Option<&str> {
873        self.entries.get(self.selected).map(|e| e.sha.as_str())
874    }
875
876    /// The highlighted commit's abbreviated hash, if any.
877    #[must_use]
878    pub fn selected_abbrev(&self) -> Option<&str> {
879        self.entries.get(self.selected).map(|e| e.abbrev.as_str())
880    }
881}
882
883#[cfg(feature = "process")]
884/// Delimiters for [`parse_log`]'s custom `git log --format`: ASCII unit
885/// separator between fields, record separator between commits. Neither can
886/// appear in ordinary commit metadata, so no escaping is needed.
887const LOG_FIELD_SEP: &str = "\u{1f}";
888#[cfg(feature = "process")]
889const LOG_RECORD_SEP: &str = "\u{1e}";
890
891#[cfg(feature = "process")]
892/// Parse [`log`]/[`file_log`]'s `git log` output (the `LOG_FORMAT` shape)
893/// into rows. Malformed records (the wrong number of fields) are skipped
894/// rather than panicking.
895fn parse_log(output: &str) -> Vec<LogEntry> {
896    output
897        .split(LOG_RECORD_SEP)
898        .filter_map(|record| {
899            let record = record.trim_start_matches('\n');
900            let mut fields = record.split(LOG_FIELD_SEP);
901            let sha = fields.next()?.to_string();
902            let abbrev = fields.next()?.to_string();
903            let author = fields.next()?.to_string();
904            let date = fields.next()?.to_string();
905            let subject = fields.next()?.to_string();
906            if sha.is_empty() {
907                return None;
908            }
909            Some(LogEntry {
910                sha,
911                abbrev,
912                author,
913                date,
914                subject,
915            })
916        })
917        .collect()
918}
919
920#[cfg(feature = "process")]
921/// `git log --format=<LOG_FORMAT>`'s format string: full/abbreviated hash,
922/// author name, author date, subject, then the record separator.
923fn log_format_arg() -> String {
924    format!(
925        "--format=%H{LOG_FIELD_SEP}%h{LOG_FIELD_SEP}%an{LOG_FIELD_SEP}%ad{LOG_FIELD_SEP}%s{LOG_RECORD_SEP}"
926    )
927}
928
929#[cfg(feature = "process")]
930/// The repository's `max` most recent commits on `HEAD` (**Git → Log**).
931#[must_use]
932pub fn log(dir: &Path, max: usize) -> Vec<LogEntry> {
933    let n = max.to_string();
934    let fmt = log_format_arg();
935    git_stdout(dir, &["log", &fmt, "--date=short", "-n", &n])
936        .map(|s| parse_log(&s))
937        .unwrap_or_default()
938}
939
940#[cfg(feature = "process")]
941/// The `max` most recent commits touching `rel_path`, following renames
942/// (**Git → File History**).
943#[must_use]
944pub fn file_log(dir: &Path, rel_path: &str, max: usize) -> Vec<LogEntry> {
945    let n = max.to_string();
946    let fmt = log_format_arg();
947    git_stdout(
948        dir,
949        &[
950            "log",
951            &fmt,
952            "--date=short",
953            "--follow",
954            "-n",
955            &n,
956            "--",
957            rel_path,
958        ],
959    )
960    .map(|s| parse_log(&s))
961    .unwrap_or_default()
962}
963
964#[cfg(feature = "process")]
965/// A commit's own unified diff (`git show <sha>`), or just the part touching
966/// `rel_path` when given. `None` on any failure, including an invalid `sha`
967/// (rejected before shelling out, the same guard [`checkout`] uses).
968#[must_use]
969pub fn show_commit(dir: &Path, sha: &str, rel_path: Option<&str>) -> Option<String> {
970    if !valid_ref_name(sha) {
971        return None;
972    }
973    // `--end-of-options` guarantees `sha` can never be read as a flag, even
974    // if it slipped past `valid_ref_name` (the same defense `checkout` uses).
975    let mut args = vec!["show", "--end-of-options", sha];
976    if let Some(p) = rel_path {
977        args.push("--");
978        args.push(p);
979    }
980    git_stdout(dir, &args)
981}
982
983#[cfg(feature = "process")]
984/// `rel_path`'s content at `revision` (`git show <revision>:<rel_path>`, e.g.
985/// **Git → Open File at Revision…**). `None` when `revision` doesn't resolve,
986/// the path isn't in that tree, or `revision` fails the same validation
987/// [`checkout`] applies to a ref name.
988#[must_use]
989pub fn show_file_at(dir: &Path, revision: &str, rel_path: &str) -> Option<String> {
990    if !valid_ref_name(revision) {
991        return None;
992    }
993    let out = git(
994        dir,
995        &[
996            "show",
997            "--end-of-options",
998            &format!("{revision}:{rel_path}"),
999        ],
1000    )
1001    .ok()?;
1002    out.status
1003        .success()
1004        .then(|| String::from_utf8_lossy(&out.stdout).into_owned())
1005}
1006
1007#[cfg(feature = "process")]
1008/// Resolve `revision` (a branch, tag, or any commit-ish) to its abbreviated
1009/// hash (`git rev-parse --short`), for titling an Open File at Revision tab.
1010/// `None` when it doesn't resolve or fails the ref-name validation.
1011#[must_use]
1012pub fn resolve_short_sha(dir: &Path, revision: &str) -> Option<String> {
1013    if !valid_ref_name(revision) {
1014        return None;
1015    }
1016    git_stdout(dir, &["rev-parse", "--short", "--end-of-options", revision])
1017}
1018
1019#[cfg(feature = "process")]
1020/// Restore the most recent stash (`git stash pop`).
1021///
1022/// # Errors
1023/// Returns git's stderr on failure (e.g. no stash, or a conflict).
1024pub fn stash_pop(dir: &Path) -> Result<(), String> {
1025    run_ok(dir, &["stash", "pop"])
1026}
1027
1028#[cfg(feature = "process")]
1029/// Run a git command, mapping a non-zero exit to its trimmed stderr.
1030fn run_ok(dir: &Path, args: &[&str]) -> Result<(), String> {
1031    match git(dir, args) {
1032        Ok(o) if o.status.success() => Ok(()),
1033        Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
1034        Err(e) => Err(e.to_string()),
1035    }
1036}
1037
1038#[cfg(test)]
1039mod tests {
1040    use super::*;
1041
1042    #[cfg(feature = "process")]
1043    #[test]
1044    fn valid_ref_name_rejects_option_shaped_and_control_input() {
1045        assert!(valid_ref_name("main"));
1046        assert!(valid_ref_name("feature/x-y"));
1047        // Option-shaped names that git would otherwise interpret as flags.
1048        assert!(!valid_ref_name("--detach"));
1049        assert!(!valid_ref_name("-oProxyCommand=x"));
1050        assert!(!valid_ref_name("--upload-pack=evil"));
1051        // Empty / whitespace / control characters.
1052        assert!(!valid_ref_name(""));
1053        assert!(!valid_ref_name("a b"));
1054        assert!(!valid_ref_name("a\nb"));
1055    }
1056
1057    #[cfg(feature = "process")]
1058    #[test]
1059    fn checkout_and_create_branch_refuse_dash_prefixed_names() {
1060        // No git process is spawned: validation fails first, so any temp dir works.
1061        let dir = std::env::temp_dir();
1062        assert!(checkout(&dir, "--detach").is_err());
1063        assert!(create_branch(&dir, "-c").is_err());
1064        assert!(create_branch(&dir, "--orphan").is_err());
1065    }
1066
1067    #[cfg(feature = "process")]
1068    #[test]
1069    fn show_commit_and_show_file_at_refuse_option_shaped_revisions() {
1070        // No git process is spawned: validation fails first, so any temp dir works.
1071        let dir = std::env::temp_dir();
1072        assert!(show_commit(&dir, "--upload-pack=evil", None).is_none());
1073        assert!(show_file_at(&dir, "--upload-pack=evil", "a.rs").is_none());
1074        assert!(resolve_short_sha(&dir, "--upload-pack=evil").is_none());
1075    }
1076
1077    #[cfg(feature = "process")]
1078    #[test]
1079    fn stage_and_unstage_report_the_real_git_error() {
1080        // T540 (Run H): these used to collapse to a bare `bool`, so a caller
1081        // could only show a generic "git failed" with no reason. Any git
1082        // command run outside a repository fails immediately with a real,
1083        // git-provided message -- no repo fixture needed to prove it's
1084        // captured now instead of discarded.
1085        let dir = std::env::temp_dir().join(format!("vix-git-notrepo-{}", std::process::id()));
1086        let _ = std::fs::remove_dir_all(&dir);
1087        std::fs::create_dir_all(&dir).unwrap();
1088        let err = stage(&dir, "a.txt").expect_err("not a repo, so this must fail");
1089        assert!(!err.is_empty(), "a real message, not silently discarded");
1090        let err = unstage(&dir, "a.txt").expect_err("not a repo, so this must fail");
1091        assert!(!err.is_empty(), "a real message, not silently discarded");
1092        let _ = std::fs::remove_dir_all(&dir);
1093    }
1094
1095    #[cfg(feature = "process")]
1096    #[test]
1097    fn parse_log_splits_records_and_fields() {
1098        let output = format!(
1099            "abc123{LOG_FIELD_SEP}abc{LOG_FIELD_SEP}Ada Lovelace{LOG_FIELD_SEP}2026-01-02\
1100             {LOG_FIELD_SEP}Add the analytical engine{LOG_RECORD_SEP}\
1101             def456{LOG_FIELD_SEP}def{LOG_FIELD_SEP}Grace Hopper{LOG_FIELD_SEP}2026-01-01\
1102             {LOG_FIELD_SEP}Initial commit{LOG_RECORD_SEP}"
1103        );
1104        let entries = parse_log(&output);
1105        assert_eq!(entries.len(), 2);
1106        assert_eq!(entries[0].sha, "abc123");
1107        assert_eq!(entries[0].abbrev, "abc");
1108        assert_eq!(entries[0].author, "Ada Lovelace");
1109        assert_eq!(entries[0].date, "2026-01-02");
1110        assert_eq!(entries[0].subject, "Add the analytical engine");
1111        assert_eq!(entries[1].author, "Grace Hopper");
1112    }
1113
1114    #[cfg(feature = "process")]
1115    #[test]
1116    fn parse_log_skips_a_trailing_empty_record() {
1117        // git log's own trailing record-separator leaves one empty record at
1118        // the end; it must not become a bogus all-empty-field entry.
1119        let output = format!(
1120            "abc123{LOG_FIELD_SEP}abc{LOG_FIELD_SEP}Ada Lovelace{LOG_FIELD_SEP}2026-01-02\
1121             {LOG_FIELD_SEP}subject{LOG_RECORD_SEP}"
1122        );
1123        assert_eq!(parse_log(&output).len(), 1);
1124    }
1125
1126    #[cfg(feature = "process")]
1127    #[test]
1128    fn parse_log_ignores_a_truncated_record() {
1129        let output = format!("abc123{LOG_FIELD_SEP}abc{LOG_FIELD_SEP}only three fields");
1130        assert_eq!(parse_log(&output), [] as [LogEntry; 0]);
1131    }
1132
1133    #[test]
1134    fn log_panel_navigation_reports_the_selected_hashes() {
1135        let mut p = LogPanel::new(
1136            vec![
1137                LogEntry {
1138                    sha: "aaa111".into(),
1139                    abbrev: "aaa".into(),
1140                    author: "A".into(),
1141                    date: "2026-01-01".into(),
1142                    subject: "first".into(),
1143                },
1144                LogEntry {
1145                    sha: "bbb222".into(),
1146                    abbrev: "bbb".into(),
1147                    author: "B".into(),
1148                    date: "2026-01-02".into(),
1149                    subject: "second".into(),
1150                },
1151            ],
1152            LogScope::Repo,
1153        );
1154        assert_eq!(p.selected_sha(), Some("aaa111"));
1155        assert_eq!(p.selected_abbrev(), Some("aaa"));
1156        p.down();
1157        assert_eq!(p.selected_sha(), Some("bbb222"));
1158        p.down(); // stays clamped at the last row
1159        assert_eq!(p.selected_sha(), Some("bbb222"));
1160        p.up();
1161        assert_eq!(p.selected_sha(), Some("aaa111"));
1162    }
1163
1164    #[cfg(feature = "process")]
1165    #[test]
1166    fn which_on_path_never_returns_a_relative_or_cwd_path() {
1167        // Whatever it resolves (git may or may not be installed in CI), it must
1168        // be an absolute path — never a bare name resolvable against the cwd.
1169        if let Some(p) = which_on_path("git") {
1170            assert!(
1171                Path::new(&p).is_absolute(),
1172                "git resolved to non-absolute: {p:?}"
1173            );
1174        }
1175    }
1176
1177    #[test]
1178    fn hunks_groups_a_modified_line() {
1179        let h = hunks("a\nb\nc\n", "a\nB\nc\n");
1180        assert_eq!(h.len(), 1);
1181        assert_eq!(h[0].current_start, 1);
1182        assert_eq!(h[0].current_end, 2);
1183        assert_eq!(h[0].head_text, "b\n");
1184        assert!(h[0].contains(1));
1185        assert!(!h[0].contains(0));
1186    }
1187
1188    #[test]
1189    fn hunks_addition_has_empty_head_text() {
1190        let h = hunks("a\nc\n", "a\nb1\nb2\nc\n");
1191        assert_eq!(h.len(), 1);
1192        assert_eq!((h[0].current_start, h[0].current_end), (1, 3));
1193        assert_eq!(h[0].head_text, ""); // pure addition restores to nothing
1194    }
1195
1196    #[test]
1197    fn hunks_deletion_anchors_a_zero_width_range() {
1198        let h = hunks("a\nb\nc\n", "a\nc\n");
1199        assert_eq!(h.len(), 1);
1200        assert_eq!(h[0].current_start, h[0].current_end); // pure deletion
1201        assert_eq!(h[0].head_text, "b\n"); // reverting re-inserts the committed line
1202        assert!(h[0].contains(h[0].current_start));
1203    }
1204
1205    #[test]
1206    fn hunks_finds_multiple_regions() {
1207        let h = hunks("a\nb\nc\nd\ne\n", "A\nb\nc\nD\ne\n");
1208        assert_eq!(h.len(), 2);
1209        assert_eq!(h[0].current_start, 0);
1210        assert_eq!(h[1].current_start, 3);
1211    }
1212
1213    #[test]
1214    fn hunks_identical_text_is_empty() {
1215        assert_eq!(hunks("x\ny\n", "x\ny\n"), [] as [Hunk; 0]);
1216    }
1217
1218    #[test]
1219    fn parse_status_reads_xy_codes_and_paths() {
1220        let out = " M src/app.rs\nA  src/new.rs\n?? notes.txt\nMM both.rs\nD  gone.rs\n";
1221        let rows = parse_status(out);
1222        assert_eq!(rows.len(), 5);
1223
1224        assert_eq!(rows[0].path, "src/app.rs");
1225        assert_eq!(rows[0].staged, None);
1226        assert_eq!(rows[0].unstaged, Some(Change::Modified));
1227
1228        assert_eq!(rows[1].path, "src/new.rs");
1229        assert_eq!(rows[1].staged, Some(Change::Added));
1230        assert_eq!(rows[1].unstaged, None);
1231
1232        assert_eq!(rows[2].path, "notes.txt");
1233        assert_eq!(rows[2].primary(), Some(Change::Untracked));
1234
1235        assert_eq!(rows[3].staged, Some(Change::Modified));
1236        assert_eq!(rows[3].unstaged, Some(Change::Modified));
1237
1238        assert_eq!(rows[4].path, "gone.rs");
1239        assert_eq!(rows[4].staged, Some(Change::Deleted));
1240    }
1241
1242    #[test]
1243    fn parse_status_records_rename_destination() {
1244        let rows = parse_status("R  old/name.rs -> new/name.rs\n");
1245        assert_eq!(rows.len(), 1);
1246        assert_eq!(rows[0].path, "new/name.rs");
1247        assert_eq!(rows[0].staged, Some(Change::Renamed));
1248    }
1249
1250    #[test]
1251    fn parse_status_skips_blank_and_short_lines() {
1252        assert_eq!(parse_status("\n\nx\n"), [] as [FileStatus; 0]);
1253    }
1254
1255    #[test]
1256    fn diff_marks_flags_added_modified_and_deleted() {
1257        let head = "alpha\nbeta\ngamma\n";
1258        // Modify line 2, add a new line 3, keep the rest.
1259        let current = "alpha\nBETA\ngamma\ndelta\n";
1260        let marks = diff_marks(head, current);
1261        assert!(
1262            marks.contains(&(1, LineMark::Modified)),
1263            "line 2 modified: {marks:?}"
1264        );
1265        assert!(
1266            marks.contains(&(3, LineMark::Added)),
1267            "line 4 added: {marks:?}"
1268        );
1269    }
1270
1271    #[test]
1272    fn diff_marks_flags_deletion() {
1273        let head = "a\nb\nc\n";
1274        let current = "a\nc\n"; // removed "b"
1275        let marks = diff_marks(head, current);
1276        assert!(
1277            marks.iter().any(|&(_, m)| m == LineMark::Deleted),
1278            "a deletion is marked: {marks:?}"
1279        );
1280    }
1281
1282    #[test]
1283    fn diff_marks_skips_huge_buffers_and_finishes_fast_on_rewrites() {
1284        // Over the line cap: no marks at all, and no work done.
1285        let big = "x\n".repeat(GUTTER_DIFF_MAX_LINES + 1);
1286        assert_eq!(diff_marks("", &big), vec![]);
1287        // A fully rewritten mid-size file must come back inside a generous
1288        // multiple of the deadline rather than running Myers to completion.
1289        let head = (0..20_000).fold(String::new(), |a, i| a + &format!("a{i}\n"));
1290        let cur = (0..20_000).fold(String::new(), |a, i| {
1291            a + &format!(
1292                "b{}
1293",
1294                i * 7 % 20_000
1295            )
1296        });
1297        let start = std::time::Instant::now();
1298        let _ = diff_marks(&head, &cur);
1299        assert!(start.elapsed() < std::time::Duration::from_secs(2));
1300    }
1301
1302    #[test]
1303    fn pinentry_wants_tty_reads_the_last_program_line() {
1304        assert!(pinentry_wants_tty(
1305            "pinentry-program /usr/bin/pinentry-curses\n"
1306        ));
1307        assert!(pinentry_wants_tty(
1308            "pinentry-program /opt/homebrew/bin/pinentry-tty"
1309        ));
1310        assert!(!pinentry_wants_tty(
1311            "pinentry-program /usr/bin/pinentry-mac\n"
1312        ));
1313        assert!(!pinentry_wants_tty("default-cache-ttl 600\n"));
1314        // A comment does not count, and the last real line wins.
1315        assert!(!pinentry_wants_tty("# pinentry-program pinentry-curses\n"));
1316        assert!(!pinentry_wants_tty(
1317            "pinentry-program /usr/bin/pinentry-curses\npinentry-program /usr/bin/pinentry-gtk-2\n"
1318        ));
1319        assert!(pinentry_wants_tty(
1320            "pinentry-program /usr/bin/pinentry-gtk-2\npinentry-program /usr/bin/pinentry-curses\n"
1321        ));
1322        assert!(!pinentry_wants_tty(""));
1323    }
1324
1325    #[test]
1326    fn diff_marks_empty_when_identical() {
1327        assert_eq!(diff_marks("x\ny\n", "x\ny\n"), [] as [(usize, LineMark); 0]);
1328    }
1329
1330    #[test]
1331    fn parse_blame_porcelain_extracts_fields() {
1332        let out = "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b 12 12 1\n\
1333            author Ada Lovelace\n\
1334            author-mail <ada@example.com>\n\
1335            author-time 1700000000\n\
1336            author-tz +0000\n\
1337            committer Ada Lovelace\n\
1338            summary Add the analytical engine\n\
1339            filename engine.rs\n\
1340            \tlet x = 1;\n";
1341        let b = parse_blame_porcelain(out).expect("parsed");
1342        assert_eq!(b.hash, "1a2b3c4d");
1343        assert_eq!(b.author, "Ada Lovelace");
1344        assert_eq!(b.date, "2023-11-14");
1345        assert_eq!(b.summary, "Add the analytical engine");
1346        assert!(!b.is_uncommitted());
1347    }
1348
1349    #[test]
1350    fn parse_blame_porcelain_flags_uncommitted() {
1351        let out = "0000000000000000000000000000000000000000 1 1 1\n\
1352            author Not Committed Yet\n\
1353            author-time 1700000000\n\
1354            author-tz +0000\n\
1355            summary Version of engine.rs from engine.rs\n\
1356            \tlet y = 2;\n";
1357        let b = parse_blame_porcelain(out).expect("parsed");
1358        assert!(b.is_uncommitted());
1359        assert_eq!(b.author, "Not Committed Yet");
1360    }
1361
1362    #[test]
1363    fn parse_blame_porcelain_none_without_header() {
1364        assert!(parse_blame_porcelain("not a blame\n").is_none());
1365    }
1366
1367    #[test]
1368    fn epoch_to_date_applies_tz_offset() {
1369        // 1700000000 is 2023-11-14 22:13:20 UTC; +0200 rolls it into the 15th.
1370        assert_eq!(epoch_to_date(1_700_000_000, 0), "2023-11-14");
1371        assert_eq!(epoch_to_date(1_700_000_000, 2 * 3600), "2023-11-15");
1372        assert_eq!(epoch_to_date(0, 0), "1970-01-01");
1373    }
1374
1375    #[test]
1376    fn change_letters() {
1377        assert_eq!(Change::Modified.letter(), 'M');
1378        assert_eq!(Change::Untracked.letter(), '?');
1379    }
1380
1381    // Runner smoke test against this very repository. Ignored by default because
1382    // it assumes `git` is installed and the tests run inside the work tree.
1383    #[cfg(feature = "process")]
1384    #[test]
1385    #[ignore = "needs git and an in-tree checkout"]
1386    fn runners_report_branch_in_this_repo() {
1387        let dir = Path::new(env!("CARGO_MANIFEST_DIR"));
1388        assert!(is_repo(dir));
1389        assert!(branch(dir).is_some());
1390    }
1391}