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    cmd.args([
39        "-C",
40        &workdir.display().to_string(),
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            cmd.arg(format!("-L{a},{b}:{}", f.display()));
49        }
50        (Some(f), None) => {
51            cmd.arg("--graph").arg("--").arg(f);
52        }
53        (None, None) => {
54            cmd.arg("--graph");
55        }
56        (None, Some(_)) => return Err("-L needs a file".into()),
57    }
58    let out = cmd.output().map_err(|e| format!("spawn git log: {e}"))?;
59    if !out.status.success() {
60        return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
61    }
62    let text = String::from_utf8_lossy(&out.stdout);
63    Ok(text
64        .lines()
65        // -L output carries patch text; only marked lines are commits
66        .filter(|line| !ranged || line.starts_with('\x01'))
67        .map(|line| {
68            let line = line.strip_prefix('\x01').unwrap_or(line);
69            // the format hides the full SHA after a NUL
70            let (vis, sha) = match line.split_once('\0') {
71                Some((v, s)) => (v.to_string(), Some(s.trim().to_string())),
72                None => (line.to_string(), None),
73            };
74            LogRow { text: vis, sha }
75        })
76        .collect())
77}
78
79/// A blame card for one line (0001 pillar 3.3).
80#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
81pub struct BlameCard {
82    pub sha: String,
83    pub short_sha: String,
84    pub author: String,
85    pub age: String,
86    pub summary: String,
87    pub line: usize,
88}
89
90/// Blame one line of a file (1-based). Shells out; porcelain format.
91pub fn blame_line(workdir: &Path, rel: &Path, line: usize) -> Result<BlameCard, String> {
92    let out = std::process::Command::new("git")
93        .args([
94            "-C",
95            &workdir.display().to_string(),
96            "blame",
97            "--line-porcelain",
98            "-L",
99            &format!("{line},{line}"),
100            "--",
101            &rel.display().to_string(),
102        ])
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        .args([
165            "-C",
166            &workdir.display().to_string(),
167            "blame",
168            "--line-porcelain",
169            "--",
170            &rel.display().to_string(),
171        ])
172        .output()
173        .map_err(|e| format!("spawn git blame: {e}"))?;
174    if !out.status.success() {
175        return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
176    }
177    let mut lines = Vec::new();
178    let mut sha = String::new();
179    let mut author = String::new();
180    let mut ts = 0i64;
181    for l in String::from_utf8_lossy(&out.stdout).lines() {
182        if let Some(content) = l.strip_prefix('\t') {
183            // the record's content row closes it — porcelain repeats
184            // the full header per line, so every tab row emits one
185            let _ = content;
186            if !sha.is_empty() {
187                let uncommitted = sha.chars().all(|c| c == '0');
188                lines.push(BlameLine {
189                    sha: sha.clone(),
190                    age: if uncommitted {
191                        "now".into()
192                    } else {
193                        rel_age(ts)
194                    },
195                    author: if uncommitted {
196                        "you".into()
197                    } else {
198                        author.clone()
199                    },
200                    ts: if uncommitted { 0 } else { ts },
201                });
202            }
203            sha.clear();
204            author.clear();
205            ts = 0;
206        } else if sha.is_empty()
207            && !l.is_empty()
208            && l.chars().take(40).all(|c| c.is_ascii_hexdigit())
209        {
210            sha = l.split_whitespace().next().unwrap_or("").to_string();
211        } else if let Some(a) = l.strip_prefix("author ") {
212            author = a.to_string();
213        } else if let Some(t) = l.strip_prefix("author-time ") {
214            ts = t.parse().unwrap_or(0);
215        }
216    }
217    if lines.is_empty() {
218        return Err("no blame for file".into());
219    }
220    Ok(lines)
221}
222
223/// Files changed by a commit: `path | +N -M` rows for the dive view.
224#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
225pub struct ChangedFile {
226    #[serde(with = "strop_core::path_serde")]
227    pub path: PathBuf,
228    pub added: usize,
229    pub deleted: usize,
230}
231
232pub fn show_stat(workdir: &Path, sha: &str) -> Result<Vec<ChangedFile>, String> {
233    let out = std::process::Command::new("git")
234        .args([
235            "-C",
236            &workdir.display().to_string(),
237            "show",
238            "--numstat",
239            "--format=",
240            sha,
241        ])
242        .output()
243        .map_err(|e| format!("spawn git show: {e}"))?;
244    if !out.status.success() {
245        return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
246    }
247    Ok(String::from_utf8_lossy(&out.stdout)
248        .lines()
249        .filter_map(|l| {
250            let mut parts = l.split('\t');
251            let added = parts.next()?.parse().ok()?;
252            let deleted = parts.next()?.parse().ok()?;
253            Some(ChangedFile {
254                path: PathBuf::from(parts.next()?),
255                added,
256                deleted,
257            })
258        })
259        .collect())
260}
261
262// ---- permalinks ----------------------------------------------------------
263
264#[derive(Debug, Clone, PartialEq, Eq)]
265pub enum Host {
266    GitHub,
267    GitLab,
268    Bitbucket,
269    Gitea,
270    /// Unknown host: emit whatever HTTPS we can normalize to.
271    Other,
272}
273
274pub struct Remote {
275    pub host: Host,
276    pub owner_repo: String, // "org/repo"
277    pub base: String,       // "https://github.com"
278}
279
280/// Normalize a remote URL (SSH or HTTPS) to a web base. Priority
281/// upstream > origin > rest is the caller's job (0001 pillar 3.3).
282pub fn normalize_remote(url: &str) -> Option<Remote> {
283    let url = url.trim().trim_end_matches(".git");
284    let (base, path) = if let Some(rest) = url.strip_prefix("git@") {
285        // git@host:org/repo
286        let (host, path) = rest.split_once(':')?;
287        (format!("https://{host}"), path.to_string())
288    } else if let Some(rest) = url.strip_prefix("ssh://git@") {
289        // ssh://git@host/org/repo
290        let rest = rest.split('/').collect::<Vec<_>>();
291        let host = rest.first()?;
292        (format!("https://{host}"), rest[1..].join("/"))
293    } else if url.starts_with("https://") || url.starts_with("http://") {
294        let stripped = url
295            .strip_prefix("https://")
296            .or_else(|| url.strip_prefix("http://"))?;
297        let (host, path) = stripped.split_once('/')?;
298        (format!("https://{host}"), path.to_string())
299    } else if let Some((host, path)) = url.split_once(':') {
300        // scp syntax without user@: bare hostname or an ssh host alias
301        // (`bbgithub:org/repo` — ~/.ssh/config supplies the real host)
302        if host.contains('@') || host.contains('/') {
303            return None;
304        }
305        let host = resolve_ssh_alias(host).unwrap_or_else(|| host.to_string());
306        (format!("https://{host}"), path.to_string())
307    } else {
308        return None;
309    };
310    let host = match base.as_str() {
311        "https://github.com" => Host::GitHub,
312        "https://gitlab.com" => Host::GitLab,
313        "https://bitbucket.org" => Host::Bitbucket,
314        b if b.contains("gitea") => Host::Gitea,
315        _ => Host::Other,
316    };
317    Some(Remote {
318        host,
319        owner_repo: path,
320        base,
321    })
322}
323
324/// Resolve an ssh host alias via `~/.ssh/config` Host blocks (exact
325/// matches; wildcard blocks skipped). Enterprise GitHub setups live on
326/// these — the alias exists so the hostname isn't repeated per clone.
327fn resolve_ssh_alias(alias: &str) -> Option<String> {
328    let home = std::env::var_os("HOME")?;
329    let config = std::fs::read_to_string(PathBuf::from(home).join(".ssh").join("config")).ok()?;
330    parse_ssh_alias(&config, alias)
331}
332
333fn parse_ssh_alias(config: &str, alias: &str) -> Option<String> {
334    let mut in_block = false;
335    for line in config.lines() {
336        let line = line.trim();
337        if line.is_empty() || line.starts_with('#') {
338            continue;
339        }
340        let mut parts = line.split_whitespace();
341        match parts.next().map(|k| k.to_ascii_lowercase()).as_deref() {
342            Some("host") => in_block = parts.any(|h| h == alias),
343            Some("hostname") if in_block => return parts.next().map(|h| h.to_string()),
344            _ => {}
345        }
346    }
347    None
348}
349
350/// Pick the permalink remote: upstream > origin > first remaining.
351pub fn pick_remote(repo: &Repo) -> Option<Remote> {
352    pick_remote_from(&repo.remotes())
353}
354
355/// The pure fold over cached remotes (R6): permalink selection needs
356/// no repository handle, only the (name, url) pairs a `GitContext`
357/// already carries.
358pub fn pick_remote_from(remotes: &[(String, String)]) -> Option<Remote> {
359    for name in ["upstream", "origin"] {
360        if let Some(url) = remotes.iter().find(|(n, _)| n == name).map(|(_, u)| u) {
361            if let Some(r) = normalize_remote(url) {
362                return Some(r);
363            }
364        }
365    }
366    remotes.iter().find_map(|(_, u)| normalize_remote(u))
367}
368
369/// Build the immutable permalink for a file at 1-based lines. Branch is
370/// always resolved to a commit SHA (0001 pillar 3.3).
371/// The URL for a revisioned location (0014): pinned to the location's
372/// revision — a commit surface links that commit, not HEAD.
373pub fn permalink(repo: &Repo, loc: &crate::SourceLocation) -> Option<String> {
374    permalink_with(
375        &repo.remotes(),
376        &|revision| match revision {
377            crate::GitRevision::Head | crate::GitRevision::Index | crate::GitRevision::Worktree => {
378                repo.head_sha()
379            }
380            crate::GitRevision::Commit(sha) => Some(sha.clone()),
381            crate::GitRevision::MergeBase(a, b) => repo.merge_base(a, b),
382        },
383        loc,
384    )
385}
386
387/// The pure permalink builder (R6): cached remotes plus a revision
388/// resolver — no repository handle, no native work on the caller's
389/// thread.
390pub fn permalink_with(
391    remotes: &[(String, String)],
392    resolve: &dyn Fn(&crate::GitRevision) -> Option<String>,
393    loc: &crate::SourceLocation,
394) -> Option<String> {
395    let remote = pick_remote_from(remotes)?;
396    let (start_line, end_line) = loc.lines.unwrap_or((1, 1));
397    let sha = resolve(&loc.revision)?;
398    let frag = if start_line == end_line {
399        format!("#L{start_line}")
400    } else {
401        format!("#L{start_line}-L{end_line}")
402    };
403    Some(format!(
404        "{}/{}/blob/{}/{}{frag}",
405        remote.base,
406        remote.owner_repo,
407        sha,
408        loc.path.display()
409    ))
410}
411
412/// Relative age, human short form ("3h", "2d", "5mo").
413fn rel_age(ts: i64) -> String {
414    let now = std::time::SystemTime::now()
415        .duration_since(std::time::UNIX_EPOCH)
416        .map(|d| d.as_secs() as i64)
417        .unwrap_or(0);
418    let age = (now - ts).max(0);
419    match age {
420        a if a < 3600 => format!("{}m", a / 60),
421        a if a < 86400 => format!("{}h", a / 3600),
422        a if a < 86400 * 30 => format!("{}d", a / 86400),
423        a if a < 86400 * 365 => format!("{}mo", a / (86400 * 30)),
424        a => format!("{}y", a / (86400 * 365)),
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn ssh_alias_resolves_via_config() {
434        let config = "# comment\nHost bbgithub\n  HostName bbgithub.dev.bloomberg.com\n  User git\nHost *\n  ServerAliveInterval 30\n";
435        assert_eq!(
436            parse_ssh_alias(config, "bbgithub").as_deref(),
437            Some("bbgithub.dev.bloomberg.com")
438        );
439        assert_eq!(parse_ssh_alias(config, "other"), None);
440        // wildcard-only blocks don't claim aliases
441        assert_eq!(parse_ssh_alias("Host *\n  HostName x", "bbgithub"), None);
442    }
443
444    #[test]
445    fn scp_without_user_parses_as_bare_host() {
446        // unresolved alias falls back to the bare name (matches what git
447        // itself would attempt) — but with a config entry it resolves
448        let r = normalize_remote("bbgithub:acme/demo.git");
449        assert!(r.is_some(), "alias form parses");
450    }
451
452    #[test]
453    fn reviewer_table() {
454        // the first-week report's remote table, verbatim
455        for url in [
456            "https://github.com/acme/demo.git",
457            "ssh://git@github.com/acme/demo.git",
458            "git@github.com:acme/demo",
459            "git@bbgithub.dev.bloomberg.com:acme/demo.git",
460            "https://bbgithub.dev.bloomberg.com/acme/demo.git",
461        ] {
462            let r = normalize_remote(url);
463            assert!(r.is_some(), "should parse: {url}");
464        }
465        // the ssh host-alias form parses (bare-host fallback; resolves
466        // via ~/.ssh/config when an entry exists)
467        assert!(normalize_remote("bbgithub:acme/demo.git").is_some());
468    }
469
470    #[test]
471    fn normalizes_ssh_and_https() {
472        let r = normalize_remote("git@github.com:stropdev/strop.git").unwrap();
473        assert_eq!(
474            (r.base.as_str(), r.owner_repo.as_str()),
475            ("https://github.com", "stropdev/strop")
476        );
477        assert_eq!(r.host, Host::GitHub);
478        let r = normalize_remote("https://gitlab.com/org/proj").unwrap();
479        assert_eq!(r.host, Host::GitLab);
480        assert_eq!(r.owner_repo, "org/proj");
481        let r = normalize_remote("ssh://git@bitbucket.org/team/repo.git").unwrap();
482        assert_eq!(r.host, Host::Bitbucket);
483        assert!(normalize_remote("not a url").is_none());
484    }
485
486    /// Repo with two commits (f.rs grows a line), then a dirty edit —
487    /// blame_file must attribute committed lines and flag dirty ones.
488    #[test]
489    fn blame_file_attributes_lines() {
490        let dir = tempfile::tempdir().unwrap();
491        let root = dir.path();
492        let git = |args: &[&str]| {
493            std::process::Command::new("git")
494                .args(args)
495                .current_dir(root)
496                .output()
497                .unwrap();
498        };
499        git(&["init", "-q"]);
500        git(&["config", "user.email", "t@t.t"]);
501        git(&["config", "user.name", "t"]);
502        std::fs::write(root.join("f.rs"), "one\n").unwrap();
503        git(&["add", "."]);
504        git(&["commit", "-qm", "first"]);
505        std::fs::write(root.join("f.rs"), "one\ntwo\n").unwrap();
506        git(&["commit", "-qam", "second"]);
507
508        let clean = blame_file(root, Path::new("f.rs")).unwrap();
509        assert_eq!(clean.len(), 2, "one BlameLine per file line");
510        assert_eq!(clean[0].author, "t");
511        assert_eq!(clean[1].author, "t");
512        assert_ne!(clean[0].sha, clean[1].sha, "two commits, two shas");
513        assert!(!clean[0].is_uncommitted());
514
515        // dirty worktree: the new line belongs to nobody
516        std::fs::write(root.join("f.rs"), "one\ntwo\nthree\n").unwrap();
517        let dirty = blame_file(root, Path::new("f.rs")).unwrap();
518        assert_eq!(dirty.len(), 3);
519        assert!(dirty[2].is_uncommitted(), "last line is uncommitted");
520        assert_eq!(dirty[2].age, "now");
521        assert_eq!(dirty[2].author, "you");
522        assert_eq!(dirty[2].ts, 0);
523    }
524
525    #[test]
526    fn blame_file_rejects_missing_file() {
527        let dir = tempfile::tempdir().unwrap();
528        assert!(blame_file(dir.path(), Path::new("nope.rs")).is_err());
529    }
530}