1use std::path::Path;
12use std::process::Command;
13
14pub fn current_head(cwd: &Path) -> Option<String> {
16 current_head_with(&["git"], cwd)
17}
18
19pub fn last_commit_for_path(cwd: &Path, path: &Path) -> Option<String> {
21 last_commit_for_path_with(&["git"], cwd, path)
22}
23
24pub 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
30fn current_head_with(program: &[&str], cwd: &Path) -> Option<String> {
33 non_empty(&run_git(program, cwd, &["rev-parse", "HEAD"])?)
34}
35
36fn 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
46fn 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
56fn 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 use super::*;
76 use std::os::unix::fs::PermissionsExt;
77 use tempfile::TempDir;
78
79 fn fake_git(dir: &TempDir, script: &str) -> Vec<String> {
84 let path = dir.path().join("fake-git");
85 std::fs::write(&path, script).unwrap();
86 let mut perms = std::fs::metadata(&path).unwrap().permissions();
87 perms.set_mode(0o755);
88 std::fs::set_permissions(&path, perms).unwrap();
89 vec!["sh".to_string(), path.display().to_string()]
90 }
91
92 fn script(head: Option<&str>, log: Option<&str>) -> String {
95 let head_arm = match head {
96 Some(h) => format!(" \"rev-parse\") printf '%s\\n' '{h}';;\n"),
97 None => " \"rev-parse\") exit 1;;\n".to_string(),
98 };
99 let log_arm = match log {
100 Some(h) => format!(" \"log\") printf '%s\\n' '{h}';;\n"),
101 None => " \"log\") exit 1;;\n".to_string(),
102 };
103 format!("#!/bin/sh\ncase \"$1\" in\n{head_arm}{log_arm} *) exit 1;;\nesac\n")
104 }
105
106 fn cmd(parts: &[String]) -> Vec<&str> {
107 parts.iter().map(String::as_str).collect()
108 }
109
110 #[test]
111 fn current_head_reads_and_trims_rev_parse() {
112 let tmp = TempDir::new().unwrap();
113 let git = fake_git(&tmp, &script(Some(" abc123\n"), None));
114 assert_eq!(
115 current_head_with(&cmd(&git), tmp.path()),
116 Some("abc123".to_string())
117 );
118 }
119
120 #[test]
121 fn current_head_is_none_on_failure() {
122 let tmp = TempDir::new().unwrap();
123 let git = fake_git(&tmp, &script(None, None));
124 assert_eq!(current_head_with(&cmd(&git), tmp.path()), None);
125 }
126
127 #[test]
128 fn last_commit_for_path_reads_log() {
129 let tmp = TempDir::new().unwrap();
130 let git = fake_git(&tmp, &script(None, Some("def456\n")));
131 let path = tmp.path().join("docs/issues/a.md");
132 assert_eq!(
133 last_commit_for_path_with(&cmd(&git), tmp.path(), &path),
134 Some("def456".to_string())
135 );
136 }
137
138 #[test]
139 fn last_commit_for_path_is_none_on_failure() {
140 let tmp = TempDir::new().unwrap();
141 let git = fake_git(&tmp, &script(None, None));
142 let path = tmp.path().join("docs/issues/a.md");
143 assert_eq!(
144 last_commit_for_path_with(&cmd(&git), tmp.path(), &path),
145 None
146 );
147 }
148
149 #[test]
150 fn resolve_closing_commit_prefers_the_file_commit() {
151 let tmp = TempDir::new().unwrap();
152 let git = fake_git(&tmp, &script(Some("head999"), Some("file111")));
153 let path = tmp.path().join("docs/issues/a.md");
154 assert_eq!(
155 resolve_closing_commit_with(&cmd(&git), tmp.path(), &path),
156 Some("file111".to_string())
157 );
158 }
159
160 #[test]
161 fn resolve_closing_commit_falls_back_to_head() {
162 let tmp = TempDir::new().unwrap();
163 let git = fake_git(&tmp, &script(Some("head999"), None));
164 let path = tmp.path().join("docs/issues/a.md");
165 assert_eq!(
166 resolve_closing_commit_with(&cmd(&git), tmp.path(), &path),
167 Some("head999".to_string())
168 );
169 }
170
171 #[test]
172 fn missing_binary_is_none() {
173 let tmp = TempDir::new().unwrap();
174 assert_eq!(current_head_with(&["/nonexistent/git"], tmp.path()), None);
175 assert_eq!(
176 last_commit_for_path_with(&["/nonexistent/git"], tmp.path(), &tmp.path().join("a.md")),
177 None
178 );
179 }
180
181 fn resolve_closing_commit_with(program: &[&str], cwd: &Path, path: &Path) -> Option<String> {
183 last_commit_for_path_with(program, cwd, path).or_else(|| current_head_with(program, cwd))
184 }
185}