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}