Skip to main content

wipe_core/
git.rs

1//! Lightweight git integration by shelling out to the `git` CLI.
2//!
3//! wipe is git-native, so `git` is always present. Rather than pull in a heavy
4//! libgit2/gitoxide dependency (and its native build), we invoke `git` for the
5//! few things the UI needs: the commit history of the board, and the contents of
6//! a tracked file at a past commit (used for the board-rewind feature).
7
8use std::path::Path;
9use std::process::Command;
10
11use serde::Serialize;
12
13use crate::error::{Error, Result};
14
15/// A single commit's metadata.
16#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
17pub struct CommitInfo {
18    /// Full commit hash.
19    pub hash: String,
20    /// Abbreviated hash.
21    pub short: String,
22    /// Author display name.
23    pub author_name: String,
24    /// Author email.
25    pub author_email: String,
26    /// Author date, ISO-8601 / RFC-3339.
27    pub date: String,
28    /// Commit subject line.
29    pub subject: String,
30}
31
32// Field/record separators unlikely to appear in commit metadata.
33const FS: char = '\u{1f}';
34const RS: char = '\u{1e}';
35
36/// Whether `path` is inside a git work tree.
37pub fn is_repo(root: &Path) -> bool {
38    run(root, &["rev-parse", "--is-inside-work-tree"])
39        .map(|o| o.trim() == "true")
40        .unwrap_or(false)
41}
42
43/// Return the commit history, most recent first. If `pathspec` is given, only
44/// commits touching that path are returned. `limit` caps the number of commits.
45pub fn log(root: &Path, pathspec: Option<&str>, limit: Option<usize>) -> Result<Vec<CommitInfo>> {
46    let format = format!("--format=%H{FS}%h{FS}%an{FS}%ae{FS}%aI{FS}%s{RS}");
47    let mut args: Vec<String> = vec![
48        "--no-pager".into(),
49        "log".into(),
50        format,
51        "--no-color".into(),
52    ];
53    if let Some(l) = limit {
54        args.push("-n".into());
55        args.push(l.to_string());
56    }
57    if let Some(p) = pathspec {
58        args.push("--".into());
59        args.push(p.to_string());
60    }
61    let refs: Vec<&str> = args.iter().map(String::as_str).collect();
62    let out = run(root, &refs)?;
63    Ok(parse_log(&out))
64}
65
66/// Read the contents of a tracked file as of a specific commit/ref. Returns
67/// `None` if the file did not exist at that revision.
68pub fn file_at_commit(root: &Path, rev: &str, relpath: &str) -> Result<Option<String>> {
69    // Forward slashes work on all platforms for git pathspecs.
70    let spec = format!("{rev}:{}", relpath.replace('\\', "/"));
71    match run(root, &["--no-pager", "show", &spec]) {
72        Ok(s) => Ok(Some(s)),
73        // A non-zero exit here means "path not present at rev", not a hard error.
74        Err(Error::Message(_)) => Ok(None),
75        Err(e) => Err(e),
76    }
77}
78
79/// The most recent commit that touched `relpath`, if any (for attribution).
80pub fn last_change(root: &Path, relpath: &str) -> Result<Option<CommitInfo>> {
81    Ok(log(root, Some(relpath), Some(1))?.into_iter().next())
82}
83
84/// Compute the git blob hash of `bytes` (identical to `git hash-object`).
85pub fn blob_hash(bytes: &[u8]) -> String {
86    use sha1::{Digest, Sha1};
87    let mut h = Sha1::new();
88    h.update(format!("blob {}\0", bytes.len()).as_bytes());
89    h.update(bytes);
90    format!("{:x}", h.finalize())
91}
92
93/// All tracked files as `(blob_hash, repo-relative path)` pairs.
94pub fn tracked_blobs(root: &Path) -> Result<Vec<(String, String)>> {
95    let out = run(root, &["ls-files", "-s"])?;
96    let mut blobs = Vec::new();
97    for line in out.lines() {
98        // Format: "<mode> <hash> <stage>\t<path>"
99        if let Some((meta, path)) = line.split_once('\t') {
100            let mut cols = meta.split_whitespace();
101            let _mode = cols.next();
102            if let Some(hash) = cols.next() {
103                blobs.push((hash.to_string(), path.to_string()));
104            }
105        }
106    }
107    Ok(blobs)
108}
109
110/// Distinct commit authors as `(name, email)`, most-recent first.
111pub fn authors(root: &Path) -> Result<Vec<(String, String)>> {
112    let out = run(
113        root,
114        &["--no-pager", "log", &format!("--format=%an{FS}%ae")],
115    )?;
116    let mut seen = std::collections::HashSet::new();
117    let mut authors = Vec::new();
118    for line in out.lines() {
119        if let Some((name, email)) = line.split_once(FS) {
120            if seen.insert(email.to_string()) {
121                authors.push((name.to_string(), email.to_string()));
122            }
123        }
124    }
125    Ok(authors)
126}
127
128/// The configured git identity for this repo (`Name <email>`), if set. Used to
129/// attribute UI-driven changes in the activity timeline to the human at the keyboard.
130pub fn config_identity(root: &Path) -> Option<String> {
131    let get = |key: &str| {
132        run(root, &["config", key])
133            .ok()
134            .map(|s| s.trim().to_string())
135            .filter(|s| !s.is_empty())
136    };
137    match (get("user.name"), get("user.email")) {
138        (Some(name), Some(email)) => Some(format!("{name} <{email}>")),
139        (Some(name), None) => Some(name),
140        (None, Some(email)) => Some(email),
141        (None, None) => None,
142    }
143}
144
145/// A commit in the repository graph, with parent links and ref decorations.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
147pub struct GraphCommit {
148    /// Full commit hash.
149    pub hash: String,
150    /// Abbreviated hash.
151    pub short: String,
152    /// Parent commit hashes (2+ means a merge).
153    pub parents: Vec<String>,
154    /// Ref decorations pointing at this commit (branches, tags, HEAD).
155    pub refs: Vec<String>,
156    /// Author display name.
157    pub author_name: String,
158    /// Author date, ISO-8601.
159    pub date: String,
160    /// Commit subject.
161    pub subject: String,
162    /// Whether this commit changed the board (`.wipe/`) - a board "checkpoint".
163    pub board: bool,
164}
165
166/// The commit graph across all branches (most recent first), with parent links,
167/// ref decorations, and a flag marking commits that touched the board. Intended
168/// for drawing a git-graph view of the board's history.
169pub fn graph(root: &Path, limit: Option<usize>) -> Result<Vec<GraphCommit>> {
170    // Hashes of commits that changed the board, so the UI can mark checkpoints.
171    let board: std::collections::HashSet<String> = run(
172        root,
173        &["--no-pager", "log", "--all", "--format=%H", "--", ".wipe"],
174    )
175    .unwrap_or_default()
176    .lines()
177    .map(|s| s.trim().to_string())
178    .collect();
179
180    let format = format!("--format=%H{FS}%h{FS}%P{FS}%D{FS}%an{FS}%aI{FS}%s{RS}");
181    let mut args: Vec<String> = vec![
182        "--no-pager".into(),
183        "log".into(),
184        "--all".into(),
185        "--date-order".into(),
186        format,
187        "--no-color".into(),
188    ];
189    if let Some(l) = limit {
190        args.push("-n".into());
191        args.push(l.to_string());
192    }
193    let refs: Vec<&str> = args.iter().map(String::as_str).collect();
194    let out = run(root, &refs)?;
195    Ok(out
196        .split(RS)
197        .map(str::trim)
198        .filter(|r| !r.is_empty())
199        .filter_map(|record| {
200            let mut f = record.split(FS);
201            let hash = f.next()?.to_string();
202            let short = f.next()?.to_string();
203            let parents = f
204                .next()?
205                .split_whitespace()
206                .map(|s| s.to_string())
207                .collect();
208            let refs = f
209                .next()
210                .unwrap_or("")
211                .split(',')
212                .map(|s| s.trim().to_string())
213                .filter(|s| !s.is_empty())
214                .collect();
215            let author_name = f.next().unwrap_or("").to_string();
216            let date = f.next().unwrap_or("").to_string();
217            let subject = f.next().unwrap_or("").to_string();
218            let board = board.contains(&hash);
219            Some(GraphCommit {
220                hash,
221                short,
222                parents,
223                refs,
224                author_name,
225                date,
226                subject,
227                board,
228            })
229        })
230        .collect())
231}
232
233fn parse_log(out: &str) -> Vec<CommitInfo> {
234    out.split(RS)
235        .map(str::trim)
236        .filter(|r| !r.is_empty())
237        .filter_map(|record| {
238            let mut f = record.split(FS);
239            Some(CommitInfo {
240                hash: f.next()?.to_string(),
241                short: f.next()?.to_string(),
242                author_name: f.next()?.to_string(),
243                author_email: f.next()?.to_string(),
244                date: f.next()?.to_string(),
245                subject: f.next().unwrap_or("").to_string(),
246            })
247        })
248        .collect()
249}
250
251/// Strip Windows' `\\?\` verbatim prefix, which `git -C` does not accept.
252fn plain(root: &Path) -> std::path::PathBuf {
253    let s = root.to_string_lossy();
254    match s.strip_prefix(r"\\?\") {
255        Some(rest) => std::path::PathBuf::from(rest),
256        None => root.to_path_buf(),
257    }
258}
259
260/// Run a git command in `root`, returning stdout on success or an
261/// [`Error::Message`] carrying stderr on failure.
262fn run(root: &Path, args: &[&str]) -> Result<String> {
263    let out = Command::new("git")
264        .arg("-C")
265        .arg(plain(root))
266        .args(args)
267        .output()
268        .map_err(|e| Error::msg(format!("failed to run git: {e}")))?;
269    if out.status.success() {
270        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
271    } else {
272        Err(Error::msg(
273            String::from_utf8_lossy(&out.stderr).trim().to_string(),
274        ))
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use std::process::Command;
282
283    fn git(root: &Path, args: &[&str]) {
284        let ok = Command::new("git")
285            .arg("-C")
286            .arg(root)
287            .args(args)
288            .output()
289            .unwrap()
290            .status
291            .success();
292        assert!(ok, "git {args:?} failed");
293    }
294
295    #[test]
296    fn log_and_show_roundtrip() {
297        let dir = tempfile::tempdir().unwrap();
298        let root = dir.path();
299        git(root, &["init", "-q"]);
300        git(root, &["config", "user.email", "t@example.com"]);
301        git(root, &["config", "user.name", "Tester"]);
302        std::fs::write(root.join("a.txt"), "v1\n").unwrap();
303        git(root, &["add", "."]);
304        git(root, &["commit", "-q", "-m", "first commit"]);
305
306        assert!(is_repo(root));
307        let history = log(root, None, None).unwrap();
308        assert_eq!(history.len(), 1);
309        assert_eq!(history[0].subject, "first commit");
310        assert_eq!(history[0].author_email, "t@example.com");
311
312        let head = &history[0].hash;
313        let content = file_at_commit(root, head, "a.txt").unwrap();
314        assert_eq!(content.as_deref(), Some("v1\n"));
315
316        // A path that never existed yields None, not an error.
317        assert_eq!(file_at_commit(root, head, "missing.txt").unwrap(), None);
318    }
319
320    #[test]
321    fn non_repo_reports_false() {
322        let dir = tempfile::tempdir().unwrap();
323        assert!(!is_repo(dir.path()));
324    }
325}