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)]
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    let mut cmd = std::process::Command::new("git");
22    cmd.args([
23        "-C",
24        &workdir.display().to_string(),
25        "log",
26        "--graph",
27        "--format=%h %an · %ar · %s%x00%H",
28        "-n",
29        &max.to_string(),
30    ]);
31    if let Some(f) = file {
32        cmd.arg("--").arg(f);
33    }
34    let out = cmd.output().map_err(|e| format!("spawn git log: {e}"))?;
35    if !out.status.success() {
36        return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
37    }
38    let text = String::from_utf8_lossy(&out.stdout);
39    Ok(text
40        .lines()
41        .map(|line| {
42            // the format hides the full SHA after a NUL
43            let (vis, sha) = match line.split_once('\0') {
44                Some((v, s)) => (v.to_string(), Some(s.trim().to_string())),
45                None => (line.to_string(), None),
46            };
47            LogRow { text: vis, sha }
48        })
49        .collect())
50}
51
52/// A blame card for one line (0001 pillar 3.3).
53#[derive(Debug, Clone)]
54pub struct BlameCard {
55    pub sha: String,
56    pub short_sha: String,
57    pub author: String,
58    pub age: String,
59    pub summary: String,
60    pub line: usize,
61}
62
63/// Blame one line of a file (1-based). Shells out; porcelain format.
64pub fn blame_line(workdir: &Path, rel: &Path, line: usize) -> Result<BlameCard, String> {
65    let out = std::process::Command::new("git")
66        .args([
67            "-C",
68            &workdir.display().to_string(),
69            "blame",
70            "--line-porcelain",
71            "-L",
72            &format!("{line},{line}"),
73            "--",
74            &rel.display().to_string(),
75        ])
76        .output()
77        .map_err(|e| format!("spawn git blame: {e}"))?;
78    if !out.status.success() {
79        return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
80    }
81    let text = String::from_utf8_lossy(&out.stdout);
82    let mut sha = String::new();
83    let mut author = String::new();
84    let mut summary = String::new();
85    let mut ts = 0i64;
86    for l in text.lines() {
87        if sha.is_empty()
88            && !l.starts_with('\t')
89            && l.chars().take(8).all(|c| c.is_ascii_hexdigit())
90        {
91            sha = l.split_whitespace().next().unwrap_or("").to_string();
92        } else if let Some(a) = l.strip_prefix("author ") {
93            author = a.to_string();
94        } else if let Some(t) = l.strip_prefix("author-time ") {
95            ts = t.parse().unwrap_or(0);
96        } else if let Some(s) = l.strip_prefix("summary ") {
97            summary = s.to_string();
98        }
99    }
100    if sha.is_empty() {
101        return Err("no blame for line".into());
102    }
103    Ok(BlameCard {
104        short_sha: sha.chars().take(8).collect(),
105        sha,
106        author,
107        age: rel_age(ts),
108        summary,
109        line,
110    })
111}
112
113/// One line of a whole-file blame (0001 pillar 3.3, the toggleable
114/// column). `age` is rendered at parse time; `ts` keeps "recent"
115/// honest for the caller's coloring.
116#[derive(Debug, Clone)]
117pub struct BlameLine {
118    pub sha: String,
119    pub author: String,
120    /// Human short form ("3h", "2d", "5mo"); "now" when uncommitted.
121    pub age: String,
122    /// Author time, unix seconds (0 = uncommitted).
123    pub ts: i64,
124}
125
126impl BlameLine {
127    /// Worktree lines git blame attributes to nobody (all-zero sha).
128    pub fn is_uncommitted(&self) -> bool {
129        !self.sha.is_empty() && self.sha.chars().all(|c| c == '0')
130    }
131}
132
133/// Blame every line of a file (`--line-porcelain`; the gutter's data,
134/// 0011 §3). Shells out on a job thread — never the input path.
135pub fn blame_file(workdir: &Path, rel: &Path) -> Result<Vec<BlameLine>, String> {
136    let out = std::process::Command::new("git")
137        .args([
138            "-C",
139            &workdir.display().to_string(),
140            "blame",
141            "--line-porcelain",
142            "--",
143            &rel.display().to_string(),
144        ])
145        .output()
146        .map_err(|e| format!("spawn git blame: {e}"))?;
147    if !out.status.success() {
148        return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
149    }
150    let mut lines = Vec::new();
151    let mut sha = String::new();
152    let mut author = String::new();
153    let mut ts = 0i64;
154    for l in String::from_utf8_lossy(&out.stdout).lines() {
155        if let Some(content) = l.strip_prefix('\t') {
156            // the record's content row closes it — porcelain repeats
157            // the full header per line, so every tab row emits one
158            let _ = content;
159            if !sha.is_empty() {
160                let uncommitted = sha.chars().all(|c| c == '0');
161                lines.push(BlameLine {
162                    sha: sha.clone(),
163                    age: if uncommitted {
164                        "now".into()
165                    } else {
166                        rel_age(ts)
167                    },
168                    author: if uncommitted {
169                        "you".into()
170                    } else {
171                        author.clone()
172                    },
173                    ts: if uncommitted { 0 } else { ts },
174                });
175            }
176            sha.clear();
177            author.clear();
178            ts = 0;
179        } else if sha.is_empty()
180            && !l.is_empty()
181            && l.chars().take(40).all(|c| c.is_ascii_hexdigit())
182        {
183            sha = l.split_whitespace().next().unwrap_or("").to_string();
184        } else if let Some(a) = l.strip_prefix("author ") {
185            author = a.to_string();
186        } else if let Some(t) = l.strip_prefix("author-time ") {
187            ts = t.parse().unwrap_or(0);
188        }
189    }
190    if lines.is_empty() {
191        return Err("no blame for file".into());
192    }
193    Ok(lines)
194}
195
196/// Files changed by a commit: `path | +N -M` rows for the dive view.
197#[derive(Debug, Clone)]
198pub struct ChangedFile {
199    pub path: PathBuf,
200    pub added: usize,
201    pub deleted: usize,
202}
203
204pub fn show_stat(workdir: &Path, sha: &str) -> Result<Vec<ChangedFile>, String> {
205    let out = std::process::Command::new("git")
206        .args([
207            "-C",
208            &workdir.display().to_string(),
209            "show",
210            "--numstat",
211            "--format=",
212            sha,
213        ])
214        .output()
215        .map_err(|e| format!("spawn git show: {e}"))?;
216    if !out.status.success() {
217        return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
218    }
219    Ok(String::from_utf8_lossy(&out.stdout)
220        .lines()
221        .filter_map(|l| {
222            let mut parts = l.split('\t');
223            let added = parts.next()?.parse().ok()?;
224            let deleted = parts.next()?.parse().ok()?;
225            Some(ChangedFile {
226                path: PathBuf::from(parts.next()?),
227                added,
228                deleted,
229            })
230        })
231        .collect())
232}
233
234// ---- permalinks ----------------------------------------------------------
235
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub enum Host {
238    GitHub,
239    GitLab,
240    Bitbucket,
241    Gitea,
242    /// Unknown host: emit whatever HTTPS we can normalize to.
243    Other,
244}
245
246pub struct Remote {
247    pub host: Host,
248    pub owner_repo: String, // "org/repo"
249    pub base: String,       // "https://github.com"
250}
251
252/// Normalize a remote URL (SSH or HTTPS) to a web base. Priority
253/// upstream > origin > rest is the caller's job (0001 pillar 3.3).
254pub fn normalize_remote(url: &str) -> Option<Remote> {
255    let url = url.trim().trim_end_matches(".git");
256    let (base, path) = if let Some(rest) = url.strip_prefix("git@") {
257        // git@host:org/repo
258        let (host, path) = rest.split_once(':')?;
259        (format!("https://{host}"), path.to_string())
260    } else if let Some(rest) = url.strip_prefix("ssh://git@") {
261        // ssh://git@host/org/repo
262        let rest = rest.split('/').collect::<Vec<_>>();
263        let host = rest.first()?;
264        (format!("https://{host}"), rest[1..].join("/"))
265    } else if url.starts_with("https://") || url.starts_with("http://") {
266        let stripped = url
267            .strip_prefix("https://")
268            .or_else(|| url.strip_prefix("http://"))?;
269        let (host, path) = stripped.split_once('/')?;
270        (format!("https://{host}"), path.to_string())
271    } else if let Some((host, path)) = url.split_once(':') {
272        // scp syntax without user@: bare hostname or an ssh host alias
273        // (`bbgithub:org/repo` — ~/.ssh/config supplies the real host)
274        if host.contains('@') || host.contains('/') {
275            return None;
276        }
277        let host = resolve_ssh_alias(host).unwrap_or_else(|| host.to_string());
278        (format!("https://{host}"), path.to_string())
279    } else {
280        return None;
281    };
282    let host = match base.as_str() {
283        "https://github.com" => Host::GitHub,
284        "https://gitlab.com" => Host::GitLab,
285        "https://bitbucket.org" => Host::Bitbucket,
286        b if b.contains("gitea") => Host::Gitea,
287        _ => Host::Other,
288    };
289    Some(Remote {
290        host,
291        owner_repo: path,
292        base,
293    })
294}
295
296/// Resolve an ssh host alias via `~/.ssh/config` Host blocks (exact
297/// matches; wildcard blocks skipped). Enterprise GitHub setups live on
298/// these — the alias exists so the hostname isn't repeated per clone.
299fn resolve_ssh_alias(alias: &str) -> Option<String> {
300    let home = std::env::var_os("HOME")?;
301    let config = std::fs::read_to_string(PathBuf::from(home).join(".ssh").join("config")).ok()?;
302    parse_ssh_alias(&config, alias)
303}
304
305fn parse_ssh_alias(config: &str, alias: &str) -> Option<String> {
306    let mut in_block = false;
307    for line in config.lines() {
308        let line = line.trim();
309        if line.is_empty() || line.starts_with('#') {
310            continue;
311        }
312        let mut parts = line.split_whitespace();
313        match parts.next().map(|k| k.to_ascii_lowercase()).as_deref() {
314            Some("host") => in_block = parts.any(|h| h == alias),
315            Some("hostname") if in_block => return parts.next().map(|h| h.to_string()),
316            _ => {}
317        }
318    }
319    None
320}
321
322/// Pick the permalink remote: upstream > origin > first remaining.
323pub fn pick_remote(repo: &Repo) -> Option<Remote> {
324    let remotes = repo.remotes();
325    for name in ["upstream", "origin"] {
326        if let Some(url) = remotes.iter().find(|(n, _)| n == name).map(|(_, u)| u) {
327            if let Some(r) = normalize_remote(url) {
328                return Some(r);
329            }
330        }
331    }
332    remotes.iter().find_map(|(_, u)| normalize_remote(u))
333}
334
335/// Build the immutable permalink for a file at 1-based lines. Branch is
336/// always resolved to a commit SHA (0001 pillar 3.3).
337pub fn permalink(repo: &Repo, rel: &Path, start_line: usize, end_line: usize) -> Option<String> {
338    let remote = pick_remote(repo)?;
339    let sha = repo.head_sha()?;
340    let frag = if start_line == end_line {
341        format!("#L{start_line}")
342    } else {
343        format!("#L{start_line}-L{end_line}")
344    };
345    Some(format!(
346        "{}/{}/blob/{}/{}{frag}",
347        remote.base,
348        remote.owner_repo,
349        sha,
350        rel.display()
351    ))
352}
353
354/// Relative age, human short form ("3h", "2d", "5mo").
355fn rel_age(ts: i64) -> String {
356    let now = std::time::SystemTime::now()
357        .duration_since(std::time::UNIX_EPOCH)
358        .map(|d| d.as_secs() as i64)
359        .unwrap_or(0);
360    let age = (now - ts).max(0);
361    match age {
362        a if a < 3600 => format!("{}m", a / 60),
363        a if a < 86400 => format!("{}h", a / 3600),
364        a if a < 86400 * 30 => format!("{}d", a / 86400),
365        a if a < 86400 * 365 => format!("{}mo", a / (86400 * 30)),
366        a => format!("{}y", a / (86400 * 365)),
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn ssh_alias_resolves_via_config() {
376        let config = "# comment\nHost bbgithub\n  HostName bbgithub.dev.bloomberg.com\n  User git\nHost *\n  ServerAliveInterval 30\n";
377        assert_eq!(
378            parse_ssh_alias(config, "bbgithub").as_deref(),
379            Some("bbgithub.dev.bloomberg.com")
380        );
381        assert_eq!(parse_ssh_alias(config, "other"), None);
382        // wildcard-only blocks don't claim aliases
383        assert_eq!(parse_ssh_alias("Host *\n  HostName x", "bbgithub"), None);
384    }
385
386    #[test]
387    fn scp_without_user_parses_as_bare_host() {
388        // unresolved alias falls back to the bare name (matches what git
389        // itself would attempt) — but with a config entry it resolves
390        let r = normalize_remote("bbgithub:acme/demo.git");
391        assert!(r.is_some(), "alias form parses");
392    }
393
394    #[test]
395    fn reviewer_table() {
396        // the first-week report's remote table, verbatim
397        for url in [
398            "https://github.com/acme/demo.git",
399            "ssh://git@github.com/acme/demo.git",
400            "git@github.com:acme/demo",
401            "git@bbgithub.dev.bloomberg.com:acme/demo.git",
402            "https://bbgithub.dev.bloomberg.com/acme/demo.git",
403        ] {
404            let r = normalize_remote(url);
405            assert!(r.is_some(), "should parse: {url}");
406        }
407        // the ssh host-alias form parses (bare-host fallback; resolves
408        // via ~/.ssh/config when an entry exists)
409        assert!(normalize_remote("bbgithub:acme/demo.git").is_some());
410    }
411
412    #[test]
413    fn normalizes_ssh_and_https() {
414        let r = normalize_remote("git@github.com:stropdev/strop.git").unwrap();
415        assert_eq!(
416            (r.base.as_str(), r.owner_repo.as_str()),
417            ("https://github.com", "stropdev/strop")
418        );
419        assert_eq!(r.host, Host::GitHub);
420        let r = normalize_remote("https://gitlab.com/org/proj").unwrap();
421        assert_eq!(r.host, Host::GitLab);
422        assert_eq!(r.owner_repo, "org/proj");
423        let r = normalize_remote("ssh://git@bitbucket.org/team/repo.git").unwrap();
424        assert_eq!(r.host, Host::Bitbucket);
425        assert!(normalize_remote("not a url").is_none());
426    }
427
428    /// Repo with two commits (f.rs grows a line), then a dirty edit —
429    /// blame_file must attribute committed lines and flag dirty ones.
430    #[test]
431    fn blame_file_attributes_lines() {
432        let dir = tempfile::tempdir().unwrap();
433        let root = dir.path();
434        let git = |args: &[&str]| {
435            std::process::Command::new("git")
436                .args(args)
437                .current_dir(root)
438                .output()
439                .unwrap();
440        };
441        git(&["init", "-q"]);
442        git(&["config", "user.email", "t@t.t"]);
443        git(&["config", "user.name", "t"]);
444        std::fs::write(root.join("f.rs"), "one\n").unwrap();
445        git(&["add", "."]);
446        git(&["commit", "-qm", "first"]);
447        std::fs::write(root.join("f.rs"), "one\ntwo\n").unwrap();
448        git(&["commit", "-qam", "second"]);
449
450        let clean = blame_file(root, Path::new("f.rs")).unwrap();
451        assert_eq!(clean.len(), 2, "one BlameLine per file line");
452        assert_eq!(clean[0].author, "t");
453        assert_eq!(clean[1].author, "t");
454        assert_ne!(clean[0].sha, clean[1].sha, "two commits, two shas");
455        assert!(!clean[0].is_uncommitted());
456
457        // dirty worktree: the new line belongs to nobody
458        std::fs::write(root.join("f.rs"), "one\ntwo\nthree\n").unwrap();
459        let dirty = blame_file(root, Path::new("f.rs")).unwrap();
460        assert_eq!(dirty.len(), 3);
461        assert!(dirty[2].is_uncommitted(), "last line is uncommitted");
462        assert_eq!(dirty[2].age, "now");
463        assert_eq!(dirty[2].author, "you");
464        assert_eq!(dirty[2].ts, 0);
465    }
466
467    #[test]
468    fn blame_file_rejects_missing_file() {
469        let dir = tempfile::tempdir().unwrap();
470        assert!(blame_file(dir.path(), Path::new("nope.rs")).is_err());
471    }
472}