1use std::path::Path;
19use std::process::Command;
20
21#[derive(Debug)]
23pub enum GitError {
24 NotInstalled(String),
26 CommandFailed {
29 command: &'static str,
30 stderr: String,
31 },
32 Parse(String),
34}
35
36impl std::fmt::Display for GitError {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 match self {
39 GitError::NotInstalled(msg) => write!(f, "git not available: {msg}"),
40 GitError::CommandFailed { command, stderr } => {
41 write!(f, "`git {command}` failed: {}", stderr.trim())
42 }
43 GitError::Parse(msg) => write!(f, "git output parse error: {msg}"),
44 }
45 }
46}
47
48impl std::error::Error for GitError {}
49
50pub fn rev_parse_head(workspace_root: &Path) -> Result<String, GitError> {
53 let out = Command::new("git")
54 .args(["rev-parse", "HEAD"])
55 .current_dir(workspace_root)
56 .output()
57 .map_err(|e| GitError::NotInstalled(format!("spawn `git rev-parse HEAD`: {e}")))?;
58 if !out.status.success() {
59 return Err(GitError::CommandFailed {
60 command: "rev-parse HEAD",
61 stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
62 });
63 }
64 let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
65 if !looks_like_sha(&sha) {
66 return Err(GitError::Parse(format!(
67 "expected 40- or 64-char hex SHA, got `{sha}`"
68 )));
69 }
70 Ok(sha)
71}
72
73pub fn commit_present_on_remote(workspace_root: &Path, commit_sha: &str) -> Result<bool, GitError> {
83 if !looks_like_sha(commit_sha) {
84 return Err(GitError::Parse(format!(
85 "commit_sha `{commit_sha}` is not a hex SHA"
86 )));
87 }
88 let out = Command::new("git")
89 .args(["branch", "-r", "--contains", commit_sha])
90 .current_dir(workspace_root)
91 .output()
92 .map_err(|e| GitError::NotInstalled(format!("spawn `git branch -r --contains`: {e}")))?;
93 if !out.status.success() {
94 let stderr = String::from_utf8_lossy(&out.stderr);
97 if stderr.contains("malformed object name")
98 || stderr.contains("unknown revision")
99 || stderr.contains("no such commit")
100 || stderr.contains("bad object")
101 {
102 return Ok(false);
103 }
104 return Err(GitError::CommandFailed {
105 command: "branch -r --contains",
106 stderr: stderr.into_owned(),
107 });
108 }
109 let stdout = String::from_utf8_lossy(&out.stdout);
112 Ok(stdout
113 .lines()
114 .map(|l| l.trim())
115 .any(|l| l.starts_with("origin/")))
116}
117
118fn looks_like_sha(s: &str) -> bool {
119 (s.len() == 40 || s.len() == 64) && s.bytes().all(|b| b.is_ascii_hexdigit())
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125 use std::fs;
126 use std::process::Command;
127 use tempfile::TempDir;
128
129 fn run_git(repo: &Path, args: &[&str]) {
130 let out = Command::new("git")
131 .args(args)
132 .current_dir(repo)
133 .output()
134 .expect("git");
135 assert!(
136 out.status.success(),
137 "git {args:?} failed: {}",
138 String::from_utf8_lossy(&out.stderr)
139 );
140 }
141
142 fn init_repo() -> TempDir {
143 let tmp = TempDir::new().unwrap();
144 run_git(tmp.path(), &["init", "-q", "-b", "main"]);
145 run_git(tmp.path(), &["config", "user.email", "t@x"]);
148 run_git(tmp.path(), &["config", "user.name", "t"]);
149 run_git(tmp.path(), &["config", "commit.gpgsign", "false"]);
150 fs::write(tmp.path().join("README"), b"x").unwrap();
151 run_git(tmp.path(), &["add", "."]);
152 run_git(tmp.path(), &["commit", "-q", "-m", "init"]);
153 tmp
154 }
155
156 #[test]
157 fn looks_like_sha_accepts_40_and_64_chars() {
158 assert!(looks_like_sha(&"a".repeat(40)));
159 assert!(looks_like_sha(&"f".repeat(40)));
160 assert!(looks_like_sha(&"0".repeat(64)));
161 assert!(!looks_like_sha("abc"));
162 assert!(!looks_like_sha(&"g".repeat(40))); assert!(!looks_like_sha(&"a".repeat(41))); }
165
166 #[test]
167 fn rev_parse_head_returns_40_char_sha_for_fresh_repo() {
168 let repo = init_repo();
169 let sha = rev_parse_head(repo.path()).expect("rev-parse");
170 assert!(looks_like_sha(&sha), "got: {sha}");
171 assert_eq!(sha.len(), 40);
172 }
173
174 #[test]
175 fn commit_present_on_remote_false_for_local_only_repo() {
176 let repo = init_repo();
179 let sha = rev_parse_head(repo.path()).unwrap();
180 let present = commit_present_on_remote(repo.path(), &sha).expect("branch -r");
181 assert!(!present, "local-only repo must report not-pushed");
182 }
183
184 #[test]
185 fn commit_present_on_remote_true_after_simulated_push() {
186 let repo = init_repo();
190 let upstream = TempDir::new().unwrap();
191 run_git(upstream.path(), &["init", "--bare", "-q"]);
192 run_git(
193 repo.path(),
194 &["remote", "add", "origin", upstream.path().to_str().unwrap()],
195 );
196 run_git(repo.path(), &["push", "-q", "origin", "main"]);
197 let sha = rev_parse_head(repo.path()).unwrap();
198 let present = commit_present_on_remote(repo.path(), &sha).expect("branch -r");
199 assert!(
200 present,
201 "after `git push origin main`, commit must be on origin/*"
202 );
203 }
204
205 #[test]
206 fn commit_present_on_remote_rejects_non_sha_input() {
207 let repo = init_repo();
208 let err = commit_present_on_remote(repo.path(), "not-a-sha").unwrap_err();
209 assert!(matches!(err, GitError::Parse(_)));
210 }
211
212 #[test]
213 fn commit_present_on_remote_false_for_unknown_sha() {
214 let repo = init_repo();
215 let bogus = "0".repeat(40);
217 let present = commit_present_on_remote(repo.path(), &bogus).expect("branch -r");
218 assert!(!present, "unknown SHA must report not-on-remote");
219 }
220}