Skip to content

g1t/crates/runner/src/clone.rs

246 lines11,059 bytesCodeBlame
1//! How a sandbox clones and fetches: shallow, and deeper only when it needs
2//! to be. A sandbox's work starts from one commit (an agent's branch, the
3//! commit checks run on, a build), so the history behind it is usually
4//! never read; a full clone of g1t took 5.4 s against 3.8 s at depth 1
5//! (docs/ARTIFACTS.md, R8). Work that merges (catching up, the merge queue,
6//! a merge check, a review's diff) deepens until the two sides share a
7//! commit, and fetches everything only as the last resort.
8//!
9//! Environment, for the operator or a self-hosted runner:
10//!
11//! - `G1T_CLONE_DEPTH`: commits to clone (default 1); `0` or `full`
12//! clones everything, as before.
13//! - `G1T_CLONE_FILTER=blob:none`: a blobless clone as well, whose files
14//! are fetched as they are read. Off by default: Cloudflare Artifacts
15//! documents partial clone as unsupported over protocol v1, and it is
16//! cheaper only when few files are read.
17
18use std::path::Path;
19use std::process::Command;
20
21use anyhow::Result;
22
23use crate::git;
24
25/// How deep each step of `share_history` goes before fetching everything.
26const DEEPEN: [u32; 3] = [50, 500, 5000];
27
28/// The clone's depth and filter options, from the environment.
29pub(crate) fn clone_options() -> Vec<String> {
30 options(std::env::var("G1T_CLONE_DEPTH").ok().as_deref(), std::env::var("G1T_CLONE_FILTER").ok().as_deref())
31}
32
33fn options(depth: Option<&str>, filter: Option<&str>) -> Vec<String> {
34 let mut out = Vec::new();
35 match depth.map(str::trim) {
36 Some("0") | Some("full") => {}
37 Some(n) if n.parse::<u32>().is_ok_and(|n| n > 0) => out.push(format!("--depth={n}")),
38 _ => out.push("--depth=1".to_owned()),
39 }
40 if filter.map(str::trim) == Some("blob:none") {
41 out.push("--filter=blob:none".to_owned());
42 }
43 out
44}
45
46/// The depth option for a fetch into the clone: as shallow as the clone,
47/// or nothing for a full one (a fetch into a shallow clone without one
48/// would bring the branch's whole history).
49pub(crate) fn fetch_options() -> Vec<String> {
50 clone_options().into_iter().filter(|o| o.starts_with("--depth")).collect()
51}
52
53/// `git clone` with the clone options: `git -c <auth> clone --quiet
54/// <options> <extra> <remote> <into>`.
55pub(crate) fn clone(dir: &Path, auth: &str, extra: &[&str], remote: &str, into: &str) -> Result<String> {
56 let options = clone_options();
57 let mut args: Vec<&str> = vec!["-c", auth, "clone", "--quiet"];
58 args.extend(options.iter().map(String::as_str));
59 args.extend(extra);
60 args.extend([remote, into]);
61 git(dir, &args)
62}
63
64/// `git fetch --quiet <depth> <remote> <refspec>`, as shallow as the clone.
65pub(crate) fn fetch(dir: &Path, auth: &str, remote: &str, refspec: &str) -> Result<String> {
66 let options = fetch_options();
67 let mut args: Vec<&str> = vec!["-c", auth, "fetch", "--quiet"];
68 args.extend(options.iter().map(String::as_str));
69 args.extend([remote, refspec]);
70 git(dir, &args)
71}
72
73/// Whether the clone is shallow.
74pub(crate) fn is_shallow(dir: &Path) -> bool {
75 git(dir, &["rev-parse", "--is-shallow-repository"]).is_ok_and(|out| out == "true")
76}
77
78/// Whether `commit` is in the clone.
79pub(crate) fn has(dir: &Path, commit: &str) -> bool {
80 Command::new("git")
81 .current_dir(dir)
82 .args(["cat-file", "-e", &format!("{commit}^{{commit}}")])
83 .status()
84 .is_ok_and(|status| status.success())
85}
86
87fn merge_base(dir: &Path, a: &str, b: &str) -> bool {
88 Command::new("git")
89 .current_dir(dir)
90 .args(["merge-base", a, b])
91 .output()
92 .is_ok_and(|output| output.status.success())
93}
94
95/// Deepens a shallow clone until `a` and `b` (commits, or refs such as
96/// `HEAD`) share a commit, so they can be compared or merged: each of
97/// `sources` (remote, branch) fetched deeper, then fully. `FETCH_HEAD`
98/// ends on the last source's branch, so list the one a caller reads as
99/// `FETCH_HEAD` last. A full clone, or one where they already share a
100/// commit, fetches nothing.
101pub(crate) fn share_history(dir: &Path, auth: &str, sources: &[(&str, &str)], a: &str, b: &str) -> Result<()> {
102 if !is_shallow(dir) || merge_base(dir, a, b) {
103 return Ok(());
104 }
105 for depth in DEEPEN {
106 for (remote, branch) in sources {
107 git(dir, &["-c", auth, "fetch", "--quiet", &format!("--deepen={depth}"), remote, branch])?;
108 }
109 if merge_base(dir, a, b) || !is_shallow(dir) {
110 return Ok(());
111 }
112 }
113 for (remote, branch) in sources {
114 // "--unshallow on a complete repository" once the first has done it.
115 let _ = git(dir, &["-c", auth, "fetch", "--quiet", "--unshallow", remote, branch]);
116 }
117 Ok(())
118}
119
120/// Makes sure `commit` is in the clone: fetched by name, which servers
121/// allow for commits on their branches, else the whole of `branch`, else
122/// every branch (a preview's commit can be on a branch the clone never
123/// had, from a server that will not fetch a commit by name).
124pub(crate) fn ensure(dir: &Path, auth: &str, remote: &str, branch: &str, commit: &str) -> Result<()> {
125 if has(dir, commit) {
126 return Ok(());
127 }
128 let _ = fetch(dir, auth, remote, commit);
129 if has(dir, commit) {
130 return Ok(());
131 }
132 if is_shallow(dir) {
133 let _ = git(dir, &["-c", auth, "fetch", "--quiet", "--unshallow", remote, branch]);
134 if has(dir, commit) {
135 return Ok(());
136 }
137 }
138 let shallow = if is_shallow(dir) { "--unshallow" } else { "--quiet" };
139 let _ = git(dir, &["-c", auth, "fetch", "--quiet", shallow, remote, "+refs/heads/*:refs/remotes/everything/*"]);
140 Ok(())
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 #[test]
148 fn shallow_by_default_full_or_blobless_when_asked() {
149 assert_eq!(options(None, None), ["--depth=1"]);
150 assert_eq!(options(Some("50"), None), ["--depth=50"]);
151 assert_eq!(options(Some("0"), None), Vec::<String>::new());
152 assert_eq!(options(Some("full"), Some("blob:none")), ["--filter=blob:none"]);
153 assert_eq!(options(Some("nonsense"), Some("tree:0")), ["--depth=1"]);
154 assert_eq!(options(None, Some("blob:none")), ["--depth=1", "--filter=blob:none"]);
155 }
156
157 /// Clones a repository of its own with history, shallow, and deepens it
158 /// until a branch merges, where git is installed.
159 #[test]
160 fn a_shallow_clone_deepens_until_two_branches_share_a_commit() {
161 if Command::new("git").arg("--version").output().is_err() {
162 return;
163 }
164 let root = std::env::temp_dir().join(format!("g1t-clone-test-{}", std::process::id()));
165 let _ = std::fs::remove_dir_all(&root);
166 let origin = root.join("origin");
167 std::fs::create_dir_all(&origin).unwrap();
168 let run = |dir: &Path, args: &[&str]| git(dir, args).unwrap();
169 run(&origin, &["init", "--quiet", "-b", "main"]);
170 run(&origin, &["config", "user.name", "t"]);
171 run(&origin, &["config", "user.email", "t@example.com"]);
172 // A test repository of its own: never the machine's commit signing.
173 run(&origin, &["config", "commit.gpgsign", "false"]);
174 run(&origin, &["config", "uploadpack.allowReachableSHA1InWant", "true"]);
175 for i in 0..12 {
176 std::fs::write(origin.join("f.txt"), format!("{i}\n")).unwrap();
177 run(&origin, &["add", "f.txt"]);
178 run(&origin, &["commit", "--quiet", "-m", &format!("c{i}")]);
179 }
180 run(&origin, &["branch", "side", "HEAD~10"]);
181 run(&origin, &["checkout", "--quiet", "side"]);
182 std::fs::write(origin.join("g.txt"), "side\n").unwrap();
183 run(&origin, &["add", "g.txt"]);
184 run(&origin, &["commit", "--quiet", "-m", "side"]);
185 run(&origin, &["checkout", "--quiet", "main"]);
186
187 let url = format!("file://{}", origin.display().to_string().replace('\\', "/"));
188 let auth = "http.extraHeader=X-Test: 1";
189 clone(&root, auth, &["--branch", "main"], &url, "work").unwrap();
190 let work = root.join("work");
191 assert!(is_shallow(&work));
192 assert_eq!(git(&work, &["rev-list", "--count", "HEAD"]).unwrap(), "1");
193 fetch(&work, auth, &url, "side").unwrap();
194 assert!(!merge_base(&work, "HEAD", "FETCH_HEAD"));
195 share_history(&work, auth, &[(&url, "main"), (&url, "side")], "HEAD", "FETCH_HEAD").unwrap();
196 assert!(merge_base(&work, "HEAD", "FETCH_HEAD"));
197 // FETCH_HEAD is still the side branch, the last source.
198 assert_eq!(git(&work, &["log", "-1", "--format=%s", "FETCH_HEAD"]).unwrap(), "side");
199 let _ = std::fs::remove_dir_all(&root);
200 }
201
202 /// A build of a commit that is only on another branch, from a server
203 /// that will not fetch a commit by name: a shallow clone of the default
204 /// branch, then `ensure` finds it (a preview of a branch, 2026-10-08:
205 /// "reference is not a tree").
206 #[test]
207 fn a_commit_only_on_another_branch_is_fetched_for_a_build() {
208 if Command::new("git").arg("--version").output().is_err() {
209 return;
210 }
211 let root = std::env::temp_dir().join(format!("g1t-ensure-test-{}", std::process::id()));
212 let _ = std::fs::remove_dir_all(&root);
213 let origin = root.join("origin");
214 std::fs::create_dir_all(&origin).unwrap();
215 let run = |dir: &Path, args: &[&str]| git(dir, args).unwrap();
216 run(&origin, &["init", "--quiet", "-b", "main"]);
217 run(&origin, &["config", "user.name", "t"]);
218 run(&origin, &["config", "user.email", "t@example.com"]);
219 // A test repository of its own: never the machine's commit signing.
220 run(&origin, &["config", "commit.gpgsign", "false"]);
221 run(&origin, &["config", "uploadpack.allowReachableSHA1InWant", "false"]);
222 run(&origin, &["config", "uploadpack.allowAnySHA1InWant", "false"]);
223 for i in 0..3 {
224 std::fs::write(origin.join("f.txt"), format!("{i}\n")).unwrap();
225 run(&origin, &["add", "f.txt"]);
226 run(&origin, &["commit", "--quiet", "-m", &format!("c{i}")]);
227 }
228 run(&origin, &["checkout", "--quiet", "-b", "v2"]);
229 std::fs::write(origin.join("g.txt"), "v2\n").unwrap();
230 run(&origin, &["add", "g.txt"]);
231 run(&origin, &["commit", "--quiet", "-m", "v2"]);
232 let preview = run(&origin, &["rev-parse", "HEAD"]);
233 run(&origin, &["checkout", "--quiet", "main"]);
234
235 let url = format!("file://{}", origin.display().to_string().replace('\\', "/"));
236 let auth = "http.extraHeader=X-Test: 1";
237 clone(&root, auth, &[], &url, "work").unwrap();
238 let work = root.join("work");
239 assert!(!has(&work, &preview));
240 ensure(&work, auth, "origin", "main", &preview).unwrap();
241 assert!(has(&work, &preview));
242 git(&work, &["-c", "advice.detachedHead=false", "checkout", "--quiet", &preview]).unwrap();
243 assert_eq!(std::fs::read_to_string(work.join("g.txt")).unwrap().trim(), "v2");
244 let _ = std::fs::remove_dir_all(&root);
245 }
246}