Skip to main content

repolith_actions/
git_clone.rs

1//! `GitClone` action — fetches a git repository into a local path.
2//!
3//! Shells out to the `git` CLI rather than linking `git2` (no C bindings,
4//! faster compile, and `git` is a hard requirement on every dev/CI host
5//! anyway). Every spawned subprocess is raced against the [`Ctx::cancel`]
6//! token via `tokio::select!` so the orchestrator's `FailFast` mode can
7//! short-circuit a long-running clone.
8//!
9//! [`Ctx::cancel`]: repolith_core::types::Ctx::cancel
10
11use crate::util::{check_status, run_with_cancel};
12use async_trait::async_trait;
13use repolith_core::action::Action;
14use repolith_core::types::{ActionId, BuildError, BuildOutput, Ctx, Sha256};
15use sha2::{Digest, Sha256 as ShaHasher};
16use std::path::PathBuf;
17use tokio::process::Command;
18
19/// Action that mirrors a remote git repository into a local working tree.
20///
21/// On first execution, performs `git clone <url> <path>`. On subsequent
22/// executions, performs `git -C <path> fetch origin` followed by
23/// `git -C <path> reset --hard FETCH_HEAD`. The `output_hash` is the
24/// SHA-256 of the freshly-checked-out HEAD commit's SHA-1.
25pub struct GitClone {
26    /// Action identifier for the plan.
27    pub id: ActionId,
28    /// Remote git URL to clone from.
29    pub repo_url: String,
30    /// Local destination path.
31    pub path: PathBuf,
32    /// IDs of actions that must complete before this one runs.
33    pub deps: Vec<ActionId>,
34}
35
36#[async_trait]
37impl Action for GitClone {
38    fn id(&self) -> ActionId {
39        self.id.clone()
40    }
41
42    fn deps(&self) -> Vec<ActionId> {
43        self.deps.clone()
44    }
45
46    async fn input_hash(&self, ctx: &Ctx) -> Result<Sha256, BuildError> {
47        // `git ls-remote -- <url> HEAD` returns one line: `<sha1-hex>\tHEAD`.
48        let mut cmd = Command::new("git");
49        cmd.args(ls_remote_args(&self.repo_url));
50        let out = run_with_cancel(cmd, &ctx.cancel).await?;
51        if !out.status.success() {
52            return Err(BuildError::UpstreamUnreachable(format!(
53                "git ls-remote {}: {}",
54                self.repo_url,
55                String::from_utf8_lossy(&out.stderr).trim()
56            )));
57        }
58        // `git ls-remote` returning exit 0 with empty stdout (repo with no
59        // HEAD, or a server quirk) used to silently produce an empty SHA1 —
60        // and a stable bogus `input_hash` that made re-runs look "up to date"
61        // forever. Reject explicitly so the user sees the problem.
62        let sha1 = String::from_utf8_lossy(&out.stdout)
63            .split_whitespace()
64            .next()
65            .unwrap_or_default()
66            .to_string();
67        if sha1.is_empty() {
68            return Err(BuildError::UpstreamUnreachable(format!(
69                "git ls-remote {} returned no HEAD",
70                self.repo_url
71            )));
72        }
73        Ok(hash_url_and_sha(&self.repo_url, &sha1))
74    }
75
76    async fn execute(&self, ctx: &Ctx) -> Result<BuildOutput, BuildError> {
77        let path_str = self
78            .path
79            .to_str()
80            .ok_or_else(|| BuildError::Io(format!("non-utf8 path: {:?}", self.path)))?;
81
82        if self.path.join(".git").is_dir() {
83            // Refresh existing clone: fetch + hard reset.
84            let mut fetch = Command::new("git");
85            fetch.args(["-C", path_str, "fetch", "origin", "--quiet"]);
86            check_status(&run_with_cancel(fetch, &ctx.cancel).await?)?;
87
88            let mut reset = Command::new("git");
89            reset.args(["-C", path_str, "reset", "--hard", "FETCH_HEAD", "--quiet"]);
90            check_status(&run_with_cancel(reset, &ctx.cancel).await?)?;
91        } else {
92            // First clone — create parent dir if needed.
93            if let Some(parent) = self.path.parent()
94                && !parent.as_os_str().is_empty()
95            {
96                std::fs::create_dir_all(parent)
97                    .map_err(|e| BuildError::Io(format!("create_dir_all: {e}")))?;
98            }
99            let mut clone = Command::new("git");
100            clone.args(clone_args(&self.repo_url, path_str));
101            check_status(&run_with_cancel(clone, &ctx.cancel).await?)?;
102        }
103
104        // Resolve current HEAD for output_hash + diagnostic stdout.
105        let mut head = Command::new("git");
106        head.args(["-C", path_str, "rev-parse", "HEAD"]);
107        let head_out = run_with_cancel(head, &ctx.cancel).await?;
108        if !head_out.status.success() {
109            return Err(BuildError::CommandFailed {
110                exit_code: head_out.status.code().unwrap_or(-1),
111                stderr: String::from_utf8_lossy(&head_out.stderr).trim().to_string(),
112            });
113        }
114        let sha1 = String::from_utf8_lossy(&head_out.stdout).trim().to_string();
115        let mut h = ShaHasher::new();
116        h.update(sha1.as_bytes());
117        Ok(BuildOutput {
118            output_hash: Sha256(h.finalize().into()),
119            stdout: format!("HEAD {sha1}"),
120        })
121    }
122}
123
124/// Hash a `(url, sha1-hex)` pair into a `Sha256` for use as `input_hash`.
125/// Mixing the URL prevents collisions across different mirrors of the
126/// same commit.
127fn hash_url_and_sha(url: &str, sha1: &str) -> Sha256 {
128    let mut h = ShaHasher::new();
129    h.update(url.as_bytes());
130    h.update(b":");
131    h.update(sha1.as_bytes());
132    Sha256(h.finalize().into())
133}
134
135// Defense-in-depth: every git subprocess that takes a user-supplied URL as
136// a positional argument receives `--` immediately before the URL so a
137// `-prefixed` URL that ever slips past the manifest validator can't be
138// parsed as a flag by git.
139fn ls_remote_args(url: &str) -> [&str; 4] {
140    ["ls-remote", "--", url, "HEAD"]
141}
142
143fn clone_args<'a>(url: &'a str, path: &'a str) -> [&'a str; 5] {
144    ["clone", "--quiet", "--", url, path]
145}
146
147#[cfg(test)]
148mod argv_tests {
149    use super::{clone_args, ls_remote_args};
150
151    fn assert_separator_before(args: &[&str], url: &str) {
152        let dash = args
153            .iter()
154            .position(|a| *a == "--")
155            .unwrap_or_else(|| panic!("`--` argv separator missing from {args:?}"));
156        let url_pos = args
157            .iter()
158            .position(|a| *a == url)
159            .unwrap_or_else(|| panic!("url `{url}` missing from {args:?}"));
160        assert!(
161            dash < url_pos,
162            "`--` (idx {dash}) must precede url (idx {url_pos}) in {args:?}"
163        );
164    }
165
166    #[test]
167    fn ls_remote_argv_has_separator() {
168        let url = "https://example.com/r.git";
169        assert_separator_before(&ls_remote_args(url), url);
170    }
171
172    #[test]
173    fn clone_argv_has_separator() {
174        let url = "https://example.com/r.git";
175        let path = "/tmp/r";
176        assert_separator_before(&clone_args(url, path), url);
177    }
178}