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;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Tool {
15    Git,
16    Shipcheck,
17}
18
19impl Tool {
20    /// The same rule the agent prompt follows: shipcheck when the repository opts in and the
21    /// installed shipcheck has `commit`, git otherwise
22    pub async fn detect(dir: &Path) -> Self {
23        // Without the file there is nothing to ask, and asking runs `shipcheck --help`
24        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/// Whether a commit may skip shipcheck's gate
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum When {
38    /// Only a tree shipcheck already passed, for steps during a session
39    Tested,
40    /// Commit anyway, for the end of a session, so the agent's work is never left uncommitted.
41    /// shipcheck's hooks run the checks when the branch is pushed
42    Always,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum Outcome {
47    Committed,
48    Nothing,
49    /// The commit did not happen, and the tree is as it was. shipcheck refuses a tree without a passing run
50    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
63/// The git commit behind every orch commit: the agent's identity, and never the operator's signing agent
64pub 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
104/// Commit every change in `dir` with the tool the repository uses
105pub 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
166/// Leave a failed commit's files unstaged, so the next check sees them and can retry cleanly
167async 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    /// A stand-in for shipcheck: `ok` commits like `shipcheck commit`, anything else refuses
230    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}