1use anyhow::{anyhow, bail, Context, Result};
2use std::path::Path;
3use std::process::Command;
4
5fn run(cmd: &mut Command, what: &str) -> Result<Vec<u8>> {
6 let out = cmd.output().with_context(|| format!("spawning {what}"))?;
7 if !out.status.success() {
8 bail!(
9 "{what} failed: {}",
10 String::from_utf8_lossy(&out.stderr).trim()
11 );
12 }
13 Ok(out.stdout)
14}
15
16pub fn init_bare(repo: &Path) -> Result<()> {
17 if let Some(parent) = repo.parent() {
18 std::fs::create_dir_all(parent)
19 .with_context(|| format!("creating {}", parent.display()))?;
20 }
21 run(
22 Command::new("git")
23 .args(["init", "--bare", "-b", "main"])
24 .arg(repo),
25 "git init --bare",
26 )?;
27 Ok(())
28}
29
30pub fn bundle_all(repo: &Path) -> Result<Vec<u8>> {
31 run(
32 Command::new("git")
33 .arg("-C")
34 .arg(repo)
35 .args(["bundle", "create", "-", "--all"]),
36 "git bundle create",
37 )
38}
39
40pub fn bundle_refs(repo: &Path, refs: &[String]) -> Result<Vec<u8>> {
52 let mut spec: Vec<String> = Vec::with_capacity(refs.len() + 1);
53 if head_points_at_selected(repo, refs) {
54 spec.push("HEAD".to_string());
55 }
56 spec.extend(refs.iter().cloned());
57
58 let mut cmd = Command::new("git");
59 cmd.arg("-C")
60 .arg(repo)
61 .args(["bundle", "create", "-"])
62 .args(&spec);
63 run(&mut cmd, "git bundle create")
64}
65
66fn head_points_at_selected(repo: &Path, refs: &[String]) -> bool {
70 Command::new("git")
71 .arg("-C")
72 .arg(repo)
73 .args(["symbolic-ref", "-q", "HEAD"])
74 .output()
75 .ok()
76 .filter(|o| o.status.success())
77 .and_then(|o| String::from_utf8(o.stdout).ok())
78 .map(|head| {
79 let head = head.trim();
80 refs.iter().any(|r| r == head)
81 })
82 .unwrap_or(false)
83}
84
85pub fn ref_exists(repo: &Path, refname: &str) -> bool {
89 Command::new("git")
90 .arg("-C")
91 .arg(repo)
92 .args(["rev-parse", "--verify", "--quiet", refname])
93 .output()
94 .map(|o| o.status.success())
95 .unwrap_or(false)
96}
97
98pub fn clone_bundle(bundle: &Path, dest: &Path) -> Result<()> {
99 run(
100 Command::new("git").arg("clone").arg(bundle).arg(dest),
101 "git clone <bundle>",
102 )?;
103 ensure_checked_out(dest)?;
107 Ok(())
108}
109
110fn ensure_checked_out(dest: &Path) -> Result<()> {
114 let has_head = Command::new("git")
115 .arg("-C")
116 .arg(dest)
117 .args(["rev-parse", "--verify", "-q", "HEAD"])
118 .output()
119 .map(|o| o.status.success())
120 .unwrap_or(false);
121 if has_head {
122 return Ok(());
123 }
124
125 let listed = run(
127 Command::new("git").arg("-C").arg(dest).args([
128 "for-each-ref",
129 "--format=%(refname:short)",
130 "refs/remotes/origin",
131 ]),
132 "git for-each-ref",
133 )?;
134 let listed = String::from_utf8_lossy(&listed);
135 let branches: Vec<&str> = listed
136 .lines()
137 .map(str::trim)
138 .filter(|b| !b.is_empty() && *b != "origin/HEAD")
139 .collect();
140 let pick = branches
141 .iter()
142 .find(|b| **b == "origin/main" || **b == "origin/master")
143 .or_else(|| branches.first())
144 .ok_or_else(|| anyhow!("restored bundle has no branch to check out"))?;
145 let local = pick.strip_prefix("origin/").unwrap_or(pick);
146 run(
147 Command::new("git")
148 .arg("-C")
149 .arg(dest)
150 .args(["checkout", "-b", local, pick]),
151 "git checkout",
152 )?;
153 Ok(())
154}
155
156#[cfg(test)]
157mod tests {
158 use super::{bundle_all, bundle_refs, clone_bundle, init_bare, ref_exists};
159 use std::process::Command;
160
161 fn commit_a_file(work: &std::path::Path) {
162 let sh = |args: &[&str]| {
163 let ok = Command::new("git")
164 .args(args)
165 .current_dir(work)
166 .status()
167 .unwrap()
168 .success();
169 assert!(ok, "git {:?} failed", args);
170 };
171 sh(&["init", "-q"]);
172 sh(&["config", "user.email", "t@t"]);
173 sh(&["config", "user.name", "t"]);
174 std::fs::write(work.join("hello.txt"), "hi").unwrap();
175 sh(&["add", "."]);
176 sh(&["commit", "-qm", "first"]);
177 }
178
179 #[test]
180 fn bundle_then_clone_preserves_content() {
181 let src = tempfile::tempdir().unwrap();
182 commit_a_file(src.path());
183
184 let bytes = bundle_all(src.path()).unwrap();
185 assert!(bytes.starts_with(b"# v2 git bundle") || bytes.starts_with(b"# v3 git bundle"));
186
187 let bdir = tempfile::tempdir().unwrap();
188 let bpath = bdir.path().join("r.bundle");
189 std::fs::write(&bpath, &bytes).unwrap();
190
191 let out = tempfile::tempdir().unwrap();
192 let dest = out.path().join("clone");
193 clone_bundle(&bpath, &dest).unwrap();
194 assert_eq!(
195 std::fs::read_to_string(dest.join("hello.txt")).unwrap(),
196 "hi"
197 );
198 }
199
200 #[test]
201 fn init_bare_creates_repo() {
202 let d = tempfile::tempdir().unwrap();
203 let repo = d.path().join("x.git");
204 init_bare(&repo).unwrap();
205 assert!(repo.join("HEAD").exists());
206 }
207
208 fn repo_with_two_branches(work: &std::path::Path) {
210 let sh = |args: &[&str]| {
211 assert!(
212 Command::new("git")
213 .args(args)
214 .current_dir(work)
215 .status()
216 .unwrap()
217 .success(),
218 "git {args:?}"
219 );
220 };
221 sh(&["init", "-q", "-b", "main"]);
222 sh(&["config", "user.email", "t@t"]);
223 sh(&["config", "user.name", "t"]);
224 std::fs::write(work.join("on_main.txt"), "m").unwrap();
225 sh(&["add", "."]);
226 sh(&["commit", "-qm", "main"]);
227 sh(&["checkout", "-q", "-b", "feature"]);
228 std::fs::write(work.join("on_feature.txt"), "f").unwrap();
229 sh(&["add", "."]);
230 sh(&["commit", "-qm", "feat"]);
231 sh(&["checkout", "-q", "main"]);
232 }
233
234 #[test]
235 fn bundle_refs_excludes_unselected_branch_and_still_restores() {
236 let src = tempfile::tempdir().unwrap();
239 repo_with_two_branches(src.path());
240
241 let bytes = bundle_refs(src.path(), &["refs/heads/main".to_string()]).unwrap();
242 assert!(bytes.starts_with(b"# v2 git bundle") || bytes.starts_with(b"# v3 git bundle"));
243
244 let bdir = tempfile::tempdir().unwrap();
245 let bpath = bdir.path().join("main.bundle");
246 std::fs::write(&bpath, &bytes).unwrap();
247 let dest = bdir.path().join("clone");
248 clone_bundle(&bpath, &dest).unwrap();
249
250 assert_eq!(
252 std::fs::read_to_string(dest.join("on_main.txt")).unwrap(),
253 "m"
254 );
255 assert!(
256 !dest.join("on_feature.txt").exists(),
257 "feature branch leaked into the bundle"
258 );
259 }
260
261 #[test]
262 fn bundle_of_non_head_branch_still_restores_content() {
263 let src = tempfile::tempdir().unwrap();
267 repo_with_two_branches(src.path()); let bytes = bundle_refs(src.path(), &["refs/heads/feature".to_string()]).unwrap();
270 let bdir = tempfile::tempdir().unwrap();
271 let bpath = bdir.path().join("feature.bundle");
272 std::fs::write(&bpath, &bytes).unwrap();
273 let dest = bdir.path().join("clone");
274 clone_bundle(&bpath, &dest).unwrap();
275
276 assert_eq!(
278 std::fs::read_to_string(dest.join("on_feature.txt")).unwrap(),
279 "f"
280 );
281 }
282
283 #[test]
284 fn ref_exists_reports_presence() {
285 let src = tempfile::tempdir().unwrap();
286 repo_with_two_branches(src.path());
287 assert!(ref_exists(src.path(), "refs/heads/main"));
288 assert!(ref_exists(src.path(), "refs/heads/feature"));
289 assert!(!ref_exists(src.path(), "refs/heads/ghost"));
290 let empty = tempfile::tempdir().unwrap();
292 assert!(!ref_exists(empty.path(), "refs/heads/main"));
293 }
294}