Skip to main content

orch/engine/runner/
commit.rs

1//! How orch commits. A repository that uses shipcheck commits through `shipcheck commit`, which
2//! commits only a tree its checks passed, any other repository through git
3
4use 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    /// The same rule the agent prompt follows: shipcheck when the repository opts in and the
22    /// installed shipcheck has `commit`, git otherwise
23    pub async fn detect(dir: &Path) -> Self {
24        // Without the file there is nothing to ask, and asking runs `shipcheck --help`
25        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/// Whether a commit may skip shipcheck's gate
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum When {
39    /// Only a tree shipcheck already passed, for steps during a session
40    Tested,
41    /// Commit anyway, for the end of a session, so the agent's work is never left uncommitted.
42    /// shipcheck's hooks run the checks when the branch is pushed
43    Always,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum Outcome {
48    Committed,
49    Nothing,
50    /// The commit did not happen, and the tree is as it was. shipcheck refuses a tree without a passing run
51    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
64/// The git commit behind every orch commit: the agent's identity, and never the operator's signing agent
65pub 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
105/// Commit every change in `dir` with the tool the repository uses
106pub 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
167/// Leave a failed commit's files unstaged, so the next check sees them and can retry cleanly
168async 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    /// A stand-in for shipcheck: `ok` commits like `shipcheck commit`, anything else refuses
237    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}