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