1use std::path::Path;
5use std::process::Output;
6
7use tokio::process::Command;
8
9use super::agent::Shipcheck;
10use super::git_ops;
11use crate::cmd::CommandErrorContext;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Tool {
15 Git,
16 Shipcheck,
17}
18
19impl Tool {
20 pub async fn detect(dir: &Path) -> Self {
23 if !dir.join("shipcheck.toml").exists() {
25 return Tool::Git;
26 }
27
28 match Shipcheck::detect(dir).await {
29 Shipcheck::Available => Tool::Shipcheck,
30 Shipcheck::Absent | Shipcheck::NotInstalled | Shipcheck::Outdated => Tool::Git,
31 }
32 }
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum When {
38 Tested,
40 Always,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum Outcome {
47 Committed,
48 Nothing,
49 Refused {
51 by: Tool,
52 reason: String,
53 },
54}
55
56fn identity(agent: &str) -> (String, String) {
57 let name = crate::config::get("git.name").unwrap_or_else(|_| format!("{agent}[bot]"));
58 let email = crate::config::get("git.email")
59 .unwrap_or_else(|_| format!("{agent}[bot]@users.noreply.github.com"));
60 (name, email)
61}
62
63pub async fn git_commit(dir: &Path, message: &str, agent: &str) -> anyhow::Result<Output> {
65 let (name, email) = identity(agent);
66 Command::new("git")
67 .args(["-c", "commit.gpgsign=false", "commit", "-m", message])
68 .env("GIT_AUTHOR_NAME", &name)
69 .env("GIT_COMMITTER_NAME", &name)
70 .env("GIT_AUTHOR_EMAIL", &email)
71 .env("GIT_COMMITTER_EMAIL", &email)
72 .current_dir(dir)
73 .output_with_context()
74 .await
75}
76
77async fn shipcheck_commit(
78 dir: &Path,
79 bin: &Path,
80 message: &str,
81 agent: &str,
82) -> anyhow::Result<Outcome> {
83 let (name, email) = identity(agent);
84 let out = Command::new(bin)
85 .args(["commit", "-m", message])
86 .env("GIT_AUTHOR_NAME", &name)
87 .env("GIT_COMMITTER_NAME", &name)
88 .env("GIT_AUTHOR_EMAIL", &email)
89 .env("GIT_COMMITTER_EMAIL", &email)
90 .current_dir(dir)
91 .output_with_context()
92 .await?;
93 if out.status.success() {
94 return Ok(Outcome::Committed);
95 }
96
97 let why = String::from_utf8_lossy(&out.stderr).trim().to_string();
98 Ok(Outcome::Refused {
99 by: Tool::Shipcheck,
100 reason: format!("shipcheck commit refused, run `shipcheck run` first: {why}"),
101 })
102}
103
104pub async fn commit_all(
106 dir: &Path,
107 message: &str,
108 agent: &str,
109 when: When,
110) -> anyhow::Result<Outcome> {
111 let tool = Tool::detect(dir).await;
112 let bin = which::which("shipcheck").unwrap_or_else(|_| "shipcheck".into());
113 commit_with(dir, tool, &bin, message, agent, when).await
114}
115
116async fn commit_with(
117 dir: &Path,
118 tool: Tool,
119 shipcheck: &Path,
120 message: &str,
121 agent: &str,
122 when: When,
123) -> anyhow::Result<Outcome> {
124 if !git_ops::has_changes(dir).await {
125 return Ok(Outcome::Nothing);
126 }
127
128 if tool == Tool::Shipcheck {
129 let outcome = shipcheck_commit(dir, shipcheck, message, agent).await?;
130 let Outcome::Refused { reason, .. } = &outcome else {
131 return Ok(outcome);
132 };
133 if when == When::Tested {
134 return Ok(outcome);
135 }
136
137 tracing::info!(reason = %reason, "committing untested work through git at the end of the session");
138 }
139
140 let add = Command::new("git")
141 .args(["add", "-A"])
142 .current_dir(dir)
143 .output_with_context()
144 .await?;
145 if !add.status.success() {
146 tracing::warn!("git add -A failed");
147 return Ok(Outcome::Refused {
148 by: Tool::Git,
149 reason: String::from_utf8_lossy(&add.stderr).trim().to_string(),
150 });
151 }
152
153 let out = git_commit(dir, message, agent).await?;
154 if out.status.success() {
155 return Ok(Outcome::Committed);
156 }
157
158 let reason = String::from_utf8_lossy(&out.stderr).trim().to_string();
159 unstage(dir, &reason).await?;
160 Ok(Outcome::Refused {
161 by: Tool::Git,
162 reason,
163 })
164}
165
166async fn unstage(dir: &Path, commit_err: &str) -> anyhow::Result<()> {
168 let restore = Command::new("git")
169 .args(["restore", "--staged", "."])
170 .current_dir(dir)
171 .output_with_context()
172 .await;
173 let restore_err = match restore {
174 Ok(o) if o.status.success() => return Ok(()),
175 Ok(o) => String::from_utf8_lossy(&o.stderr).trim().to_string(),
176 Err(e) => e.to_string(),
177 };
178 tracing::warn!(err = %restore_err, "git restore --staged failed, falling back to git reset HEAD -- .");
179 let reset = Command::new("git")
180 .args(["reset", "HEAD", "--", "."])
181 .current_dir(dir)
182 .output_with_context()
183 .await?;
184 if !reset.status.success() {
185 anyhow::bail!(
186 "git commit failed and cleanup failed (git restore --staged + git reset HEAD -- .): commit stderr: {commit_err}; restore: {restore_err}; reset stderr: {}",
187 String::from_utf8_lossy(&reset.stderr).trim()
188 );
189 }
190
191 Ok(())
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197
198 fn git(dir: &Path, args: &[&str]) -> String {
199 let out = std::process::Command::new("git")
200 .args([
201 "-c",
202 "user.name=T",
203 "-c",
204 "user.email=t@t",
205 "-c",
206 "commit.gpgsign=false",
207 ])
208 .args(args)
209 .current_dir(dir)
210 .output()
211 .unwrap();
212 assert!(
213 out.status.success(),
214 "git {args:?}: {}",
215 String::from_utf8_lossy(&out.stderr)
216 );
217 String::from_utf8_lossy(&out.stdout).to_string()
218 }
219
220 fn repo() -> tempfile::TempDir {
221 let dir = tempfile::tempdir().unwrap();
222 git(dir.path(), &["init", "-b", "main"]);
223 std::fs::write(dir.path().join("base.txt"), "base").unwrap();
224 git(dir.path(), &["add", "base.txt"]);
225 git(dir.path(), &["commit", "-m", "base"]);
226 dir
227 }
228
229 fn fake_shipcheck(dir: &Path, ok: bool) -> std::path::PathBuf {
231 use std::os::unix::fs::PermissionsExt;
232 let bin = dir.join("shipcheck");
233 let body = if ok {
234 "#!/bin/sh\ngit add -A && git -c commit.gpgsign=false commit -q -m \"$3\"\n"
235 } else {
236 "#!/bin/sh\necho 'no passing result for this tree' >&2\nexit 1\n"
237 };
238 std::fs::write(&bin, body).unwrap();
239 std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
240 bin
241 }
242
243 #[tokio::test]
244 async fn git_commits_every_change_and_reports_nothing_on_a_clean_tree() {
245 let dir = repo();
246 let missing = Path::new("/nonexistent/shipcheck");
247 std::fs::write(dir.path().join("a.txt"), "a").unwrap();
248 let out = commit_with(
249 dir.path(),
250 Tool::Git,
251 missing,
252 "step",
253 "claude",
254 When::Tested,
255 )
256 .await
257 .unwrap();
258 assert_eq!(out, Outcome::Committed);
259 assert!(
260 git(dir.path(), &["log", "-1", "--format=%s%n%an"]).starts_with("step\nclaude[bot]")
261 );
262 let out = commit_with(
263 dir.path(),
264 Tool::Git,
265 missing,
266 "again",
267 "claude",
268 When::Tested,
269 )
270 .await
271 .unwrap();
272 assert_eq!(out, Outcome::Nothing);
273 }
274
275 #[tokio::test]
276 async fn a_shipcheck_refusal_commits_nothing_and_leaves_the_tree() {
277 let dir = repo();
278 let bins = tempfile::tempdir().unwrap();
279 let bin = fake_shipcheck(bins.path(), false);
280 let head = git(dir.path(), &["rev-parse", "HEAD"]);
281 std::fs::write(dir.path().join("untested.txt"), "x").unwrap();
282 let out = commit_with(
283 dir.path(),
284 Tool::Shipcheck,
285 &bin,
286 "step",
287 "claude",
288 When::Tested,
289 )
290 .await
291 .unwrap();
292 match out {
293 Outcome::Refused { by, reason } => {
294 assert_eq!(by, Tool::Shipcheck);
295 assert!(reason.contains("shipcheck run"), "{reason}");
296 }
297 other => panic!("expected a refusal, got {other:?}"),
298 }
299 assert_eq!(git(dir.path(), &["rev-parse", "HEAD"]), head);
300 assert_eq!(
301 git(dir.path(), &["status", "--porcelain"]),
302 "?? untested.txt\n"
303 );
304 }
305
306 #[tokio::test]
307 async fn an_accepting_shipcheck_commits() {
308 let dir = repo();
309 let bins = tempfile::tempdir().unwrap();
310 let bin = fake_shipcheck(bins.path(), true);
311 std::fs::write(dir.path().join("tested.txt"), "x").unwrap();
312 let out = commit_with(
313 dir.path(),
314 Tool::Shipcheck,
315 &bin,
316 "tested step",
317 "claude",
318 When::Tested,
319 )
320 .await
321 .unwrap();
322 assert_eq!(out, Outcome::Committed);
323 assert_eq!(
324 git(dir.path(), &["log", "-1", "--format=%s"]).trim(),
325 "tested step"
326 );
327 }
328
329 #[tokio::test]
330 async fn the_end_of_a_session_commits_untested_work_through_git() {
331 let dir = repo();
332 let bins = tempfile::tempdir().unwrap();
333 let bin = fake_shipcheck(bins.path(), false);
334 std::fs::write(dir.path().join("untested.txt"), "x").unwrap();
335 let out = commit_with(
336 dir.path(),
337 Tool::Shipcheck,
338 &bin,
339 "final",
340 "claude",
341 When::Always,
342 )
343 .await
344 .unwrap();
345 assert_eq!(out, Outcome::Committed);
346 assert_eq!(git(dir.path(), &["status", "--porcelain"]), "");
347 assert_eq!(
348 git(dir.path(), &["log", "-1", "--format=%s"]).trim(),
349 "final"
350 );
351 }
352}