Skip to main content

strop_git/
memory.rs

1//! Git memory (M3, 0001 pillar 3.2/3.3): log graph, blame, permalinks.
2//! Reads via shell `git` (matches user config; not hot-path), permalinks
3//! via libgit2 config (no spawn).
4
5use std::path::{Path, PathBuf};
6
7use crate::Repo;
8
9/// One log line from `git log --graph`, with the commit hash extracted.
10#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
11pub struct LogRow {
12    /// The rendered graph+summary line (what the buffer shows).
13    pub text: String,
14    /// Full SHA when the line names a commit (graph-only lines: None).
15    pub sha: Option<String>,
16}
17
18/// `git log --graph` for the browser. Shells out — the log is not a
19/// per-keystroke path (0001 §3). Caller decides threading.
20pub fn log_graph(workdir: &Path, max: usize, file: Option<&Path>) -> Result<Vec<LogRow>, String> {
21    log_graph_range(workdir, max, file, None)
22}
23
24/// `git log -L start,end:path` — the history of a line range (0014 wave
25/// 4: selection archaeology). The graph flag is meaningless with -L;
26/// rows come straight from the patch headers.
27pub fn log_graph_range(
28    workdir: &Path,
29    max: usize,
30    file: Option<&Path>,
31    range: Option<(usize, usize)>,
32) -> Result<Vec<LogRow>, String> {
33    let mut cmd = std::process::Command::new("git");
34    let (marker_fmt, ranged) = match range {
35        Some(_) => ("%x01%h %an · %ar · %s%x00%H", true),
36        None => ("%h %an · %ar · %s%x00%H", false),
37    };
38    // workdir and file operands pass as OsStr: a non-UTF8 repo path or
39    // tracked filename must reach git byte-for-byte, not via a lossy
40    // display() rendering
41    cmd.arg("-C").arg(workdir).args([
42        "log",
43        &format!("--format={marker_fmt}"),
44        "-n",
45        &max.to_string(),
46    ]);
47    match (file, range) {
48        (Some(f), Some((a, b))) => {
49            // -L embeds the path in one argument; compose the OsString
50            // instead of formatting through display()
51            let mut spec = std::ffi::OsString::from(format!("-L{a},{b}:"));
52            spec.push(f);
53            cmd.arg(spec);
54        }
55        (Some(f), None) => {
56            cmd.arg("--graph").arg("--").arg(f);
57        }
58        (None, None) => {
59            cmd.arg("--graph");
60        }
61        (None, Some(_)) => return Err("-L needs a file".into()),
62    }
63    let out = cmd.output().map_err(|e| format!("spawn git log: {e}"))?;
64    if !out.status.success() {
65        return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
66    }
67    let text = String::from_utf8_lossy(&out.stdout);
68    Ok(text
69        .lines()
70        // -L output carries patch text; only marked lines are commits
71        .filter(|line| !ranged || line.starts_with('\x01'))
72        .map(|line| {
73            let line = line.strip_prefix('\x01').unwrap_or(line);
74            // the format hides the full SHA after a NUL
75            let (vis, sha) = match line.split_once('\0') {
76                Some((v, s)) => (v.to_string(), Some(s.trim().to_string())),
77                None => (line.to_string(), None),
78            };
79            LogRow { text: vis, sha }
80        })
81        .collect())
82}
83
84/// A blame card for one line (0001 pillar 3.3).
85#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
86pub struct BlameCard {
87    pub sha: String,
88    pub short_sha: String,
89    pub author: String,
90    pub age: String,
91    pub summary: String,
92    pub line: usize,
93}
94
95/// Blame one line of a file (1-based). Shells out; porcelain format.
96pub fn blame_line(workdir: &Path, rel: &Path, line: usize) -> Result<BlameCard, String> {
97    let out = std::process::Command::new("git")
98        .arg("-C")
99        .arg(workdir)
100        .args(["blame", "--line-porcelain", "-L", &format!("{line},{line}")])
101        .arg("--")
102        .arg(rel)
103        .output()
104        .map_err(|e| format!("spawn git blame: {e}"))?;
105    if !out.status.success() {
106        return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
107    }
108    let text = String::from_utf8_lossy(&out.stdout);
109    let mut sha = String::new();
110    let mut author = String::new();
111    let mut summary = String::new();
112    let mut ts = 0i64;
113    for l in text.lines() {
114        if sha.is_empty()
115            && !l.starts_with('\t')
116            && l.chars().take(8).all(|c| c.is_ascii_hexdigit())
117        {
118            sha = l.split_whitespace().next().unwrap_or("").to_string();
119        } else if let Some(a) = l.strip_prefix("author ") {
120            author = a.to_string();
121        } else if let Some(t) = l.strip_prefix("author-time ") {
122            ts = t.parse().unwrap_or(0);
123        } else if let Some(s) = l.strip_prefix("summary ") {
124            summary = s.to_string();
125        }
126    }
127    if sha.is_empty() {
128        return Err("no blame for line".into());
129    }
130    Ok(BlameCard {
131        short_sha: sha.chars().take(8).collect(),
132        sha,
133        author,
134        age: rel_age(ts),
135        summary,
136        line,
137    })
138}
139
140/// One line of a whole-file blame (0001 pillar 3.3, the toggleable
141/// column). `age` is rendered at parse time; `ts` keeps "recent"
142/// honest for the caller's coloring.
143#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
144pub struct BlameLine {
145    pub sha: String,
146    pub author: String,
147    /// Human short form ("3h", "2d", "5mo"); "now" when uncommitted.
148    pub age: String,
149    /// Author time, unix seconds (0 = uncommitted).
150    pub ts: i64,
151}
152
153impl BlameLine {
154    /// Worktree lines git blame attributes to nobody (all-zero sha).
155    pub fn is_uncommitted(&self) -> bool {
156        !self.sha.is_empty() && self.sha.chars().all(|c| c == '0')
157    }
158}
159
160/// Blame every line of a file (`--line-porcelain`; the gutter's data,
161/// 0011 §3). Shells out on a job thread — never the input path.
162pub fn blame_file(workdir: &Path, rel: &Path) -> Result<Vec<BlameLine>, String> {
163    let out = std::process::Command::new("git")
164        .arg("-C")
165        .arg(workdir)
166        .args(["blame", "--line-porcelain"])
167        .arg("--")
168        .arg(rel)
169        .output()
170        .map_err(|e| format!("spawn git blame: {e}"))?;
171    if !out.status.success() {
172        return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
173    }
174    let mut lines = Vec::new();
175    let mut sha = String::new();
176    let mut author = String::new();
177    let mut ts = 0i64;
178    for l in String::from_utf8_lossy(&out.stdout).lines() {
179        if let Some(content) = l.strip_prefix('\t') {
180            // the record's content row closes it — porcelain repeats
181            // the full header per line, so every tab row emits one
182            let _ = content;
183            if !sha.is_empty() {
184                let uncommitted = sha.chars().all(|c| c == '0');
185                lines.push(BlameLine {
186                    sha: sha.clone(),
187                    age: if uncommitted {
188                        "now".into()
189                    } else {
190                        rel_age(ts)
191                    },
192                    author: if uncommitted {
193                        "you".into()
194                    } else {
195                        author.clone()
196                    },
197                    ts: if uncommitted { 0 } else { ts },
198                });
199            }
200            sha.clear();
201            author.clear();
202            ts = 0;
203        } else if sha.is_empty()
204            && !l.is_empty()
205            && l.chars().take(40).all(|c| c.is_ascii_hexdigit())
206        {
207            sha = l.split_whitespace().next().unwrap_or("").to_string();
208        } else if let Some(a) = l.strip_prefix("author ") {
209            author = a.to_string();
210        } else if let Some(t) = l.strip_prefix("author-time ") {
211            ts = t.parse().unwrap_or(0);
212        }
213    }
214    if lines.is_empty() {
215        return Err("no blame for file".into());
216    }
217    Ok(lines)
218}
219
220/// Files changed by a commit: `path | +N -M` rows for the dive view.
221#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
222pub struct ChangedFile {
223    #[serde(with = "strop_core::path_serde")]
224    pub path: PathBuf,
225    pub added: usize,
226    pub deleted: usize,
227}
228
229/// Files changed by a commit: `path | +N -M` rows for the dive view.
230/// Paths come from numstat's NUL-delimited machine form, so native —
231/// never C-quoted, possibly non-UTF8 — names arrive as the worktree
232/// identities `commit_file_diff` expects.
233pub fn show_stat(workdir: &Path, sha: &str) -> Result<Vec<ChangedFile>, String> {
234    let out = std::process::Command::new("git")
235        .arg("-C")
236        .arg(workdir)
237        .args(["show", "--numstat", "-z", "--format=", sha])
238        .output()
239        .map_err(|e| format!("spawn git show: {e}"))?;
240    if !out.status.success() {
241        return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
242    }
243    crate::numstat::parse_numstat(&out.stdout)
244}
245
246// ---- permalinks ----------------------------------------------------------
247
248#[derive(Debug, Clone, PartialEq, Eq)]
249pub enum Host {
250    GitHub,
251    GitLab,
252    Bitbucket,
253    Gitea,
254    /// Unknown host: emit whatever HTTPS we can normalize to.
255    Other,
256}
257
258pub struct Remote {
259    pub host: Host,
260    pub owner_repo: String, // "org/repo"
261    pub base: String,       // "https://github.com"
262}
263
264/// Normalize a remote URL (SSH or HTTPS) to a web base. Priority
265/// upstream > origin > rest is the caller's job (0001 pillar 3.3).
266pub fn normalize_remote(url: &str) -> Option<Remote> {
267    let url = url.trim().trim_end_matches(".git");
268    let (base, path) = if let Some(rest) = url.strip_prefix("git@") {
269        // git@host:org/repo
270        let (host, path) = rest.split_once(':')?;
271        (format!("https://{host}"), path.to_string())
272    } else if let Some(rest) = url.strip_prefix("ssh://git@") {
273        // ssh://git@host/org/repo
274        let rest = rest.split('/').collect::<Vec<_>>();
275        let host = rest.first()?;
276        (format!("https://{host}"), rest[1..].join("/"))
277    } else if url.starts_with("https://") || url.starts_with("http://") {
278        let stripped = url
279            .strip_prefix("https://")
280            .or_else(|| url.strip_prefix("http://"))?;
281        let (host, path) = stripped.split_once('/')?;
282        (format!("https://{host}"), path.to_string())
283    } else if let Some((host, path)) = url.split_once(':') {
284        // scp syntax without user@: bare hostname or an ssh host alias
285        // (`bbgithub:org/repo` — ~/.ssh/config supplies the real host)
286        if host.contains('@') || host.contains('/') {
287            return None;
288        }
289        let host = resolve_ssh_alias(host).unwrap_or_else(|| host.to_string());
290        (format!("https://{host}"), path.to_string())
291    } else {
292        return None;
293    };
294    let host = match base.as_str() {
295        "https://github.com" => Host::GitHub,
296        "https://gitlab.com" => Host::GitLab,
297        "https://bitbucket.org" => Host::Bitbucket,
298        b if b.contains("gitea") => Host::Gitea,
299        _ => Host::Other,
300    };
301    Some(Remote {
302        host,
303        owner_repo: path,
304        base,
305    })
306}
307
308/// Resolve an ssh host alias via `~/.ssh/config` Host blocks (exact
309/// matches; wildcard blocks skipped). Enterprise GitHub setups live on
310/// these — the alias exists so the hostname isn't repeated per clone.
311fn resolve_ssh_alias(alias: &str) -> Option<String> {
312    let home = std::env::var_os("HOME")?;
313    let config = std::fs::read_to_string(PathBuf::from(home).join(".ssh").join("config")).ok()?;
314    parse_ssh_alias(&config, alias)
315}
316
317fn parse_ssh_alias(config: &str, alias: &str) -> Option<String> {
318    let mut in_block = false;
319    for line in config.lines() {
320        let line = line.trim();
321        if line.is_empty() || line.starts_with('#') {
322            continue;
323        }
324        let mut parts = line.split_whitespace();
325        match parts.next().map(|k| k.to_ascii_lowercase()).as_deref() {
326            Some("host") => in_block = parts.any(|h| h == alias),
327            Some("hostname") if in_block => return parts.next().map(|h| h.to_string()),
328            _ => {}
329        }
330    }
331    None
332}
333
334/// Pick the permalink remote: upstream > origin > first remaining.
335pub fn pick_remote(repo: &Repo) -> Option<Remote> {
336    pick_remote_from(&repo.remotes())
337}
338
339/// The pure fold over cached remotes (R6): permalink selection needs
340/// no repository handle, only the (name, url) pairs a `GitContext`
341/// already carries.
342pub fn pick_remote_from(remotes: &[(String, String)]) -> Option<Remote> {
343    for name in ["upstream", "origin"] {
344        if let Some(url) = remotes.iter().find(|(n, _)| n == name).map(|(_, u)| u) {
345            if let Some(r) = normalize_remote(url) {
346                return Some(r);
347            }
348        }
349    }
350    remotes.iter().find_map(|(_, u)| normalize_remote(u))
351}
352
353/// Build the immutable permalink for a file at 1-based lines. Branch is
354/// always resolved to a commit SHA (0001 pillar 3.3).
355/// The URL for a revisioned location (0014): pinned to the location's
356/// revision — a commit surface links that commit, not HEAD.
357pub fn permalink(repo: &Repo, loc: &crate::SourceLocation) -> Option<String> {
358    permalink_with(
359        &repo.remotes(),
360        &|revision| match revision {
361            crate::GitRevision::Head | crate::GitRevision::Index | crate::GitRevision::Worktree => {
362                repo.head_sha()
363            }
364            crate::GitRevision::Commit(sha) => Some(sha.clone()),
365            crate::GitRevision::MergeBase(a, b) => repo.merge_base(a, b),
366        },
367        loc,
368    )
369}
370
371/// The pure permalink builder (R6): cached remotes plus a revision
372/// resolver — no repository handle, no native work on the caller's
373/// thread.
374pub fn permalink_with(
375    remotes: &[(String, String)],
376    resolve: &dyn Fn(&crate::GitRevision) -> Option<String>,
377    loc: &crate::SourceLocation,
378) -> Option<String> {
379    let remote = pick_remote_from(remotes)?;
380    let (start_line, end_line) = loc.lines.unwrap_or((1, 1));
381    let sha = resolve(&loc.revision)?;
382    let frag = if start_line == end_line {
383        format!("#L{start_line}")
384    } else {
385        format!("#L{start_line}-L{end_line}")
386    };
387    Some(format!(
388        "{}/{}/blob/{}/{}{frag}",
389        remote.base,
390        remote.owner_repo,
391        sha,
392        loc.path.display()
393    ))
394}
395
396/// Relative age, human short form ("3h", "2d", "5mo").
397fn rel_age(ts: i64) -> String {
398    let now = std::time::SystemTime::now()
399        .duration_since(std::time::UNIX_EPOCH)
400        .map(|d| d.as_secs() as i64)
401        .unwrap_or(0);
402    let age = (now - ts).max(0);
403    match age {
404        a if a < 3600 => format!("{}m", a / 60),
405        a if a < 86400 => format!("{}h", a / 3600),
406        a if a < 86400 * 30 => format!("{}d", a / 86400),
407        a if a < 86400 * 365 => format!("{}mo", a / (86400 * 30)),
408        a => format!("{}y", a / (86400 * 365)),
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn ssh_alias_resolves_via_config() {
418        let config = "# comment\nHost bbgithub\n  HostName bbgithub.dev.bloomberg.com\n  User git\nHost *\n  ServerAliveInterval 30\n";
419        assert_eq!(
420            parse_ssh_alias(config, "bbgithub").as_deref(),
421            Some("bbgithub.dev.bloomberg.com")
422        );
423        assert_eq!(parse_ssh_alias(config, "other"), None);
424        // wildcard-only blocks don't claim aliases
425        assert_eq!(parse_ssh_alias("Host *\n  HostName x", "bbgithub"), None);
426    }
427
428    #[test]
429    fn scp_without_user_parses_as_bare_host() {
430        // unresolved alias falls back to the bare name (matches what git
431        // itself would attempt) — but with a config entry it resolves
432        let r = normalize_remote("bbgithub:acme/demo.git");
433        assert!(r.is_some(), "alias form parses");
434    }
435
436    #[test]
437    fn reviewer_table() {
438        // the first-week report's remote table, verbatim
439        for url in [
440            "https://github.com/acme/demo.git",
441            "ssh://git@github.com/acme/demo.git",
442            "git@github.com:acme/demo",
443            "git@bbgithub.dev.bloomberg.com:acme/demo.git",
444            "https://bbgithub.dev.bloomberg.com/acme/demo.git",
445        ] {
446            let r = normalize_remote(url);
447            assert!(r.is_some(), "should parse: {url}");
448        }
449        // the ssh host-alias form parses (bare-host fallback; resolves
450        // via ~/.ssh/config when an entry exists)
451        assert!(normalize_remote("bbgithub:acme/demo.git").is_some());
452    }
453
454    #[test]
455    fn normalizes_ssh_and_https() {
456        let r = normalize_remote("git@github.com:stropdev/strop.git").unwrap();
457        assert_eq!(
458            (r.base.as_str(), r.owner_repo.as_str()),
459            ("https://github.com", "stropdev/strop")
460        );
461        assert_eq!(r.host, Host::GitHub);
462        let r = normalize_remote("https://gitlab.com/org/proj").unwrap();
463        assert_eq!(r.host, Host::GitLab);
464        assert_eq!(r.owner_repo, "org/proj");
465        let r = normalize_remote("ssh://git@bitbucket.org/team/repo.git").unwrap();
466        assert_eq!(r.host, Host::Bitbucket);
467        assert!(normalize_remote("not a url").is_none());
468    }
469
470    /// Repo with two commits (f.rs grows a line), then a dirty edit —
471    /// blame_file must attribute committed lines and flag dirty ones.
472    #[test]
473    fn blame_file_attributes_lines() {
474        let dir = tempfile::tempdir().unwrap();
475        let root = dir.path();
476        let git = |args: &[&str]| {
477            std::process::Command::new("git")
478                .args(args)
479                .current_dir(root)
480                .output()
481                .unwrap();
482        };
483        git(&["init", "-q"]);
484        git(&["config", "user.email", "t@t.t"]);
485        git(&["config", "user.name", "t"]);
486        std::fs::write(root.join("f.rs"), "one\n").unwrap();
487        git(&["add", "."]);
488        git(&["commit", "-qm", "first"]);
489        std::fs::write(root.join("f.rs"), "one\ntwo\n").unwrap();
490        git(&["commit", "-qam", "second"]);
491
492        let clean = blame_file(root, Path::new("f.rs")).unwrap();
493        assert_eq!(clean.len(), 2, "one BlameLine per file line");
494        assert_eq!(clean[0].author, "t");
495        assert_eq!(clean[1].author, "t");
496        assert_ne!(clean[0].sha, clean[1].sha, "two commits, two shas");
497        assert!(!clean[0].is_uncommitted());
498
499        // dirty worktree: the new line belongs to nobody
500        std::fs::write(root.join("f.rs"), "one\ntwo\nthree\n").unwrap();
501        let dirty = blame_file(root, Path::new("f.rs")).unwrap();
502        assert_eq!(dirty.len(), 3);
503        assert!(dirty[2].is_uncommitted(), "last line is uncommitted");
504        assert_eq!(dirty[2].age, "now");
505        assert_eq!(dirty[2].author, "you");
506        assert_eq!(dirty[2].ts, 0);
507    }
508
509    #[test]
510    fn blame_file_rejects_missing_file() {
511        let dir = tempfile::tempdir().unwrap();
512        assert!(blame_file(dir.path(), Path::new("nope.rs")).is_err());
513    }
514
515    /// Hermetic git: no reads of the real HOME or system/global config,
516    /// no network, no sleeps. Returns trimmed stdout for rev-parse.
517    fn git_here(root: &Path, args: &[&str]) -> String {
518        let out = std::process::Command::new("git")
519            .args(args)
520            .current_dir(root)
521            .env("HOME", root)
522            .env("XDG_CONFIG_HOME", root.join(".xdg"))
523            .env("GIT_CONFIG_NOSYSTEM", "1")
524            .env("GIT_CONFIG_GLOBAL", "/dev/null")
525            .output()
526            .unwrap();
527        assert!(
528            out.status.success(),
529            "git {args:?}: {}",
530            String::from_utf8_lossy(&out.stderr).trim()
531        );
532        String::from_utf8_lossy(&out.stdout).trim().to_string()
533    }
534
535    /// The review-repo bug: `src/日本語.rs` reached ChangedFiles as a
536    /// C-quoted octal escape and renames as `old => new`, neither a
537    /// worktree identity. Under `-z` the native names must come back —
538    /// ordinary, Unicode, rename (destination only) and binary rows all
539    /// present.
540    #[test]
541    fn show_stat_keeps_native_paths() {
542        let dir = tempfile::tempdir().unwrap();
543        let root = dir.path();
544        git_here(root, &["init", "-q"]);
545        git_here(root, &["config", "user.email", "t@t.t"]);
546        git_here(root, &["config", "user.name", "t"]);
547        std::fs::create_dir(root.join("src")).unwrap();
548        std::fs::write(root.join("a.rs"), "one\n").unwrap();
549        std::fs::write(root.join("src/日本語.rs"), "fn x() {}\n").unwrap();
550        std::fs::write(root.join("ren.txt"), "old\n").unwrap();
551        std::fs::write(root.join("bin.dat"), b"\0\x01binary\0").unwrap();
552        git_here(root, &["add", "."]);
553        git_here(root, &["commit", "-qm", "first"]);
554        git_here(root, &["mv", "ren.txt", "new.txt"]);
555        std::fs::write(root.join("a.rs"), "one\ntwo\nthree\n").unwrap();
556        std::fs::write(root.join("src/日本語.rs"), "fn x() {}\nfn y() {}\n").unwrap();
557        std::fs::write(root.join("bin.dat"), b"\0\x01changed\0").unwrap();
558        git_here(root, &["add", "."]);
559        git_here(root, &["commit", "-qm", "second"]);
560        let sha = git_here(root, &["rev-parse", "HEAD"]);
561
562        let files = show_stat(root, &sha).unwrap();
563        assert_eq!(files.len(), 4, "{files:?}");
564        let row = |p: &str| {
565            files
566                .iter()
567                .find(|f| f.path == Path::new(p))
568                .unwrap_or_else(|| panic!("missing {p} in {files:?}"))
569        };
570        assert_eq!(row("a.rs").added, 2);
571        assert_eq!(row("a.rs").deleted, 0);
572        // the Unicode identity arrives native, never `"src/\346..."`
573        assert_eq!(row("src/日本語.rs").added, 1);
574        // rename: only the destination is a row
575        assert_eq!(row("new.txt").added, 0);
576        assert!(!files.iter().any(|f| f.path == Path::new("ren.txt")));
577        // binary: the row survives with deliberate 0/0 counts
578        assert_eq!((row("bin.dat").added, row("bin.dat").deleted), (0, 0));
579        assert!(files
580            .iter()
581            .all(|f| !f.path.to_string_lossy().starts_with('"')));
582    }
583
584    /// show_stat's paths are real identities: hand one straight to the
585    /// git2-based diff the dive opens next.
586    #[test]
587    fn show_stat_paths_feed_commit_file_diff() {
588        let dir = tempfile::tempdir().unwrap();
589        let root = dir.path();
590        git_here(root, &["init", "-q"]);
591        git_here(root, &["config", "user.email", "t@t.t"]);
592        git_here(root, &["config", "user.name", "t"]);
593        std::fs::create_dir(root.join("src")).unwrap();
594        std::fs::write(root.join("src/日本語.rs"), "fn x() {}\n").unwrap();
595        git_here(root, &["add", "."]);
596        git_here(root, &["commit", "-qm", "first"]);
597        std::fs::write(
598            root.join("src/日本語.rs"),
599            "fn x() {}\nfn y() {}\nfn z() {}\n",
600        )
601        .unwrap();
602        git_here(root, &["commit", "-qam", "second"]);
603        let sha = git_here(root, &["rev-parse", "HEAD"]);
604
605        let files = show_stat(root, &sha).unwrap();
606        let uni = files
607            .iter()
608            .find(|f| f.path == Path::new("src/日本語.rs"))
609            .expect("native unicode path is a row");
610        let repo = Repo::discover(root).unwrap();
611        let diff = repo.commit_file_diff(&sha, &uni.path).unwrap();
612        assert_eq!(diff.added, 2);
613        assert_eq!(diff.deleted, 0);
614    }
615
616    /// Unix filenames may be non-UTF8; they must arrive byte-for-byte,
617    /// not through a quoted or lossy spelling.
618    #[cfg(unix)]
619    #[test]
620    fn show_stat_preserves_non_utf8_paths() {
621        use std::os::unix::ffi::OsStrExt;
622        let dir = tempfile::tempdir().unwrap();
623        let root = dir.path();
624        git_here(root, &["init", "-q"]);
625        git_here(root, &["config", "user.email", "t@t.t"]);
626        git_here(root, &["config", "user.name", "t"]);
627        std::fs::create_dir(root.join("src")).unwrap();
628        let name = std::ffi::OsStr::from_bytes(b"src/\xff\xfe.rs");
629        std::fs::write(root.join(name), "fn x() {}\n").unwrap();
630        git_here(root, &["add", "."]);
631        git_here(root, &["commit", "-qm", "first"]);
632        let sha = git_here(root, &["rev-parse", "HEAD"]);
633
634        let files = show_stat(root, &sha).unwrap();
635        assert_eq!(files.len(), 1, "{files:?}");
636        assert_eq!(files[0].path.as_os_str().as_bytes(), b"src/\xff\xfe.rs");
637        assert_eq!(files[0].added, 1);
638    }
639}