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/// A commit in the repository graph, with parent links and ref decorations.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
130pub struct GraphCommit {
131    /// Full commit hash.
132    pub hash: String,
133    /// Abbreviated hash.
134    pub short: String,
135    /// Parent commit hashes (2+ means a merge).
136    pub parents: Vec<String>,
137    /// Ref decorations pointing at this commit (branches, tags, HEAD).
138    pub refs: Vec<String>,
139    /// Author display name.
140    pub author_name: String,
141    /// Author date, ISO-8601.
142    pub date: String,
143    /// Commit subject.
144    pub subject: String,
145    /// Whether this commit changed the board (`.wipe/`) — a board "checkpoint".
146    pub board: bool,
147}
148
149/// The commit graph across all branches (most recent first), with parent links,
150/// ref decorations, and a flag marking commits that touched the board. Intended
151/// for drawing a git-graph view of the board's history.
152pub fn graph(root: &Path, limit: Option<usize>) -> Result<Vec<GraphCommit>> {
153    // Hashes of commits that changed the board, so the UI can mark checkpoints.
154    let board: std::collections::HashSet<String> = run(
155        root,
156        &["--no-pager", "log", "--all", "--format=%H", "--", ".wipe"],
157    )
158    .unwrap_or_default()
159    .lines()
160    .map(|s| s.trim().to_string())
161    .collect();
162
163    let format = format!("--format=%H{FS}%h{FS}%P{FS}%D{FS}%an{FS}%aI{FS}%s{RS}");
164    let mut args: Vec<String> = vec![
165        "--no-pager".into(),
166        "log".into(),
167        "--all".into(),
168        "--date-order".into(),
169        format,
170        "--no-color".into(),
171    ];
172    if let Some(l) = limit {
173        args.push("-n".into());
174        args.push(l.to_string());
175    }
176    let refs: Vec<&str> = args.iter().map(String::as_str).collect();
177    let out = run(root, &refs)?;
178    Ok(out
179        .split(RS)
180        .map(str::trim)
181        .filter(|r| !r.is_empty())
182        .filter_map(|record| {
183            let mut f = record.split(FS);
184            let hash = f.next()?.to_string();
185            let short = f.next()?.to_string();
186            let parents = f
187                .next()?
188                .split_whitespace()
189                .map(|s| s.to_string())
190                .collect();
191            let refs = f
192                .next()
193                .unwrap_or("")
194                .split(',')
195                .map(|s| s.trim().to_string())
196                .filter(|s| !s.is_empty())
197                .collect();
198            let author_name = f.next().unwrap_or("").to_string();
199            let date = f.next().unwrap_or("").to_string();
200            let subject = f.next().unwrap_or("").to_string();
201            let board = board.contains(&hash);
202            Some(GraphCommit {
203                hash,
204                short,
205                parents,
206                refs,
207                author_name,
208                date,
209                subject,
210                board,
211            })
212        })
213        .collect())
214}
215
216fn parse_log(out: &str) -> Vec<CommitInfo> {
217    out.split(RS)
218        .map(str::trim)
219        .filter(|r| !r.is_empty())
220        .filter_map(|record| {
221            let mut f = record.split(FS);
222            Some(CommitInfo {
223                hash: f.next()?.to_string(),
224                short: f.next()?.to_string(),
225                author_name: f.next()?.to_string(),
226                author_email: f.next()?.to_string(),
227                date: f.next()?.to_string(),
228                subject: f.next().unwrap_or("").to_string(),
229            })
230        })
231        .collect()
232}
233
234/// Strip Windows' `\\?\` verbatim prefix, which `git -C` does not accept.
235fn plain(root: &Path) -> std::path::PathBuf {
236    let s = root.to_string_lossy();
237    match s.strip_prefix(r"\\?\") {
238        Some(rest) => std::path::PathBuf::from(rest),
239        None => root.to_path_buf(),
240    }
241}
242
243/// Run a git command in `root`, returning stdout on success or an
244/// [`Error::Message`] carrying stderr on failure.
245fn run(root: &Path, args: &[&str]) -> Result<String> {
246    let out = Command::new("git")
247        .arg("-C")
248        .arg(plain(root))
249        .args(args)
250        .output()
251        .map_err(|e| Error::msg(format!("failed to run git: {e}")))?;
252    if out.status.success() {
253        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
254    } else {
255        Err(Error::msg(
256            String::from_utf8_lossy(&out.stderr).trim().to_string(),
257        ))
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use std::process::Command;
265
266    fn git(root: &Path, args: &[&str]) {
267        let ok = Command::new("git")
268            .arg("-C")
269            .arg(root)
270            .args(args)
271            .output()
272            .unwrap()
273            .status
274            .success();
275        assert!(ok, "git {args:?} failed");
276    }
277
278    #[test]
279    fn log_and_show_roundtrip() {
280        let dir = tempfile::tempdir().unwrap();
281        let root = dir.path();
282        git(root, &["init", "-q"]);
283        git(root, &["config", "user.email", "t@example.com"]);
284        git(root, &["config", "user.name", "Tester"]);
285        std::fs::write(root.join("a.txt"), "v1\n").unwrap();
286        git(root, &["add", "."]);
287        git(root, &["commit", "-q", "-m", "first commit"]);
288
289        assert!(is_repo(root));
290        let history = log(root, None, None).unwrap();
291        assert_eq!(history.len(), 1);
292        assert_eq!(history[0].subject, "first commit");
293        assert_eq!(history[0].author_email, "t@example.com");
294
295        let head = &history[0].hash;
296        let content = file_at_commit(root, head, "a.txt").unwrap();
297        assert_eq!(content.as_deref(), Some("v1\n"));
298
299        // A path that never existed yields None, not an error.
300        assert_eq!(file_at_commit(root, head, "missing.txt").unwrap(), None);
301    }
302
303    #[test]
304    fn non_repo_reports_false() {
305        let dir = tempfile::tempdir().unwrap();
306        assert!(!is_repo(dir.path()));
307    }
308}