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/// Files changed by a commit: `path | +N -M` rows for the dive view.
114#[derive(Debug, Clone)]
115pub struct ChangedFile {
116    pub path: PathBuf,
117    pub added: usize,
118    pub deleted: usize,
119}
120
121pub fn show_stat(workdir: &Path, sha: &str) -> Result<Vec<ChangedFile>, String> {
122    let out = std::process::Command::new("git")
123        .args([
124            "-C",
125            &workdir.display().to_string(),
126            "show",
127            "--numstat",
128            "--format=",
129            sha,
130        ])
131        .output()
132        .map_err(|e| format!("spawn git show: {e}"))?;
133    if !out.status.success() {
134        return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
135    }
136    Ok(String::from_utf8_lossy(&out.stdout)
137        .lines()
138        .filter_map(|l| {
139            let mut parts = l.split('\t');
140            let added = parts.next()?.parse().ok()?;
141            let deleted = parts.next()?.parse().ok()?;
142            Some(ChangedFile {
143                path: PathBuf::from(parts.next()?),
144                added,
145                deleted,
146            })
147        })
148        .collect())
149}
150
151/// Unified diff for one file at a commit (the delta view).
152pub fn show_file_delta(workdir: &Path, sha: &str, file: &Path) -> Result<String, String> {
153    let out = std::process::Command::new("git")
154        .args([
155            "-C",
156            &workdir.display().to_string(),
157            "show",
158            "--format=",
159            "--patch",
160            sha,
161            "--",
162            &file.display().to_string(),
163        ])
164        .output()
165        .map_err(|e| format!("spawn git show: {e}"))?;
166    if !out.status.success() {
167        return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
168    }
169    Ok(String::from_utf8_lossy(&out.stdout).to_string())
170}
171
172// ---- permalinks ----------------------------------------------------------
173
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub enum Host {
176    GitHub,
177    GitLab,
178    Bitbucket,
179    Gitea,
180    /// Unknown host: emit whatever HTTPS we can normalize to.
181    Other,
182}
183
184pub struct Remote {
185    pub host: Host,
186    pub owner_repo: String, // "org/repo"
187    pub base: String,       // "https://github.com"
188}
189
190/// Normalize a remote URL (SSH or HTTPS) to a web base. Priority
191/// upstream > origin > rest is the caller's job (0001 pillar 3.3).
192pub fn normalize_remote(url: &str) -> Option<Remote> {
193    let url = url.trim().trim_end_matches(".git");
194    let (base, path) = if let Some(rest) = url.strip_prefix("git@") {
195        // git@host:org/repo
196        let (host, path) = rest.split_once(':')?;
197        (format!("https://{host}"), path.to_string())
198    } else if let Some(rest) = url.strip_prefix("ssh://git@") {
199        // ssh://git@host/org/repo
200        let rest = rest.split('/').collect::<Vec<_>>();
201        let host = rest.first()?;
202        (format!("https://{host}"), rest[1..].join("/"))
203    } else if url.starts_with("https://") || url.starts_with("http://") {
204        let stripped = url
205            .strip_prefix("https://")
206            .or_else(|| url.strip_prefix("http://"))?;
207        let (host, path) = stripped.split_once('/')?;
208        (format!("https://{host}"), path.to_string())
209    } else {
210        return None;
211    };
212    let host = match base.as_str() {
213        "https://github.com" => Host::GitHub,
214        "https://gitlab.com" => Host::GitLab,
215        "https://bitbucket.org" => Host::Bitbucket,
216        b if b.contains("gitea") => Host::Gitea,
217        _ => Host::Other,
218    };
219    Some(Remote {
220        host,
221        owner_repo: path,
222        base,
223    })
224}
225
226/// Pick the permalink remote: upstream > origin > first remaining.
227pub fn pick_remote(repo: &Repo) -> Option<Remote> {
228    let remotes = repo.remotes();
229    for name in ["upstream", "origin"] {
230        if let Some(url) = remotes.iter().find(|(n, _)| n == name).map(|(_, u)| u) {
231            if let Some(r) = normalize_remote(url) {
232                return Some(r);
233            }
234        }
235    }
236    remotes.iter().find_map(|(_, u)| normalize_remote(u))
237}
238
239/// Build the immutable permalink for a file at 1-based lines. Branch is
240/// always resolved to a commit SHA (0001 pillar 3.3).
241pub fn permalink(repo: &Repo, rel: &Path, start_line: usize, end_line: usize) -> Option<String> {
242    let remote = pick_remote(repo)?;
243    let sha = repo.head_sha()?;
244    let frag = if start_line == end_line {
245        format!("#L{start_line}")
246    } else {
247        format!("#L{start_line}-L{end_line}")
248    };
249    Some(format!(
250        "{}/{}/blob/{}/{}{frag}",
251        remote.base,
252        remote.owner_repo,
253        sha,
254        rel.display()
255    ))
256}
257
258/// Relative age, human short form ("3h", "2d", "5mo").
259fn rel_age(ts: i64) -> String {
260    let now = std::time::SystemTime::now()
261        .duration_since(std::time::UNIX_EPOCH)
262        .map(|d| d.as_secs() as i64)
263        .unwrap_or(0);
264    let age = (now - ts).max(0);
265    match age {
266        a if a < 3600 => format!("{}m", a / 60),
267        a if a < 86400 => format!("{}h", a / 3600),
268        a if a < 86400 * 30 => format!("{}d", a / 86400),
269        a if a < 86400 * 365 => format!("{}mo", a / (86400 * 30)),
270        a => format!("{}y", a / (86400 * 365)),
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn normalizes_ssh_and_https() {
280        let r = normalize_remote("git@github.com:stropdev/strop.git").unwrap();
281        assert_eq!(
282            (r.base.as_str(), r.owner_repo.as_str()),
283            ("https://github.com", "stropdev/strop")
284        );
285        assert_eq!(r.host, Host::GitHub);
286        let r = normalize_remote("https://gitlab.com/org/proj").unwrap();
287        assert_eq!(r.host, Host::GitLab);
288        assert_eq!(r.owner_repo, "org/proj");
289        let r = normalize_remote("ssh://git@bitbucket.org/team/repo.git").unwrap();
290        assert_eq!(r.host, Host::Bitbucket);
291        assert!(normalize_remote("not a url").is_none());
292    }
293}