Skip to main content

tix/
git.rs

1//! Best-effort git plumbing for recording the commit that closes an issue.
2//!
3//! A commit cannot contain its own hash: its hash is derived from its tree
4//! *and* its message, so a ticket can never store the hash of the very commit
5//! that writes it. `tix` therefore records either the *work* commit that was
6//! already made (auto-captured from `HEAD` when a ticket is closed) or, after
7//! the closure commit lands, resolves the commit that last touched the issue
8//! file. Both are best-effort: a missing `git`, a non-repository directory, or
9//! an uncommitted file yields [`None`] and never an error.
10
11use std::path::Path;
12use std::process::Command;
13
14/// The current `HEAD` commit hash, best-effort.
15pub fn current_head(cwd: &Path) -> Option<String> {
16    current_head_with(&["git"], cwd)
17}
18
19/// The most recent commit that touched `path`, best-effort.
20pub fn last_commit_for_path(cwd: &Path, path: &Path) -> Option<String> {
21    last_commit_for_path_with(&["git"], cwd, path)
22}
23
24/// The hash to record for automatic resolution (`--commit auto`): the commit
25/// that last touched the issue file, falling back to `HEAD`.
26pub fn resolve_closing_commit(cwd: &Path, issue_path: &Path) -> Option<String> {
27    last_commit_for_path(cwd, issue_path).or_else(|| current_head(cwd))
28}
29
30/// The spawn seam behind [`current_head`]: `program` defaults to `["git"]` but
31/// tests point it at `["sh", <fake-git>]`. Failures surface as [`None`].
32fn current_head_with(program: &[&str], cwd: &Path) -> Option<String> {
33    non_empty(&run_git(program, cwd, &["rev-parse", "HEAD"])?)
34}
35
36/// The spawn seam behind [`last_commit_for_path`].
37fn last_commit_for_path_with(program: &[&str], cwd: &Path, path: &Path) -> Option<String> {
38    let path = path.to_string_lossy();
39    non_empty(&run_git(
40        program,
41        cwd,
42        &["log", "-1", "--format=%H", "--", &path],
43    )?)
44}
45
46/// Trim `stdout`, mapping an empty result to [`None`].
47fn non_empty(stdout: &str) -> Option<String> {
48    let value = stdout.trim();
49    if value.is_empty() {
50        None
51    } else {
52        Some(value.to_string())
53    }
54}
55
56/// The injectable subprocess runner: any non-zero status or spawn failure is
57/// treated as "no value". Reads stdout lossily and untrimmed; trimming is the
58/// caller's job.
59fn run_git(program: &[&str], cwd: &Path, args: &[&str]) -> Option<String> {
60    let (command, prefix) = program.split_first()?;
61    let output = Command::new(command)
62        .args(prefix)
63        .args(args)
64        .current_dir(cwd)
65        .output()
66        .ok()?;
67    if !output.status.success() {
68        return None;
69    }
70    Some(String::from_utf8_lossy(&output.stdout).into_owned())
71}
72
73#[cfg(test)]
74mod tests {
75    #[allow(clippy::wildcard_imports)]
76    use super::*;
77    use std::os::unix::fs::PermissionsExt;
78    use tempfile::TempDir;
79
80    /// Write a fake `git` script into `dir` and return the `["sh", <path>]`
81    /// command prefix that runs it. Running the script through `sh` (rather
82    /// than exec'ing it directly) avoids the `ETXTBSY` race on overlay
83    /// filesystems where a just-written file cannot be executed.
84    fn fake_git(dir: &TempDir, script: &str) -> Vec<String> {
85        let path = dir.path().join("fake-git");
86        std::fs::write(&path, script).unwrap();
87        let mut perms = std::fs::metadata(&path).unwrap().permissions();
88        perms.set_mode(0o755);
89        std::fs::set_permissions(&path, perms).unwrap();
90        vec!["sh".to_string(), path.display().to_string()]
91    }
92
93    /// A fake `git` that answers `rev-parse HEAD` and
94    /// `log -1 --format=%H -- <path>`.
95    fn script(head: Option<&str>, log: Option<&str>) -> String {
96        let head_arm = match head {
97            Some(h) => format!("  \"rev-parse\") printf '%s\\n' '{h}';;\n"),
98            None => "  \"rev-parse\") exit 1;;\n".to_string(),
99        };
100        let log_arm = match log {
101            Some(h) => format!("  \"log\") printf '%s\\n' '{h}';;\n"),
102            None => "  \"log\") exit 1;;\n".to_string(),
103        };
104        format!("#!/bin/sh\ncase \"$1\" in\n{head_arm}{log_arm}  *) exit 1;;\nesac\n")
105    }
106
107    fn cmd(parts: &[String]) -> Vec<&str> {
108        parts.iter().map(String::as_str).collect()
109    }
110
111    #[test]
112    fn current_head_reads_and_trims_rev_parse() {
113        let tmp = TempDir::new().unwrap();
114        let git = fake_git(&tmp, &script(Some("  abc123\n"), None));
115        assert_eq!(
116            current_head_with(&cmd(&git), tmp.path()),
117            Some("abc123".to_string())
118        );
119    }
120
121    #[test]
122    fn current_head_is_none_on_failure() {
123        let tmp = TempDir::new().unwrap();
124        let git = fake_git(&tmp, &script(None, None));
125        assert_eq!(current_head_with(&cmd(&git), tmp.path()), None);
126    }
127
128    #[test]
129    fn last_commit_for_path_reads_log() {
130        let tmp = TempDir::new().unwrap();
131        let git = fake_git(&tmp, &script(None, Some("def456\n")));
132        let path = tmp.path().join("docs/issues/a.md");
133        assert_eq!(
134            last_commit_for_path_with(&cmd(&git), tmp.path(), &path),
135            Some("def456".to_string())
136        );
137    }
138
139    #[test]
140    fn last_commit_for_path_is_none_on_failure() {
141        let tmp = TempDir::new().unwrap();
142        let git = fake_git(&tmp, &script(None, None));
143        let path = tmp.path().join("docs/issues/a.md");
144        assert_eq!(
145            last_commit_for_path_with(&cmd(&git), tmp.path(), &path),
146            None
147        );
148    }
149
150    #[test]
151    fn resolve_closing_commit_prefers_the_file_commit() {
152        let tmp = TempDir::new().unwrap();
153        let git = fake_git(&tmp, &script(Some("head999"), Some("file111")));
154        let path = tmp.path().join("docs/issues/a.md");
155        assert_eq!(
156            resolve_closing_commit_with(&cmd(&git), tmp.path(), &path),
157            Some("file111".to_string())
158        );
159    }
160
161    #[test]
162    fn resolve_closing_commit_falls_back_to_head() {
163        let tmp = TempDir::new().unwrap();
164        let git = fake_git(&tmp, &script(Some("head999"), None));
165        let path = tmp.path().join("docs/issues/a.md");
166        assert_eq!(
167            resolve_closing_commit_with(&cmd(&git), tmp.path(), &path),
168            Some("head999".to_string())
169        );
170    }
171
172    #[test]
173    fn missing_binary_is_none() {
174        let tmp = TempDir::new().unwrap();
175        assert_eq!(current_head_with(&["/nonexistent/git"], tmp.path()), None);
176        assert_eq!(
177            last_commit_for_path_with(&["/nonexistent/git"], tmp.path(), &tmp.path().join("a.md")),
178            None
179        );
180    }
181
182    /// Test-only seam mirroring [`resolve_closing_commit`] with an injectable program.
183    fn resolve_closing_commit_with(program: &[&str], cwd: &Path, path: &Path) -> Option<String> {
184        last_commit_for_path_with(program, cwd, path).or_else(|| current_head_with(program, cwd))
185    }
186}