Skip to main content

rns_git/
git.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use crate::protocol::RefUpdate;
7use crate::util::validate_repo_name;
8use crate::{Error, Result};
9
10pub fn check_git_available() -> Result<()> {
11    run(Command::new("git").arg("--version")).map(|_| ())
12}
13
14pub fn repository_path(root: &Path, repository: &str) -> Result<PathBuf> {
15    validate_repo_name(repository)?;
16    Ok(root.join(repository))
17}
18
19pub fn validate_refname(refname: &str) -> Result<()> {
20    if is_valid_refname(refname) {
21        Ok(())
22    } else {
23        Err(Error::msg("invalid ref"))
24    }
25}
26
27pub fn validate_namespaced_ref(refname: &str) -> Result<()> {
28    validate_refname(refname)?;
29    if refname.starts_with("refs/") {
30        Ok(())
31    } else {
32        Err(Error::msg("invalid ref"))
33    }
34}
35
36pub fn validate_sha(sha: &str) -> Result<()> {
37    if is_valid_sha(sha) {
38        Ok(())
39    } else {
40        Err(Error::msg("invalid SHA"))
41    }
42}
43
44pub fn validate_shas<'a>(shas: impl IntoIterator<Item = &'a String>) -> Result<()> {
45    for sha in shas {
46        validate_sha(sha)?;
47    }
48    Ok(())
49}
50
51pub fn validate_ref_updates(updates: &[RefUpdate]) -> Result<()> {
52    for update in updates {
53        validate_namespaced_ref(&update.refname)?;
54        if let Some(old) = update.old.as_deref() {
55            validate_sha(old)?;
56        }
57        if let Some(new) = update.new.as_deref() {
58            validate_sha(new)?;
59        }
60    }
61    Ok(())
62}
63
64pub fn ensure_bare_repository(path: &Path) -> Result<()> {
65    if is_bare_repository(path) {
66        return Ok(());
67    }
68    if let Some(parent) = path.parent() {
69        fs::create_dir_all(parent)?;
70    }
71    run(Command::new("git").arg("init").arg("--bare").arg(path)).map(|_| ())
72}
73
74pub fn clone_remote_bare(source: &str, path: &Path, repository_type: &str) -> Result<()> {
75    if source.trim().is_empty() {
76        return Err(Error::msg("missing source"));
77    }
78    if path.exists() {
79        return Err(Error::msg("repository already exists"));
80    }
81    let parent = path
82        .parent()
83        .ok_or_else(|| Error::msg("repository path has no parent"))?;
84    fs::create_dir_all(parent)?;
85
86    let name = path
87        .file_name()
88        .and_then(|name| name.to_str())
89        .unwrap_or("repository");
90    let suffix = SystemTime::now()
91        .duration_since(UNIX_EPOCH)
92        .unwrap_or_default()
93        .as_nanos();
94    let temp = parent.join(format!(".{name}.clone-{suffix}"));
95
96    let result = (|| {
97        fs::create_dir_all(&temp)?;
98        run(Command::new("git").arg("init").arg("--bare").arg(&temp))?;
99        run(Command::new("git")
100            .arg("--git-dir")
101            .arg(&temp)
102            .arg("fetch")
103            .arg(source)
104            .arg("+refs/*:refs/*"))?;
105        update_head_to_source_default(&temp, source)?;
106        run(Command::new("git")
107            .arg("--git-dir")
108            .arg(&temp)
109            .arg("config")
110            .arg("repository.rngit.type")
111            .arg(repository_type))?;
112        run(Command::new("git")
113            .arg("--git-dir")
114            .arg(&temp)
115            .arg("config")
116            .arg("repository.rngit.upstream.source")
117            .arg(source))?;
118        if matches!(repository_type, "fork" | "mirror") {
119            run(Command::new("git")
120                .arg("--git-dir")
121                .arg(&temp)
122                .arg("config")
123                .arg("repository.rngit.upstream.sync")
124                .arg(unix_timestamp().to_string()))?;
125        }
126        fs::rename(&temp, path)?;
127        Ok(())
128    })();
129
130    if result.is_err() {
131        let _ = fs::remove_dir_all(&temp);
132    }
133    result
134}
135
136pub fn update_head_to_source_default(repo_path: &Path, source: &str) -> Result<()> {
137    require_repository(repo_path)?;
138    let mut target_branch = source_default_head(source)?;
139    if let Some(branch) = target_branch.as_deref() {
140        let check = Command::new("git")
141            .arg("--git-dir")
142            .arg(repo_path)
143            .arg("show-ref")
144            .arg("--verify")
145            .arg("--quiet")
146            .arg(branch)
147            .status()?;
148        if !check.success() {
149            target_branch = None;
150        }
151    }
152    let target_branch = match target_branch {
153        Some(branch) => branch,
154        None => first_local_branch(repo_path)?
155            .ok_or_else(|| Error::msg("repository has no local branch for HEAD"))?,
156    };
157    run(Command::new("git")
158        .arg("--git-dir")
159        .arg(repo_path)
160        .arg("symbolic-ref")
161        .arg("HEAD")
162        .arg(target_branch))?;
163    Ok(())
164}
165
166fn source_default_head(source: &str) -> Result<Option<String>> {
167    let output = Command::new("git")
168        .arg("ls-remote")
169        .arg("--symref")
170        .arg(source)
171        .arg("HEAD")
172        .output()?;
173    if !output.status.success() {
174        return Ok(None);
175    }
176    let stdout = String::from_utf8_lossy(&output.stdout);
177    for line in stdout.lines() {
178        let Some(rest) = line.strip_prefix("ref: ") else {
179            continue;
180        };
181        let Some((target, name)) = rest.split_once('\t') else {
182            continue;
183        };
184        if name == "HEAD" && target.starts_with("refs/heads/") {
185            return Ok(Some(target.to_string()));
186        }
187    }
188    Ok(None)
189}
190
191fn first_local_branch(repo_path: &Path) -> Result<Option<String>> {
192    let output = run(Command::new("git")
193        .arg("--git-dir")
194        .arg(repo_path)
195        .arg("for-each-ref")
196        .arg("--format=%(refname)")
197        .arg("--sort=refname")
198        .arg("refs/heads"))?;
199    Ok(output
200        .lines()
201        .find(|line| line.starts_with("refs/heads/"))
202        .map(ToOwned::to_owned))
203}
204
205pub fn repository_config(path: &Path, key: &str) -> Result<Option<String>> {
206    require_repository(path)?;
207    let output = Command::new("git")
208        .arg("--git-dir")
209        .arg(path)
210        .arg("config")
211        .arg("--get")
212        .arg(key)
213        .output()?;
214    if !output.status.success() {
215        return Ok(None);
216    }
217    Ok(Some(
218        String::from_utf8_lossy(&output.stdout).trim().to_string(),
219    ))
220}
221
222pub fn sync_upstream(path: &Path) -> Result<String> {
223    require_repository(path)?;
224    let repository_type = repository_config(path, "repository.rngit.type")?
225        .ok_or_else(|| Error::msg("repository is neither fork nor mirror"))?;
226    if !matches!(repository_type.as_str(), "fork" | "mirror") {
227        return Err(Error::msg("repository is neither fork nor mirror"));
228    }
229    let source = repository_config(path, "repository.rngit.upstream.source")?
230        .filter(|source| !source.is_empty())
231        .ok_or_else(|| Error::msg("missing upstream source"))?;
232    run(Command::new("git")
233        .arg("--git-dir")
234        .arg(path)
235        .arg("fetch")
236        .arg(&source)
237        .arg("+refs/*:refs/*"))?;
238    if repository_type == "mirror" {
239        update_head_to_source_default(path, &source)?;
240    }
241    if matches!(repository_type.as_str(), "fork" | "mirror") {
242        run(Command::new("git")
243            .arg("--git-dir")
244            .arg(path)
245            .arg("config")
246            .arg("repository.rngit.upstream.sync")
247            .arg(unix_timestamp().to_string()))?;
248    }
249    Ok(repository_type)
250}
251
252pub fn is_bare_repository(path: &Path) -> bool {
253    path.join("HEAD").exists() && path.join("objects").is_dir()
254}
255
256pub fn list_refs(path: &Path) -> Result<Vec<(String, String)>> {
257    require_repository(path)?;
258    let output = run(Command::new("git")
259        .arg("--git-dir")
260        .arg(path)
261        .arg("for-each-ref")
262        .arg("--format=%(objectname) %(refname)"))?;
263    Ok(output
264        .lines()
265        .filter_map(|line| {
266            let (sha, name) = line.split_once(' ')?;
267            Some((sha.to_string(), name.to_string()))
268        })
269        .collect())
270}
271
272pub fn list_refs_text(path: &Path) -> Result<Vec<u8>> {
273    let mut out = Vec::new();
274    for (sha, name) in list_refs(path)? {
275        out.extend_from_slice(sha.as_bytes());
276        out.push(b' ');
277        out.extend_from_slice(name.as_bytes());
278        out.push(b'\n');
279    }
280    Ok(out)
281}
282
283pub fn create_bundle(path: &Path, have: &[String]) -> Result<Vec<u8>> {
284    create_bundle_for_refs(path, have, &[])
285}
286
287pub fn create_bundle_for_refs(path: &Path, have: &[String], refs: &[String]) -> Result<Vec<u8>> {
288    require_repository(path)?;
289    validate_shas(have)?;
290    for refname in refs {
291        validate_namespaced_ref(refname)?;
292    }
293    if list_refs(path)?.is_empty() {
294        return Ok(Vec::new());
295    }
296    let bundle_path = temp_path("rngit-fetch", "bundle");
297    let exclusions = valid_repo_objects(path, have);
298    let mut cmd = Command::new("git");
299    cmd.arg("--git-dir")
300        .arg(path)
301        .arg("bundle")
302        .arg("create")
303        .arg(&bundle_path);
304    if refs.is_empty() {
305        cmd.arg("--all");
306    } else {
307        cmd.args(refs);
308    }
309    add_exclusions(&mut cmd, &exclusions);
310    let result = run(&mut cmd);
311    let bytes = match result {
312        Ok(_) => fs::read(&bundle_path)?,
313        Err(err) if is_empty_bundle_error(&err) => Vec::new(),
314        Err(err) => {
315            let _ = fs::remove_file(&bundle_path);
316            return Err(err);
317        }
318    };
319    let _ = fs::remove_file(&bundle_path);
320    Ok(bytes)
321}
322
323pub fn apply_push(path: &Path, bundle: &[u8], updates: &[RefUpdate]) -> Result<()> {
324    validate_ref_updates(updates)?;
325    ensure_bare_repository(path)?;
326    if !bundle.is_empty() {
327        let bundle_path = temp_path("rngit-push", "bundle");
328        fs::write(&bundle_path, bundle)?;
329        let result = run(Command::new("git")
330            .arg("--git-dir")
331            .arg(path)
332            .arg("fetch")
333            .arg(&bundle_path)
334            .arg("+refs/heads/*:refs/heads/*")
335            .arg("+refs/tags/*:refs/tags/*"));
336        let _ = fs::remove_file(&bundle_path);
337        result?;
338    }
339
340    for update in updates {
341        if let Some(new) = update.new.as_deref() {
342            update_ref(
343                path,
344                &update.refname,
345                new,
346                update.old.as_deref(),
347                update.force,
348            )?;
349        } else {
350            delete_ref(path, &update.refname, update.old.as_deref(), update.force)?;
351        }
352    }
353    Ok(())
354}
355
356pub fn local_ref_sha(refname: &str) -> Result<Option<String>> {
357    let output = Command::new("git")
358        .arg("rev-parse")
359        .arg("--verify")
360        .arg(refname)
361        .output()?;
362    if !output.status.success() {
363        return Ok(None);
364    }
365    Ok(Some(
366        String::from_utf8_lossy(&output.stdout).trim().to_string(),
367    ))
368}
369
370pub fn create_local_bundle(refs: &[String], exclusions: &[String]) -> Result<Vec<u8>> {
371    create_local_bundle_in(Path::new("."), refs, exclusions)
372}
373
374fn create_local_bundle_in(repo: &Path, refs: &[String], exclusions: &[String]) -> Result<Vec<u8>> {
375    if refs.is_empty() {
376        return Ok(Vec::new());
377    }
378    for refname in refs {
379        validate_refname(refname)?;
380    }
381    validate_shas(exclusions)?;
382    let bundle_path = temp_path("rngit-local-push", "bundle");
383    let valid_exclusions = valid_local_objects_in(repo, exclusions);
384    let mut cmd = Command::new("git");
385    cmd.arg("-C")
386        .arg(repo)
387        .arg("bundle")
388        .arg("create")
389        .arg(&bundle_path)
390        .args(refs);
391    add_exclusions(&mut cmd, &valid_exclusions);
392    let result = run(&mut cmd);
393    let bytes = match result {
394        Ok(_) => fs::read(&bundle_path)?,
395        Err(err) if is_empty_bundle_error(&err) => Vec::new(),
396        Err(err) => {
397            let _ = fs::remove_file(&bundle_path);
398            return Err(err);
399        }
400    };
401    let _ = fs::remove_file(&bundle_path);
402    Ok(bytes)
403}
404
405pub fn object_exists_local(sha: &str) -> bool {
406    object_exists_local_in(Path::new("."), sha)
407}
408
409fn object_exists_local_in(repo: &Path, sha: &str) -> bool {
410    Command::new("git")
411        .arg("-C")
412        .arg(repo)
413        .arg("cat-file")
414        .arg("-e")
415        .arg(format!("{sha}^{{object}}"))
416        .output()
417        .map(|output| output.status.success())
418        .unwrap_or(false)
419}
420
421pub fn fetch_bundle_into_local(bundle: &[u8], wanted: &[String]) -> Result<()> {
422    if bundle.is_empty() {
423        return Ok(());
424    }
425    for refname in wanted {
426        validate_refname(refname)?;
427    }
428    let bundle_path = temp_path("rngit-local-fetch", "bundle");
429    fs::write(&bundle_path, bundle)?;
430    let mut cmd = Command::new("git");
431    cmd.arg("fetch").arg(&bundle_path);
432    if wanted.is_empty() {
433        cmd.arg("refs/*:refs/*");
434    } else {
435        cmd.args(wanted);
436    }
437    let result = run(&mut cmd);
438    let _ = fs::remove_file(&bundle_path);
439    result.map(|_| ())
440}
441
442fn update_ref(path: &Path, refname: &str, new: &str, old: Option<&str>, force: bool) -> Result<()> {
443    let mut cmd = Command::new("git");
444    cmd.arg("--git-dir")
445        .arg(path)
446        .arg("update-ref")
447        .arg(refname)
448        .arg(new);
449    if !force {
450        if let Some(old) = old {
451            cmd.arg(old);
452        }
453    }
454    run(&mut cmd).map(|_| ())
455}
456
457fn delete_ref(path: &Path, refname: &str, old: Option<&str>, force: bool) -> Result<()> {
458    let mut cmd = Command::new("git");
459    cmd.arg("--git-dir")
460        .arg(path)
461        .arg("update-ref")
462        .arg("-d")
463        .arg(refname);
464    if !force {
465        if let Some(old) = old {
466            cmd.arg(old);
467        }
468    }
469    run(&mut cmd).map(|_| ())
470}
471
472fn require_repository(path: &Path) -> Result<()> {
473    if is_bare_repository(path) {
474        Ok(())
475    } else {
476        Err(Error::msg("repository not found"))
477    }
478}
479
480fn valid_repo_objects(path: &Path, shas: &[String]) -> Vec<String> {
481    shas.iter()
482        .filter(|sha| object_exists_in_repo(path, sha))
483        .cloned()
484        .collect()
485}
486
487fn valid_local_objects_in(repo: &Path, shas: &[String]) -> Vec<String> {
488    shas.iter()
489        .filter(|sha| object_exists_local_in(repo, sha))
490        .cloned()
491        .collect()
492}
493
494fn object_exists_in_repo(path: &Path, sha: &str) -> bool {
495    Command::new("git")
496        .arg("--git-dir")
497        .arg(path)
498        .arg("cat-file")
499        .arg("-e")
500        .arg(format!("{sha}^{{object}}"))
501        .output()
502        .map(|output| output.status.success())
503        .unwrap_or(false)
504}
505
506fn is_valid_refname(refname: &str) -> bool {
507    if refname.is_empty()
508        || refname.starts_with('-')
509        || refname.starts_with('/')
510        || refname.ends_with('/')
511        || refname.ends_with('.')
512        || !refname.contains('/')
513        || refname.contains(' ')
514        || refname.contains("..")
515        || refname.contains("/.")
516        || refname.contains("//")
517        || refname.contains('\\')
518        || refname.contains('~')
519        || refname.contains('^')
520        || refname.contains(':')
521        || refname.contains('?')
522        || refname.contains('*')
523        || refname.contains('[')
524        || refname.contains("@{")
525        || refname == "@"
526    {
527        return false;
528    }
529
530    if refname
531        .split('/')
532        .any(|component| component.is_empty() || component.ends_with(".lock"))
533    {
534        return false;
535    }
536
537    refname.chars().all(|c| (c as u32) >= 40 && c != '\u{7f}')
538}
539
540fn is_valid_sha(sha: &str) -> bool {
541    sha.len() >= 40 && sha.len() % 2 == 0 && sha.bytes().all(|b| b.is_ascii_hexdigit())
542}
543
544fn add_exclusions(cmd: &mut Command, exclusions: &[String]) {
545    for sha in exclusions {
546        cmd.arg(format!("^{sha}"));
547    }
548}
549
550fn is_empty_bundle_error(err: &Error) -> bool {
551    let message = err.to_string();
552    message.contains("Refusing to create empty bundle")
553        || message.contains("does not have any commits")
554        || message.contains("Refusing to create empty")
555}
556
557fn temp_path(prefix: &str, extension: &str) -> PathBuf {
558    let now = SystemTime::now()
559        .duration_since(UNIX_EPOCH)
560        .unwrap_or_default()
561        .as_nanos();
562    std::env::temp_dir().join(format!("{prefix}-{}-{now}.{extension}", std::process::id()))
563}
564
565fn unix_timestamp() -> u64 {
566    SystemTime::now()
567        .duration_since(UNIX_EPOCH)
568        .unwrap_or_default()
569        .as_secs()
570}
571
572fn run(cmd: &mut Command) -> Result<String> {
573    let output = cmd.output()?;
574    if output.status.success() {
575        return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
576    }
577    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
578    Err(Error::msg(if stderr.is_empty() {
579        "git command failed".to_string()
580    } else {
581        stderr
582    }))
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    #[test]
590    fn bare_repo_can_list_refs() {
591        let tmp = tempfile::tempdir().unwrap();
592        let repo = tmp.path().join("repo.git");
593        ensure_bare_repository(&repo).unwrap();
594        assert!(list_refs(&repo).unwrap().is_empty());
595    }
596
597    #[test]
598    fn repository_paths_reject_traversal() {
599        assert!(repository_path(Path::new("/tmp/repos"), "../x").is_err());
600        assert!(repository_path(Path::new("/tmp/repos"), "group/repo").is_ok());
601    }
602
603    #[test]
604    fn ref_validation_rejects_noncanonical_names() {
605        assert!(validate_refname("refs/heads/main").is_ok());
606        assert!(validate_refname("refs/tags/v1").is_ok());
607
608        for name in [
609            "-refs/heads/main",
610            "/refs/heads/main",
611            "refs/heads/main/",
612            "refs/heads/main.",
613            "refs/heads/feature lock",
614            "main",
615            "refs/heads/../main",
616            "refs/heads/.hidden",
617            "refs/heads//main",
618            "refs\\heads\\main",
619            "refs/heads/main.lock",
620            "refs/heads/main~",
621            "refs/heads/main^",
622            "refs/heads/main:evil",
623            "refs/heads/main?",
624            "refs/heads/main*",
625            "refs/heads/main[",
626            "refs/heads/@{upstream}",
627            "@",
628            "refs/heads/\nmain",
629        ] {
630            assert!(
631                validate_refname(name).is_err(),
632                "{name:?} should be rejected"
633            );
634        }
635    }
636
637    #[test]
638    fn sha_validation_rejects_non_hex_or_short_values() {
639        assert!(validate_sha("0123456789abcdef0123456789abcdef01234567").is_ok());
640        assert!(
641            validate_sha("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
642                .is_ok()
643        );
644
645        for sha in [
646            "",
647            "0123456789abcdef0123456789abcdef0123456",
648            "0123456789abcdef0123456789abcdef0123456g",
649            "--upload-pack=/tmp/x",
650            "0123456789abcdef0123456789abcdef012345678",
651        ] {
652            assert!(validate_sha(sha).is_err(), "{sha:?} should be rejected");
653        }
654    }
655
656    #[test]
657    fn bundle_push_roundtrip_updates_bare_repository() {
658        let tmp = tempfile::tempdir().unwrap();
659        let work = tmp.path().join("work");
660        let target = tmp.path().join("target.git");
661        fs::create_dir_all(&work).unwrap();
662
663        test_git(Command::new("git").arg("init").arg(&work));
664        fs::write(work.join("README.md"), "hello\n").unwrap();
665        test_git(
666            Command::new("git")
667                .arg("-C")
668                .arg(&work)
669                .arg("add")
670                .arg("README.md"),
671        );
672        test_git(
673            Command::new("git")
674                .arg("-C")
675                .arg(&work)
676                .arg("-c")
677                .arg("user.name=RNS Test")
678                .arg("-c")
679                .arg("user.email=rns@example.invalid")
680                .arg("commit")
681                .arg("-m")
682                .arg("init"),
683        );
684        test_git(
685            Command::new("git")
686                .arg("-C")
687                .arg(&work)
688                .arg("branch")
689                .arg("-M")
690                .arg("main"),
691        );
692        let sha = test_git(
693            Command::new("git")
694                .arg("-C")
695                .arg(&work)
696                .arg("rev-parse")
697                .arg("refs/heads/main"),
698        );
699        let sha = sha.trim().to_string();
700        let bundle_path = tmp.path().join("push.bundle");
701        test_git(
702            Command::new("git")
703                .arg("-C")
704                .arg(&work)
705                .arg("bundle")
706                .arg("create")
707                .arg(&bundle_path)
708                .arg("refs/heads/main"),
709        );
710
711        apply_push(
712            &target,
713            &fs::read(&bundle_path).unwrap(),
714            &[RefUpdate {
715                refname: "refs/heads/main".into(),
716                old: None,
717                new: Some(sha.clone()),
718                force: true,
719            }],
720        )
721        .unwrap();
722
723        assert_eq!(
724            list_refs(&target).unwrap(),
725            vec![(sha.clone(), "refs/heads/main".into())]
726        );
727        assert!(!create_bundle(&target, &[]).unwrap().is_empty());
728        assert!(create_bundle(&target, &[sha]).unwrap().is_empty());
729    }
730
731    #[test]
732    fn fetch_bundle_can_scope_to_requested_refs() {
733        let tmp = tempfile::tempdir().unwrap();
734        let work = tmp.path().join("work");
735        let target = tmp.path().join("target.git");
736        fs::create_dir_all(&work).unwrap();
737
738        test_git(Command::new("git").arg("init").arg(&work));
739        fs::write(work.join("README.md"), "main\n").unwrap();
740        test_git(
741            Command::new("git")
742                .arg("-C")
743                .arg(&work)
744                .arg("add")
745                .arg("README.md"),
746        );
747        test_git(
748            Command::new("git")
749                .arg("-C")
750                .arg(&work)
751                .arg("-c")
752                .arg("user.name=RNS Test")
753                .arg("-c")
754                .arg("user.email=rns@example.invalid")
755                .arg("commit")
756                .arg("-m")
757                .arg("main"),
758        );
759        test_git(
760            Command::new("git")
761                .arg("-C")
762                .arg(&work)
763                .arg("branch")
764                .arg("-M")
765                .arg("main"),
766        );
767        test_git(
768            Command::new("git")
769                .arg("-C")
770                .arg(&work)
771                .arg("checkout")
772                .arg("-b")
773                .arg("side"),
774        );
775        fs::write(work.join("SIDE.md"), "side\n").unwrap();
776        test_git(
777            Command::new("git")
778                .arg("-C")
779                .arg(&work)
780                .arg("add")
781                .arg("SIDE.md"),
782        );
783        test_git(
784            Command::new("git")
785                .arg("-C")
786                .arg(&work)
787                .arg("-c")
788                .arg("user.name=RNS Test")
789                .arg("-c")
790                .arg("user.email=rns@example.invalid")
791                .arg("commit")
792                .arg("-m")
793                .arg("side"),
794        );
795        ensure_bare_repository(&target).unwrap();
796        test_git(
797            Command::new("git")
798                .arg("--git-dir")
799                .arg(&target)
800                .arg("fetch")
801                .arg(&work)
802                .arg("+refs/heads/*:refs/heads/*"),
803        );
804
805        let bundle = create_bundle_for_refs(&target, &[], &["refs/heads/main".into()]).unwrap();
806        let bundle_path = tmp.path().join("fetch.bundle");
807        fs::write(&bundle_path, bundle).unwrap();
808        let heads = test_git(
809            Command::new("git")
810                .arg("bundle")
811                .arg("list-heads")
812                .arg(&bundle_path),
813        );
814
815        assert!(heads.contains("refs/heads/main"));
816        assert!(!heads.contains("refs/heads/side"));
817    }
818
819    #[test]
820    fn local_push_bundle_excludes_known_remote_objects() {
821        let tmp = tempfile::tempdir().unwrap();
822        let work = tmp.path().join("work");
823        fs::create_dir_all(&work).unwrap();
824
825        test_git(Command::new("git").arg("init").arg(&work));
826        fs::write(work.join("README.md"), "hello\n").unwrap();
827        test_git(
828            Command::new("git")
829                .arg("-C")
830                .arg(&work)
831                .arg("add")
832                .arg("README.md"),
833        );
834        test_git(
835            Command::new("git")
836                .arg("-C")
837                .arg(&work)
838                .arg("-c")
839                .arg("user.name=RNS Test")
840                .arg("-c")
841                .arg("user.email=rns@example.invalid")
842                .arg("commit")
843                .arg("-m")
844                .arg("init"),
845        );
846        test_git(
847            Command::new("git")
848                .arg("-C")
849                .arg(&work)
850                .arg("branch")
851                .arg("-M")
852                .arg("main"),
853        );
854        let sha = test_git(
855            Command::new("git")
856                .arg("-C")
857                .arg(&work)
858                .arg("rev-parse")
859                .arg("refs/heads/main"),
860        )
861        .trim()
862        .to_string();
863        let full = create_local_bundle_in(&work, &["refs/heads/main".into()], &[]).unwrap();
864        let excluded = create_local_bundle_in(&work, &["refs/heads/main".into()], &[sha]).unwrap();
865        assert!(!full.is_empty());
866        assert!(excluded.is_empty());
867    }
868
869    fn test_git(cmd: &mut Command) -> String {
870        let output = cmd.output().unwrap();
871        assert!(
872            output.status.success(),
873            "git command failed: {}",
874            String::from_utf8_lossy(&output.stderr)
875        );
876        String::from_utf8_lossy(&output.stdout).into_owned()
877    }
878}