Skip to main content

strop_git/
memory.rs

1//! Git memory (M3, 0001 pillar 3.2/3.3): log graph, blame, changed-file
2//! stats. Reads via shell `git` (matches user config; not hot-path).
3//! Permalinks and remote normalization live in `permalink`/`ssh`
4//! (0033 finding 1).
5
6use std::path::{Path, PathBuf};
7
8/// One log line from `git log --graph`, with the commit hash extracted.
9#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10pub struct LogRow {
11    /// The rendered graph+summary line (what the buffer shows).
12    pub text: String,
13    /// Full SHA when the line names a commit (graph-only lines: None).
14    pub sha: Option<String>,
15}
16
17/// `git log --graph` for the browser. Shells out — the log is not a
18/// per-keystroke path (0001 §3). Caller decides threading.
19pub fn log_graph(workdir: &Path, max: usize, file: Option<&Path>) -> Result<Vec<LogRow>, String> {
20    log_graph_range(workdir, max, file, None)
21}
22
23/// `git log -L start,end:path` — the history of a line range (0014 wave
24/// 4: selection archaeology). The graph flag is meaningless with -L;
25/// rows come straight from the patch headers.
26pub fn log_graph_range(
27    workdir: &Path,
28    max: usize,
29    file: Option<&Path>,
30    range: Option<(usize, usize)>,
31) -> Result<Vec<LogRow>, String> {
32    let mut cmd = std::process::Command::new("git");
33    let (marker_fmt, ranged) = match range {
34        Some(_) => ("%x01%h %an · %ar · %s%x00%H", true),
35        None => ("%h %an · %ar · %s%x00%H", false),
36    };
37    // workdir and file operands pass as OsStr: a non-UTF8 repo path or
38    // tracked filename must reach git byte-for-byte, not via a lossy
39    // display() rendering
40    cmd.arg("-C").arg(workdir).args([
41        "log",
42        &format!("--format={marker_fmt}"),
43        "-n",
44        &max.to_string(),
45    ]);
46    match (file, range) {
47        (Some(f), Some((a, b))) => {
48            // -L embeds the path in one argument; compose the OsString
49            // instead of formatting through display()
50            let mut spec = std::ffi::OsString::from(format!("-L{a},{b}:"));
51            spec.push(f);
52            cmd.arg(spec);
53        }
54        (Some(f), None) => {
55            cmd.arg("--graph").arg("--").arg(f);
56        }
57        (None, None) => {
58            cmd.arg("--graph");
59        }
60        (None, Some(_)) => return Err("-L needs a file".into()),
61    }
62    let out = cmd.output().map_err(|e| format!("spawn git log: {e}"))?;
63    if !out.status.success() {
64        return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
65    }
66    let text = String::from_utf8_lossy(&out.stdout);
67    Ok(text
68        .lines()
69        // -L output carries patch text; only marked lines are commits
70        .filter(|line| !ranged || line.starts_with('\x01'))
71        .map(|line| {
72            let line = line.strip_prefix('\x01').unwrap_or(line);
73            // the format hides the full SHA after a NUL
74            let (vis, sha) = match line.split_once('\0') {
75                Some((v, s)) => (v.to_string(), Some(s.trim().to_string())),
76                None => (line.to_string(), None),
77            };
78            LogRow { text: vis, sha }
79        })
80        .collect())
81}
82
83/// A blame card for one line (0001 pillar 3.3).
84#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
85pub struct BlameCard {
86    pub sha: String,
87    pub short_sha: String,
88    pub author: String,
89    pub age: String,
90    pub summary: String,
91    pub line: usize,
92}
93
94/// Blame one line of a file (1-based). Shells out; porcelain format.
95pub fn blame_line(workdir: &Path, rel: &Path, line: usize) -> Result<BlameCard, String> {
96    let out = std::process::Command::new("git")
97        .arg("-C")
98        .arg(workdir)
99        .args(["blame", "--line-porcelain", "-L", &format!("{line},{line}")])
100        .arg("--")
101        .arg(rel)
102        .output()
103        .map_err(|e| format!("spawn git blame: {e}"))?;
104    if !out.status.success() {
105        return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
106    }
107    let text = String::from_utf8_lossy(&out.stdout);
108    let mut sha = String::new();
109    let mut author = String::new();
110    let mut summary = String::new();
111    let mut ts = 0i64;
112    for l in text.lines() {
113        if sha.is_empty()
114            && !l.starts_with('\t')
115            && l.chars().take(8).all(|c| c.is_ascii_hexdigit())
116        {
117            sha = l.split_whitespace().next().unwrap_or("").to_string();
118        } else if let Some(a) = l.strip_prefix("author ") {
119            author = a.to_string();
120        } else if let Some(t) = l.strip_prefix("author-time ") {
121            ts = t.parse().unwrap_or(0);
122        } else if let Some(s) = l.strip_prefix("summary ") {
123            summary = s.to_string();
124        }
125    }
126    if sha.is_empty() {
127        return Err("no blame for line".into());
128    }
129    Ok(BlameCard {
130        short_sha: sha.chars().take(8).collect(),
131        sha,
132        author,
133        age: rel_age(ts),
134        summary,
135        line,
136    })
137}
138
139/// One line of a whole-file blame (0001 pillar 3.3, the toggleable
140/// column). `age` is rendered at parse time; `ts` keeps "recent"
141/// honest for the caller's coloring.
142#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
143pub struct BlameLine {
144    pub sha: String,
145    pub author: String,
146    /// Human short form ("3h", "2d", "5mo"); "now" when uncommitted.
147    pub age: String,
148    /// Author time, unix seconds (0 = uncommitted).
149    pub ts: i64,
150}
151
152impl BlameLine {
153    /// Worktree lines git blame attributes to nobody (all-zero sha).
154    pub fn is_uncommitted(&self) -> bool {
155        !self.sha.is_empty() && self.sha.chars().all(|c| c == '0')
156    }
157}
158
159/// Blame every line of a file (`--line-porcelain`; the gutter's data,
160/// 0011 §3). Shells out on a job thread — never the input path.
161pub fn blame_file(workdir: &Path, rel: &Path) -> Result<Vec<BlameLine>, String> {
162    let out = std::process::Command::new("git")
163        .arg("-C")
164        .arg(workdir)
165        .args(["blame", "--line-porcelain"])
166        .arg("--")
167        .arg(rel)
168        .output()
169        .map_err(|e| format!("spawn git blame: {e}"))?;
170    if !out.status.success() {
171        return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
172    }
173    let mut lines = Vec::new();
174    let mut sha = String::new();
175    let mut author = String::new();
176    let mut ts = 0i64;
177    for l in String::from_utf8_lossy(&out.stdout).lines() {
178        if let Some(content) = l.strip_prefix('\t') {
179            // the record's content row closes it — porcelain repeats
180            // the full header per line, so every tab row emits one
181            let _ = content;
182            if !sha.is_empty() {
183                let uncommitted = sha.chars().all(|c| c == '0');
184                lines.push(BlameLine {
185                    sha: sha.clone(),
186                    age: if uncommitted {
187                        "now".into()
188                    } else {
189                        rel_age(ts)
190                    },
191                    author: if uncommitted {
192                        "you".into()
193                    } else {
194                        author.clone()
195                    },
196                    ts: if uncommitted { 0 } else { ts },
197                });
198            }
199            sha.clear();
200            author.clear();
201            ts = 0;
202        } else if sha.is_empty()
203            && !l.is_empty()
204            && l.chars().take(40).all(|c| c.is_ascii_hexdigit())
205        {
206            sha = l.split_whitespace().next().unwrap_or("").to_string();
207        } else if let Some(a) = l.strip_prefix("author ") {
208            author = a.to_string();
209        } else if let Some(t) = l.strip_prefix("author-time ") {
210            ts = t.parse().unwrap_or(0);
211        }
212    }
213    if lines.is_empty() {
214        return Err("no blame for file".into());
215    }
216    Ok(lines)
217}
218
219/// Files changed by a commit: `path | +N -M` rows for the dive view.
220#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
221pub struct ChangedFile {
222    #[serde(with = "strop_core::path_serde")]
223    pub path: PathBuf,
224    pub added: usize,
225    pub deleted: usize,
226}
227
228/// Files changed by a commit: `path | +N -M` rows for the dive view.
229/// Paths come from numstat's NUL-delimited machine form, so native —
230/// never C-quoted, possibly non-UTF8 — names arrive as the worktree
231/// identities `commit_file_diff` expects.
232pub fn show_stat(workdir: &Path, sha: &str) -> Result<Vec<ChangedFile>, String> {
233    let out = std::process::Command::new("git")
234        .arg("-C")
235        .arg(workdir)
236        .args(["show", "--numstat", "-z", "--format=", sha])
237        .output()
238        .map_err(|e| format!("spawn git show: {e}"))?;
239    if !out.status.success() {
240        return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
241    }
242    crate::numstat::parse_numstat(&out.stdout)
243}
244
245/// Relative age, human short form ("3h", "2d", "5mo").
246fn rel_age(ts: i64) -> String {
247    let now = std::time::SystemTime::now()
248        .duration_since(std::time::UNIX_EPOCH)
249        .map(|d| d.as_secs() as i64)
250        .unwrap_or(0);
251    let age = (now - ts).max(0);
252    match age {
253        a if a < 3600 => format!("{}m", a / 60),
254        a if a < 86400 => format!("{}h", a / 3600),
255        a if a < 86400 * 30 => format!("{}d", a / 86400),
256        a if a < 86400 * 365 => format!("{}mo", a / (86400 * 30)),
257        a => format!("{}y", a / (86400 * 365)),
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::Repo;
265
266    /// Repo with two commits (f.rs grows a line), then a dirty edit —
267    /// blame_file must attribute committed lines and flag dirty ones.
268    #[test]
269    fn blame_file_attributes_lines() {
270        let dir = tempfile::tempdir().unwrap();
271        let root = dir.path();
272        let git = |args: &[&str]| {
273            std::process::Command::new("git")
274                .args(args)
275                .current_dir(root)
276                .output()
277                .unwrap();
278        };
279        git(&["init", "-q"]);
280        git(&["config", "user.email", "t@t.t"]);
281        git(&["config", "user.name", "t"]);
282        std::fs::write(root.join("f.rs"), "one\n").unwrap();
283        git(&["add", "."]);
284        git(&["commit", "-qm", "first"]);
285        std::fs::write(root.join("f.rs"), "one\ntwo\n").unwrap();
286        git(&["commit", "-qam", "second"]);
287
288        let clean = blame_file(root, Path::new("f.rs")).unwrap();
289        assert_eq!(clean.len(), 2, "one BlameLine per file line");
290        assert_eq!(clean[0].author, "t");
291        assert_eq!(clean[1].author, "t");
292        assert_ne!(clean[0].sha, clean[1].sha, "two commits, two shas");
293        assert!(!clean[0].is_uncommitted());
294
295        // dirty worktree: the new line belongs to nobody
296        std::fs::write(root.join("f.rs"), "one\ntwo\nthree\n").unwrap();
297        let dirty = blame_file(root, Path::new("f.rs")).unwrap();
298        assert_eq!(dirty.len(), 3);
299        assert!(dirty[2].is_uncommitted(), "last line is uncommitted");
300        assert_eq!(dirty[2].age, "now");
301        assert_eq!(dirty[2].author, "you");
302        assert_eq!(dirty[2].ts, 0);
303    }
304
305    #[test]
306    fn blame_file_rejects_missing_file() {
307        let dir = tempfile::tempdir().unwrap();
308        assert!(blame_file(dir.path(), Path::new("nope.rs")).is_err());
309    }
310
311    /// Hermetic git: no reads of the real HOME or system/global config,
312    /// no network, no sleeps. Returns trimmed stdout for rev-parse.
313    fn git_here(root: &Path, args: &[&str]) -> String {
314        let out = std::process::Command::new("git")
315            .args(args)
316            .current_dir(root)
317            .env("HOME", root)
318            .env("XDG_CONFIG_HOME", root.join(".xdg"))
319            .env("GIT_CONFIG_NOSYSTEM", "1")
320            .env("GIT_CONFIG_GLOBAL", "/dev/null")
321            .output()
322            .unwrap();
323        assert!(
324            out.status.success(),
325            "git {args:?}: {}",
326            String::from_utf8_lossy(&out.stderr).trim()
327        );
328        String::from_utf8_lossy(&out.stdout).trim().to_string()
329    }
330
331    /// The review-repo bug: `src/日本語.rs` reached ChangedFiles as a
332    /// C-quoted octal escape and renames as `old => new`, neither a
333    /// worktree identity. Under `-z` the native names must come back —
334    /// ordinary, Unicode, rename (destination only) and binary rows all
335    /// present.
336    #[test]
337    fn show_stat_keeps_native_paths() {
338        let dir = tempfile::tempdir().unwrap();
339        let root = dir.path();
340        git_here(root, &["init", "-q"]);
341        git_here(root, &["config", "user.email", "t@t.t"]);
342        git_here(root, &["config", "user.name", "t"]);
343        std::fs::create_dir(root.join("src")).unwrap();
344        std::fs::write(root.join("a.rs"), "one\n").unwrap();
345        std::fs::write(root.join("src/日本語.rs"), "fn x() {}\n").unwrap();
346        std::fs::write(root.join("ren.txt"), "old\n").unwrap();
347        std::fs::write(root.join("bin.dat"), b"\0\x01binary\0").unwrap();
348        git_here(root, &["add", "."]);
349        git_here(root, &["commit", "-qm", "first"]);
350        git_here(root, &["mv", "ren.txt", "new.txt"]);
351        std::fs::write(root.join("a.rs"), "one\ntwo\nthree\n").unwrap();
352        std::fs::write(root.join("src/日本語.rs"), "fn x() {}\nfn y() {}\n").unwrap();
353        std::fs::write(root.join("bin.dat"), b"\0\x01changed\0").unwrap();
354        git_here(root, &["add", "."]);
355        git_here(root, &["commit", "-qm", "second"]);
356        let sha = git_here(root, &["rev-parse", "HEAD"]);
357
358        let files = show_stat(root, &sha).unwrap();
359        assert_eq!(files.len(), 4, "{files:?}");
360        let row = |p: &str| {
361            files
362                .iter()
363                .find(|f| f.path == Path::new(p))
364                .unwrap_or_else(|| panic!("missing {p} in {files:?}"))
365        };
366        assert_eq!(row("a.rs").added, 2);
367        assert_eq!(row("a.rs").deleted, 0);
368        // the Unicode identity arrives native, never `"src/\346..."`
369        assert_eq!(row("src/日本語.rs").added, 1);
370        // rename: only the destination is a row
371        assert_eq!(row("new.txt").added, 0);
372        assert!(!files.iter().any(|f| f.path == Path::new("ren.txt")));
373        // binary: the row survives with deliberate 0/0 counts
374        assert_eq!((row("bin.dat").added, row("bin.dat").deleted), (0, 0));
375        assert!(files
376            .iter()
377            .all(|f| !f.path.to_string_lossy().starts_with('"')));
378    }
379
380    /// show_stat's paths are real identities: hand one straight to the
381    /// git2-based diff the dive opens next.
382    #[test]
383    fn show_stat_paths_feed_commit_file_diff() {
384        let dir = tempfile::tempdir().unwrap();
385        let root = dir.path();
386        git_here(root, &["init", "-q"]);
387        git_here(root, &["config", "user.email", "t@t.t"]);
388        git_here(root, &["config", "user.name", "t"]);
389        std::fs::create_dir(root.join("src")).unwrap();
390        std::fs::write(root.join("src/日本語.rs"), "fn x() {}\n").unwrap();
391        git_here(root, &["add", "."]);
392        git_here(root, &["commit", "-qm", "first"]);
393        std::fs::write(
394            root.join("src/日本語.rs"),
395            "fn x() {}\nfn y() {}\nfn z() {}\n",
396        )
397        .unwrap();
398        git_here(root, &["commit", "-qam", "second"]);
399        let sha = git_here(root, &["rev-parse", "HEAD"]);
400
401        let files = show_stat(root, &sha).unwrap();
402        let uni = files
403            .iter()
404            .find(|f| f.path == Path::new("src/日本語.rs"))
405            .expect("native unicode path is a row");
406        let repo = Repo::discover(root).unwrap();
407        let diff = repo.commit_file_diff(&sha, &uni.path).unwrap();
408        assert_eq!(diff.added, 2);
409        assert_eq!(diff.deleted, 0);
410    }
411
412    /// Unix filenames may be non-UTF8; they must arrive byte-for-byte,
413    /// not through a quoted or lossy spelling.
414    #[cfg(unix)]
415    #[test]
416    fn show_stat_preserves_non_utf8_paths() {
417        use std::os::unix::ffi::OsStrExt;
418        let dir = tempfile::tempdir().unwrap();
419        let root = dir.path();
420        git_here(root, &["init", "-q"]);
421        git_here(root, &["config", "user.email", "t@t.t"]);
422        git_here(root, &["config", "user.name", "t"]);
423        std::fs::create_dir(root.join("src")).unwrap();
424        let name = std::ffi::OsStr::from_bytes(b"src/\xff\xfe.rs");
425        std::fs::write(root.join(name), "fn x() {}\n").unwrap();
426        git_here(root, &["add", "."]);
427        git_here(root, &["commit", "-qm", "first"]);
428        let sha = git_here(root, &["rev-parse", "HEAD"]);
429
430        let files = show_stat(root, &sha).unwrap();
431        assert_eq!(files.len(), 1, "{files:?}");
432        assert_eq!(files[0].path.as_os_str().as_bytes(), b"src/\xff\xfe.rs");
433        assert_eq!(files[0].added, 1);
434    }
435}