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