flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/crates/runner/src/clone.rs

192 lines8,111 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`.
122pub(crate) fn ensure(dir: &Path, auth: &str, remote: &str, branch: &str, commit: &str) -> Result<()> {
123 if has(dir, commit) {
124 return Ok(());
125 }
126 let _ = fetch(dir, auth, remote, commit);
127 if has(dir, commit) {
128 return Ok(());
129 }
130 if is_shallow(dir) {
131 let _ = git(dir, &["-c", auth, "fetch", "--quiet", "--unshallow", remote, branch]);
132 }
133 Ok(())
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 #[test]
141 fn shallow_by_default_full_or_blobless_when_asked() {
142 assert_eq!(options(None, None), ["--depth=1"]);
143 assert_eq!(options(Some("50"), None), ["--depth=50"]);
144 assert_eq!(options(Some("0"), None), Vec::<String>::new());
145 assert_eq!(options(Some("full"), Some("blob:none")), ["--filter=blob:none"]);
146 assert_eq!(options(Some("nonsense"), Some("tree:0")), ["--depth=1"]);
147 assert_eq!(options(None, Some("blob:none")), ["--depth=1", "--filter=blob:none"]);
148 }
149
150 /// Clones a repository of its own with history, shallow, and deepens it
151 /// until a branch merges, where git is installed.
152 #[test]
153 fn a_shallow_clone_deepens_until_two_branches_share_a_commit() {
154 if Command::new("git").arg("--version").output().is_err() {
155 return;
156 }
157 let root = std::env::temp_dir().join(format!("g1t-clone-test-{}", std::process::id()));
158 let _ = std::fs::remove_dir_all(&root);
159 let origin = root.join("origin");
160 std::fs::create_dir_all(&origin).unwrap();
161 let run = |dir: &Path, args: &[&str]| git(dir, args).unwrap();
162 run(&origin, &["init", "--quiet", "-b", "main"]);
163 run(&origin, &["config", "user.name", "t"]);
164 run(&origin, &["config", "user.email", "t@example.com"]);
165 run(&origin, &["config", "uploadpack.allowReachableSHA1InWant", "true"]);
166 for i in 0..12 {
167 std::fs::write(origin.join("f.txt"), format!("{i}\n")).unwrap();
168 run(&origin, &["add", "f.txt"]);
169 run(&origin, &["commit", "--quiet", "-m", &format!("c{i}")]);
170 }
171 run(&origin, &["branch", "side", "HEAD~10"]);
172 run(&origin, &["checkout", "--quiet", "side"]);
173 std::fs::write(origin.join("g.txt"), "side\n").unwrap();
174 run(&origin, &["add", "g.txt"]);
175 run(&origin, &["commit", "--quiet", "-m", "side"]);
176 run(&origin, &["checkout", "--quiet", "main"]);
177
178 let url = format!("file://{}", origin.display().to_string().replace('\\', "/"));
179 let auth = "http.extraHeader=X-Test: 1";
180 clone(&root, auth, &["--branch", "main"], &url, "work").unwrap();
181 let work = root.join("work");
182 assert!(is_shallow(&work));
183 assert_eq!(git(&work, &["rev-list", "--count", "HEAD"]).unwrap(), "1");
184 fetch(&work, auth, &url, "side").unwrap();
185 assert!(!merge_base(&work, "HEAD", "FETCH_HEAD"));
186 share_history(&work, auth, &[(&url, "main"), (&url, "side")], "HEAD", "FETCH_HEAD").unwrap();
187 assert!(merge_base(&work, "HEAD", "FETCH_HEAD"));
188 // FETCH_HEAD is still the side branch, the last source.
189 assert_eq!(git(&work, &["log", "-1", "--format=%s", "FETCH_HEAD"]).unwrap(), "side");
190 let _ = std::fs::remove_dir_all(&root);
191 }
192}