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 #[allow(clippy::wildcard_imports)]
76 use super::*;
77 use std::os::unix::fs::PermissionsExt;
78 use tempfile::TempDir;
79
80 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 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 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}