Skip to main content

git_ark/
git.rs

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
40/// Bundle exactly the named refs (`git bundle create - <ref1> <ref2> …`). A
41/// bundle of specific refs is a valid bundle: `git clone` of it yields those
42/// branches and nothing else, so a non-selected branch is genuinely not in it.
43///
44/// A bundle of specific refs carries no `HEAD` line, and `git clone` of a
45/// HEAD-less bundle checks out nothing — newer git leaves an empty working
46/// tree. So include `HEAD` when it points at one of the selected refs: the
47/// clone then checks out that (primary) branch, and because HEAD already
48/// resolves to a selected ref, no unselected branch's objects are pulled in.
49/// (When HEAD is detached or points at an unselected branch, HEAD is omitted
50/// and [`clone_bundle`]'s checkout fallback recovers content on restore.)
51pub 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
66/// Is the repo's `HEAD` a symbolic ref to one of `refs`? False when HEAD is
67/// detached, points elsewhere, or on any error. Used to decide whether HEAD can
68/// join a selected-ref bundle without dragging in an unselected branch.
69fn 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
85/// Does `refname` resolve to an existing object in `repo`? Uses
86/// `git rev-parse --verify --quiet`, which exits non-zero (quietly) when the
87/// ref doesn't exist. Any spawn failure is treated as "doesn't exist".
88pub 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    // A selected-ref bundle may carry no HEAD line; then `git clone` fetches the
104    // branches but checks nothing out (empty working tree on newer git). Ensure a
105    // branch is checked out so a restore is never a silently-empty directory.
106    ensure_checked_out(dest)?;
107    Ok(())
108}
109
110/// If the fresh clone at `dest` has no checked-out commit (HEAD-less bundle),
111/// check out a branch — preferring `main`/`master`, else the first fetched —
112/// so restore always yields real content instead of an empty working tree.
113fn 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    // No checkout happened. The bundle's refs landed under refs/remotes/origin/*.
126    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    /// A repo with a `main` and a `feature` branch, each holding a distinct file.
209    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        // Bundle ONLY refs/heads/main; the feature branch must not be reachable
237        // from the bundle, and a clone of it must check out main's content.
238        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        // main's file restored; the non-selected feature branch's file absent.
251        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        // HEAD is on main, but we bundle ONLY feature — so the bundle has no
264        // usable HEAD line. Restore must still check the branch out (via the
265        // clone-side fallback), never leave an empty working tree.
266        let src = tempfile::tempdir().unwrap();
267        repo_with_two_branches(src.path()); // leaves HEAD on main
268
269        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        // feature's own file is present — i.e. something was actually checked out.
277        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        // A non-repo path never errors — just reports absence.
291        let empty = tempfile::tempdir().unwrap();
292        assert!(!ref_exists(empty.path(), "refs/heads/main"));
293    }
294}