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, validate_upstream_source};
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    // Validate the remote before passing it to git.
76    validate_upstream_source(source)?;
77    if path.exists() {
78        return Err(Error::msg("repository already exists"));
79    }
80    let parent = path
81        .parent()
82        .ok_or_else(|| Error::msg("repository path has no parent"))?;
83    fs::create_dir_all(parent)?;
84
85    let name = path
86        .file_name()
87        .and_then(|name| name.to_str())
88        .unwrap_or("repository");
89    let suffix = SystemTime::now()
90        .duration_since(UNIX_EPOCH)
91        .unwrap_or_default()
92        .as_nanos();
93    let temp = parent.join(format!(".{name}.clone-{suffix}"));
94
95    let result = (|| {
96        fs::create_dir_all(&temp)?;
97        run(Command::new("git").arg("init").arg("--bare").arg(&temp))?;
98        run(Command::new("git")
99            .arg("--git-dir")
100            .arg(&temp)
101            .arg("fetch")
102            .arg("--")
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("--")
117            .arg("repository.rngit.upstream.source")
118            .arg(source))?;
119        if matches!(repository_type, "fork" | "mirror") {
120            run(Command::new("git")
121                .arg("--git-dir")
122                .arg(&temp)
123                .arg("config")
124                .arg("repository.rngit.upstream.sync")
125                .arg(unix_timestamp().to_string()))?;
126        }
127        fs::rename(&temp, path)?;
128        Ok(())
129    })();
130
131    if result.is_err() {
132        let _ = fs::remove_dir_all(&temp);
133    }
134    result
135}
136
137pub fn update_head_to_source_default(repo_path: &Path, source: &str) -> Result<()> {
138    require_repository(repo_path)?;
139    let mut target_branch = source_default_head(source)?;
140    if let Some(branch) = target_branch.as_deref() {
141        let check = Command::new("git")
142            .arg("--git-dir")
143            .arg(repo_path)
144            .arg("show-ref")
145            .arg("--verify")
146            .arg("--quiet")
147            .arg("--")
148            .arg(branch)
149            .status()?;
150        if !check.success() {
151            target_branch = None;
152        }
153    }
154    let target_branch = match target_branch {
155        Some(branch) => branch,
156        None => first_local_branch(repo_path)?
157            .ok_or_else(|| Error::msg("repository has no local branch for HEAD"))?,
158    };
159    run(Command::new("git")
160        .arg("--git-dir")
161        .arg(repo_path)
162        .arg("symbolic-ref")
163        .arg("--")
164        .arg("HEAD")
165        .arg(target_branch))?;
166    Ok(())
167}
168
169fn source_default_head(source: &str) -> Result<Option<String>> {
170    // Validate the remote before passing it to git.
171    validate_upstream_source(source)?;
172    let output = Command::new("git")
173        .arg("ls-remote")
174        .arg("--symref")
175        .arg("--")
176        .arg(source)
177        .arg("HEAD")
178        .output()?;
179    if !output.status.success() {
180        return Ok(None);
181    }
182    let stdout = String::from_utf8_lossy(&output.stdout);
183    for line in stdout.lines() {
184        let Some(rest) = line.strip_prefix("ref: ") else {
185            continue;
186        };
187        let Some((target, name)) = rest.split_once('\t') else {
188            continue;
189        };
190        if name == "HEAD" && target.starts_with("refs/heads/") {
191            return Ok(Some(target.to_string()));
192        }
193    }
194    Ok(None)
195}
196
197fn first_local_branch(repo_path: &Path) -> Result<Option<String>> {
198    let output = run(Command::new("git")
199        .arg("--git-dir")
200        .arg(repo_path)
201        .arg("for-each-ref")
202        .arg("--format=%(refname)")
203        .arg("--sort=refname")
204        .arg("refs/heads"))?;
205    Ok(output
206        .lines()
207        .find(|line| line.starts_with("refs/heads/"))
208        .map(ToOwned::to_owned))
209}
210
211pub fn repository_config(path: &Path, key: &str) -> Result<Option<String>> {
212    require_repository(path)?;
213    let output = Command::new("git")
214        .arg("--git-dir")
215        .arg(path)
216        .arg("config")
217        .arg("--get")
218        .arg(key)
219        .output()?;
220    if !output.status.success() {
221        return Ok(None);
222    }
223    Ok(Some(
224        String::from_utf8_lossy(&output.stdout).trim().to_string(),
225    ))
226}
227
228pub fn sync_upstream(path: &Path) -> Result<String> {
229    require_repository(path)?;
230    let repository_type = repository_config(path, "repository.rngit.type")?
231        .ok_or_else(|| Error::msg("repository is neither fork nor mirror"))?;
232    if !matches!(repository_type.as_str(), "fork" | "mirror") {
233        return Err(Error::msg("repository is neither fork nor mirror"));
234    }
235    let source = repository_config(path, "repository.rngit.upstream.source")?
236        .filter(|source| !source.is_empty())
237        .ok_or_else(|| Error::msg("missing upstream source"))?;
238    // Validate the remote before passing it to git.
239    validate_upstream_source(&source)?;
240    run(Command::new("git")
241        .arg("--git-dir")
242        .arg(path)
243        .arg("fetch")
244        .arg("--")
245        .arg(&source)
246        .arg("+refs/*:refs/*"))?;
247    if repository_type == "mirror" {
248        update_head_to_source_default(path, &source)?;
249    }
250    if matches!(repository_type.as_str(), "fork" | "mirror") {
251        run(Command::new("git")
252            .arg("--git-dir")
253            .arg(path)
254            .arg("config")
255            .arg("repository.rngit.upstream.sync")
256            .arg(unix_timestamp().to_string()))?;
257    }
258    Ok(repository_type)
259}
260
261pub fn is_bare_repository(path: &Path) -> bool {
262    path.join("HEAD").exists() && path.join("objects").is_dir()
263}
264
265pub fn list_refs(path: &Path) -> Result<Vec<(String, String)>> {
266    require_repository(path)?;
267    let output = run(Command::new("git")
268        .arg("--git-dir")
269        .arg(path)
270        .arg("for-each-ref")
271        .arg("--format=%(objectname) %(refname)"))?;
272    Ok(output
273        .lines()
274        .filter_map(|line| {
275            let (sha, name) = line.split_once(' ')?;
276            Some((sha.to_string(), name.to_string()))
277        })
278        .collect())
279}
280
281pub fn list_refs_text(path: &Path) -> Result<Vec<u8>> {
282    let mut out = Vec::new();
283    for (sha, name) in list_refs(path)? {
284        out.extend_from_slice(sha.as_bytes());
285        out.push(b' ');
286        out.extend_from_slice(name.as_bytes());
287        out.push(b'\n');
288    }
289    Ok(out)
290}
291
292pub fn create_bundle(path: &Path, have: &[String]) -> Result<Vec<u8>> {
293    create_bundle_for_refs(path, have, &[])
294}
295
296pub fn create_bundle_for_refs(path: &Path, have: &[String], refs: &[String]) -> Result<Vec<u8>> {
297    require_repository(path)?;
298    validate_shas(have)?;
299    for refname in refs {
300        validate_namespaced_ref(refname)?;
301    }
302    if list_refs(path)?.is_empty() {
303        return Ok(Vec::new());
304    }
305    let bundle_path = temp_path("rngit-fetch", "bundle");
306    let exclusions = valid_repo_objects(path, have);
307    let mut cmd = Command::new("git");
308    cmd.arg("--git-dir")
309        .arg(path)
310        .arg("bundle")
311        .arg("create")
312        .arg(&bundle_path);
313    if refs.is_empty() {
314        cmd.arg("--all");
315    } else {
316        cmd.args(refs);
317    }
318    add_exclusions(&mut cmd, &exclusions);
319    let result = run(&mut cmd);
320    let bytes = match result {
321        Ok(_) => fs::read(&bundle_path)?,
322        Err(err) if is_empty_bundle_error(&err) => Vec::new(),
323        Err(err) => {
324            let _ = fs::remove_file(&bundle_path);
325            return Err(err);
326        }
327    };
328    let _ = fs::remove_file(&bundle_path);
329    Ok(bytes)
330}
331
332pub fn apply_push(path: &Path, bundle: &[u8], updates: &[RefUpdate]) -> Result<()> {
333    validate_ref_updates(updates)?;
334    ensure_bare_repository(path)?;
335    if !bundle.is_empty() {
336        let bundle_path = temp_path("rngit-push", "bundle");
337        fs::write(&bundle_path, bundle)?;
338        let result = run(Command::new("git")
339            .arg("--git-dir")
340            .arg(path)
341            .arg("fetch")
342            .arg("--")
343            .arg(&bundle_path)
344            .arg("+refs/heads/*:refs/heads/*")
345            .arg("+refs/tags/*:refs/tags/*"));
346        let _ = fs::remove_file(&bundle_path);
347        result?;
348    }
349
350    for update in updates {
351        if let Some(new) = update.new.as_deref() {
352            update_ref(
353                path,
354                &update.refname,
355                new,
356                update.old.as_deref(),
357                update.force,
358            )?;
359        } else {
360            delete_ref(path, &update.refname, update.old.as_deref(), update.force)?;
361        }
362    }
363    Ok(())
364}
365
366pub fn local_ref_sha(refname: &str) -> Result<Option<String>> {
367    // Remote-advertised ref names reach this call; a leading `-` is never a ref.
368    if refname.is_empty() || refname.starts_with('-') {
369        return Ok(None);
370    }
371    let output = Command::new("git")
372        .arg("rev-parse")
373        .arg("--verify")
374        .arg(refname)
375        .output()?;
376    if !output.status.success() {
377        return Ok(None);
378    }
379    Ok(Some(
380        String::from_utf8_lossy(&output.stdout).trim().to_string(),
381    ))
382}
383
384pub fn create_local_bundle(refs: &[String], exclusions: &[String]) -> Result<Vec<u8>> {
385    create_local_bundle_in(Path::new("."), refs, exclusions)
386}
387
388fn create_local_bundle_in(repo: &Path, refs: &[String], exclusions: &[String]) -> Result<Vec<u8>> {
389    if refs.is_empty() {
390        return Ok(Vec::new());
391    }
392    for refname in refs {
393        validate_refname(refname)?;
394    }
395    validate_shas(exclusions)?;
396    let bundle_path = temp_path("rngit-local-push", "bundle");
397    let valid_exclusions = valid_local_objects_in(repo, exclusions);
398    let mut cmd = Command::new("git");
399    cmd.arg("-C")
400        .arg(repo)
401        .arg("bundle")
402        .arg("create")
403        .arg(&bundle_path)
404        .args(refs);
405    add_exclusions(&mut cmd, &valid_exclusions);
406    let result = run(&mut cmd);
407    let bytes = match result {
408        Ok(_) => fs::read(&bundle_path)?,
409        Err(err) if is_empty_bundle_error(&err) => Vec::new(),
410        Err(err) => {
411            let _ = fs::remove_file(&bundle_path);
412            return Err(err);
413        }
414    };
415    let _ = fs::remove_file(&bundle_path);
416    Ok(bytes)
417}
418
419pub fn object_exists_local(sha: &str) -> bool {
420    object_exists_local_in(Path::new("."), sha)
421}
422
423fn object_exists_local_in(repo: &Path, sha: &str) -> bool {
424    if !is_valid_sha(sha) {
425        return false;
426    }
427    Command::new("git")
428        .arg("-C")
429        .arg(repo)
430        .arg("cat-file")
431        .arg("-e")
432        .arg(format!("{sha}^{{object}}"))
433        .output()
434        .map(|output| output.status.success())
435        .unwrap_or(false)
436}
437
438pub fn fetch_bundle_into_local(bundle: &[u8], wanted: &[String]) -> Result<()> {
439    if bundle.is_empty() {
440        return Ok(());
441    }
442    for refname in wanted {
443        validate_refname(refname)?;
444    }
445    let bundle_path = temp_path("rngit-local-fetch", "bundle");
446    fs::write(&bundle_path, bundle)?;
447    let mut cmd = Command::new("git");
448    cmd.arg("fetch").arg("--").arg(&bundle_path);
449    if wanted.is_empty() {
450        cmd.arg("refs/*:refs/*");
451    } else {
452        cmd.args(wanted);
453    }
454    let result = run(&mut cmd);
455    let _ = fs::remove_file(&bundle_path);
456    result.map(|_| ())
457}
458
459fn update_ref(path: &Path, refname: &str, new: &str, old: Option<&str>, force: bool) -> Result<()> {
460    let mut cmd = Command::new("git");
461    cmd.arg("--git-dir")
462        .arg(path)
463        .arg("update-ref")
464        .arg("--")
465        .arg(refname)
466        .arg(new);
467    if !force {
468        if let Some(old) = old {
469            cmd.arg(old);
470        }
471    }
472    run(&mut cmd).map(|_| ())
473}
474
475fn delete_ref(path: &Path, refname: &str, old: Option<&str>, force: bool) -> Result<()> {
476    let mut cmd = Command::new("git");
477    cmd.arg("--git-dir")
478        .arg(path)
479        .arg("update-ref")
480        .arg("-d")
481        .arg("--")
482        .arg(refname);
483    if !force {
484        if let Some(old) = old {
485            cmd.arg(old);
486        }
487    }
488    run(&mut cmd).map(|_| ())
489}
490
491fn require_repository(path: &Path) -> Result<()> {
492    if is_bare_repository(path) {
493        Ok(())
494    } else {
495        Err(Error::msg("repository not found"))
496    }
497}
498
499fn valid_repo_objects(path: &Path, shas: &[String]) -> Vec<String> {
500    shas.iter()
501        .filter(|sha| object_exists_in_repo(path, sha))
502        .cloned()
503        .collect()
504}
505
506fn valid_local_objects_in(repo: &Path, shas: &[String]) -> Vec<String> {
507    shas.iter()
508        .filter(|sha| object_exists_local_in(repo, sha))
509        .cloned()
510        .collect()
511}
512
513fn object_exists_in_repo(path: &Path, sha: &str) -> bool {
514    if !is_valid_sha(sha) {
515        return false;
516    }
517    Command::new("git")
518        .arg("--git-dir")
519        .arg(path)
520        .arg("cat-file")
521        .arg("-e")
522        .arg(format!("{sha}^{{object}}"))
523        .output()
524        .map(|output| output.status.success())
525        .unwrap_or(false)
526}
527
528fn is_valid_refname(refname: &str) -> bool {
529    if refname.is_empty()
530        || refname.starts_with('-')
531        || refname.starts_with('/')
532        || refname.ends_with('/')
533        || refname.ends_with('.')
534        || !refname.contains('/')
535        || refname.contains(' ')
536        || refname.contains("..")
537        || refname.contains("/.")
538        || refname.contains("//")
539        || refname.contains('\\')
540        || refname.contains('~')
541        || refname.contains('^')
542        || refname.contains(':')
543        || refname.contains('?')
544        || refname.contains('*')
545        || refname.contains('[')
546        || refname.contains("@{")
547        || refname == "@"
548    {
549        return false;
550    }
551
552    if refname
553        .split('/')
554        .any(|component| component.is_empty() || component.ends_with(".lock"))
555    {
556        return false;
557    }
558
559    refname.chars().all(|c| (c as u32) >= 40 && c != '\u{7f}')
560}
561
562fn is_valid_sha(sha: &str) -> bool {
563    sha.len() >= 40 && sha.len().is_multiple_of(2) && sha.bytes().all(|b| b.is_ascii_hexdigit())
564}
565
566fn add_exclusions(cmd: &mut Command, exclusions: &[String]) {
567    for sha in exclusions {
568        cmd.arg(format!("^{sha}"));
569    }
570}
571
572fn is_empty_bundle_error(err: &Error) -> bool {
573    let message = err.to_string();
574    message.contains("Refusing to create empty bundle")
575        || message.contains("does not have any commits")
576        || message.contains("Refusing to create empty")
577}
578
579fn temp_path(prefix: &str, extension: &str) -> PathBuf {
580    let now = SystemTime::now()
581        .duration_since(UNIX_EPOCH)
582        .unwrap_or_default()
583        .as_nanos();
584    std::env::temp_dir().join(format!("{prefix}-{}-{now}.{extension}", std::process::id()))
585}
586
587fn unix_timestamp() -> u64 {
588    SystemTime::now()
589        .duration_since(UNIX_EPOCH)
590        .unwrap_or_default()
591        .as_secs()
592}
593
594fn run(cmd: &mut Command) -> Result<String> {
595    let output = cmd.output()?;
596    if output.status.success() {
597        return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
598    }
599    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
600    Err(Error::msg(if stderr.is_empty() {
601        "git command failed".to_string()
602    } else {
603        stderr
604    }))
605}
606
607#[cfg(test)]
608pub(crate) mod test_support {
609    use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
610    use std::path::Path;
611    use std::process::{Child, Command, Stdio};
612    use std::time::{Duration, Instant};
613
614    /// A `git daemon` serving every repository under a directory over
615    /// `git://127.0.0.1:<port>/`, stopped when dropped.
616    pub(crate) struct GitDaemon {
617        child: Child,
618        port: u16,
619    }
620
621    impl GitDaemon {
622        pub(crate) fn serve(base: &Path) -> Self {
623            let mut last_error = String::new();
624            for _ in 0..5 {
625                let port = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))
626                    .and_then(|listener| listener.local_addr())
627                    .map(|addr| addr.port())
628                    .expect("reserve local port");
629                let mut child = Command::new("git")
630                    .arg("daemon")
631                    .arg("--reuseaddr")
632                    .arg("--export-all")
633                    .arg("--listen=127.0.0.1")
634                    .arg(format!("--port={port}"))
635                    .arg(format!("--base-path={}", base.display()))
636                    .arg(base)
637                    .stdin(Stdio::null())
638                    .stdout(Stdio::null())
639                    .stderr(Stdio::piped())
640                    .spawn()
641                    .expect("start git daemon");
642                let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port));
643                let deadline = Instant::now() + Duration::from_secs(10);
644                while Instant::now() < deadline {
645                    if let Ok(Some(status)) = child.try_wait() {
646                        last_error = format!("git daemon exited with {status}");
647                        break;
648                    }
649                    if TcpStream::connect_timeout(&addr, Duration::from_millis(100)).is_ok() {
650                        return Self { child, port };
651                    }
652                    std::thread::sleep(Duration::from_millis(20));
653                }
654                let _ = child.kill();
655                let _ = child.wait();
656            }
657            panic!("git daemon did not start: {last_error}");
658        }
659
660        pub(crate) fn url(&self, name: &str) -> String {
661            format!("git://127.0.0.1:{}/{name}", self.port)
662        }
663    }
664
665    impl Drop for GitDaemon {
666        fn drop(&mut self) {
667            let _ = self.child.kill();
668            let _ = self.child.wait();
669        }
670    }
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676
677    #[test]
678    fn bare_repo_can_list_refs() {
679        let tmp = tempfile::tempdir().unwrap();
680        let repo = tmp.path().join("repo.git");
681        ensure_bare_repository(&repo).unwrap();
682        assert!(list_refs(&repo).unwrap().is_empty());
683    }
684
685    #[test]
686    fn repository_paths_reject_traversal() {
687        assert!(repository_path(Path::new("/tmp/repos"), "../x").is_err());
688        assert!(repository_path(Path::new("/tmp/repos"), "group/repo").is_ok());
689    }
690
691    #[test]
692    fn ref_validation_rejects_noncanonical_names() {
693        assert!(validate_refname("refs/heads/main").is_ok());
694        assert!(validate_refname("refs/tags/v1").is_ok());
695
696        for name in [
697            "-refs/heads/main",
698            "/refs/heads/main",
699            "refs/heads/main/",
700            "refs/heads/main.",
701            "refs/heads/feature lock",
702            "main",
703            "refs/heads/../main",
704            "refs/heads/.hidden",
705            "refs/heads//main",
706            "refs\\heads\\main",
707            "refs/heads/main.lock",
708            "refs/heads/main~",
709            "refs/heads/main^",
710            "refs/heads/main:evil",
711            "refs/heads/main?",
712            "refs/heads/main*",
713            "refs/heads/main[",
714            "refs/heads/@{upstream}",
715            "@",
716            "refs/heads/\nmain",
717        ] {
718            assert!(
719                validate_refname(name).is_err(),
720                "{name:?} should be rejected"
721            );
722        }
723    }
724
725    #[test]
726    fn sha_validation_rejects_non_hex_or_short_values() {
727        assert!(validate_sha("0123456789abcdef0123456789abcdef01234567").is_ok());
728        assert!(
729            validate_sha("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
730                .is_ok()
731        );
732
733        for sha in [
734            "",
735            "0123456789abcdef0123456789abcdef0123456",
736            "0123456789abcdef0123456789abcdef0123456g",
737            "--upload-pack=/tmp/x",
738            "0123456789abcdef0123456789abcdef012345678",
739        ] {
740            assert!(validate_sha(sha).is_err(), "{sha:?} should be rejected");
741        }
742    }
743
744    #[test]
745    fn bundle_push_roundtrip_updates_bare_repository() {
746        let tmp = tempfile::tempdir().unwrap();
747        let work = tmp.path().join("work");
748        let target = tmp.path().join("target.git");
749        fs::create_dir_all(&work).unwrap();
750
751        test_git(Command::new("git").arg("init").arg(&work));
752        fs::write(work.join("README.md"), "hello\n").unwrap();
753        test_git(
754            Command::new("git")
755                .arg("-C")
756                .arg(&work)
757                .arg("add")
758                .arg("README.md"),
759        );
760        test_git(
761            Command::new("git")
762                .arg("-C")
763                .arg(&work)
764                .arg("-c")
765                .arg("user.name=RNS Test")
766                .arg("-c")
767                .arg("user.email=rns@example.invalid")
768                .arg("commit")
769                .arg("-m")
770                .arg("init"),
771        );
772        test_git(
773            Command::new("git")
774                .arg("-C")
775                .arg(&work)
776                .arg("branch")
777                .arg("-M")
778                .arg("main"),
779        );
780        let sha = test_git(
781            Command::new("git")
782                .arg("-C")
783                .arg(&work)
784                .arg("rev-parse")
785                .arg("refs/heads/main"),
786        );
787        let sha = sha.trim().to_string();
788        let bundle_path = tmp.path().join("push.bundle");
789        test_git(
790            Command::new("git")
791                .arg("-C")
792                .arg(&work)
793                .arg("bundle")
794                .arg("create")
795                .arg(&bundle_path)
796                .arg("refs/heads/main"),
797        );
798
799        apply_push(
800            &target,
801            &fs::read(&bundle_path).unwrap(),
802            &[RefUpdate {
803                refname: "refs/heads/main".into(),
804                old: None,
805                new: Some(sha.clone()),
806                force: true,
807            }],
808        )
809        .unwrap();
810
811        assert_eq!(
812            list_refs(&target).unwrap(),
813            vec![(sha.clone(), "refs/heads/main".into())]
814        );
815        assert!(!create_bundle(&target, &[]).unwrap().is_empty());
816        assert!(create_bundle(&target, &[sha]).unwrap().is_empty());
817    }
818
819    #[test]
820    fn fetch_bundle_can_scope_to_requested_refs() {
821        let tmp = tempfile::tempdir().unwrap();
822        let work = tmp.path().join("work");
823        let target = tmp.path().join("target.git");
824        fs::create_dir_all(&work).unwrap();
825
826        test_git(Command::new("git").arg("init").arg(&work));
827        fs::write(work.join("README.md"), "main\n").unwrap();
828        test_git(
829            Command::new("git")
830                .arg("-C")
831                .arg(&work)
832                .arg("add")
833                .arg("README.md"),
834        );
835        test_git(
836            Command::new("git")
837                .arg("-C")
838                .arg(&work)
839                .arg("-c")
840                .arg("user.name=RNS Test")
841                .arg("-c")
842                .arg("user.email=rns@example.invalid")
843                .arg("commit")
844                .arg("-m")
845                .arg("main"),
846        );
847        test_git(
848            Command::new("git")
849                .arg("-C")
850                .arg(&work)
851                .arg("branch")
852                .arg("-M")
853                .arg("main"),
854        );
855        test_git(
856            Command::new("git")
857                .arg("-C")
858                .arg(&work)
859                .arg("checkout")
860                .arg("-b")
861                .arg("side"),
862        );
863        fs::write(work.join("SIDE.md"), "side\n").unwrap();
864        test_git(
865            Command::new("git")
866                .arg("-C")
867                .arg(&work)
868                .arg("add")
869                .arg("SIDE.md"),
870        );
871        test_git(
872            Command::new("git")
873                .arg("-C")
874                .arg(&work)
875                .arg("-c")
876                .arg("user.name=RNS Test")
877                .arg("-c")
878                .arg("user.email=rns@example.invalid")
879                .arg("commit")
880                .arg("-m")
881                .arg("side"),
882        );
883        ensure_bare_repository(&target).unwrap();
884        test_git(
885            Command::new("git")
886                .arg("--git-dir")
887                .arg(&target)
888                .arg("fetch")
889                .arg(&work)
890                .arg("+refs/heads/*:refs/heads/*"),
891        );
892
893        let bundle = create_bundle_for_refs(&target, &[], &["refs/heads/main".into()]).unwrap();
894        let bundle_path = tmp.path().join("fetch.bundle");
895        fs::write(&bundle_path, bundle).unwrap();
896        let heads = test_git(
897            Command::new("git")
898                .arg("bundle")
899                .arg("list-heads")
900                .arg(&bundle_path),
901        );
902
903        assert!(heads.contains("refs/heads/main"));
904        assert!(!heads.contains("refs/heads/side"));
905    }
906
907    #[test]
908    fn local_push_bundle_excludes_known_remote_objects() {
909        let tmp = tempfile::tempdir().unwrap();
910        let work = tmp.path().join("work");
911        fs::create_dir_all(&work).unwrap();
912
913        test_git(Command::new("git").arg("init").arg(&work));
914        fs::write(work.join("README.md"), "hello\n").unwrap();
915        test_git(
916            Command::new("git")
917                .arg("-C")
918                .arg(&work)
919                .arg("add")
920                .arg("README.md"),
921        );
922        test_git(
923            Command::new("git")
924                .arg("-C")
925                .arg(&work)
926                .arg("-c")
927                .arg("user.name=RNS Test")
928                .arg("-c")
929                .arg("user.email=rns@example.invalid")
930                .arg("commit")
931                .arg("-m")
932                .arg("init"),
933        );
934        test_git(
935            Command::new("git")
936                .arg("-C")
937                .arg(&work)
938                .arg("branch")
939                .arg("-M")
940                .arg("main"),
941        );
942        let sha = test_git(
943            Command::new("git")
944                .arg("-C")
945                .arg(&work)
946                .arg("rev-parse")
947                .arg("refs/heads/main"),
948        )
949        .trim()
950        .to_string();
951        let full = create_local_bundle_in(&work, &["refs/heads/main".into()], &[]).unwrap();
952        let excluded = create_local_bundle_in(&work, &["refs/heads/main".into()], &[sha]).unwrap();
953        assert!(!full.is_empty());
954        assert!(excluded.is_empty());
955    }
956
957    fn commit_in_new_work_tree(work: &Path, branch: &str) -> String {
958        fs::create_dir_all(work).unwrap();
959        test_git(
960            Command::new("git")
961                .arg("init")
962                .arg("-b")
963                .arg(branch)
964                .arg(work),
965        );
966        fs::write(work.join("README.md"), "hello\n").unwrap();
967        test_git(
968            Command::new("git")
969                .arg("-C")
970                .arg(work)
971                .args(["add", "README.md"]),
972        );
973        test_git(Command::new("git").arg("-C").arg(work).args([
974            "-c",
975            "user.name=RNS Test",
976            "-c",
977            "user.email=rns@example.invalid",
978            "commit",
979            "-m",
980            "init",
981        ]));
982        test_git(
983            Command::new("git")
984                .arg("-C")
985                .arg(work)
986                .args(["rev-parse", "HEAD"]),
987        )
988        .trim()
989        .to_string()
990    }
991
992    #[test]
993    fn clone_remote_bare_fetches_from_git_url_and_follows_source_head() {
994        let tmp = tempfile::tempdir().unwrap();
995        let served = tmp.path().join("served");
996        let sha = commit_in_new_work_tree(&served.join("project"), "trunk");
997        let daemon = test_support::GitDaemon::serve(&served);
998        let source = daemon.url("project");
999        let target = tmp.path().join("repos/group/mirror");
1000
1001        clone_remote_bare(&source, &target, "mirror").unwrap();
1002
1003        assert!(is_bare_repository(&target));
1004        assert_eq!(
1005            list_refs(&target).unwrap(),
1006            vec![(sha, "refs/heads/trunk".to_string())]
1007        );
1008        assert_eq!(
1009            test_git(
1010                Command::new("git")
1011                    .arg("--git-dir")
1012                    .arg(&target)
1013                    .args(["symbolic-ref", "HEAD"])
1014            )
1015            .trim(),
1016            "refs/heads/trunk"
1017        );
1018        assert_eq!(
1019            repository_config(&target, "repository.rngit.upstream.source")
1020                .unwrap()
1021                .as_deref(),
1022            Some(source.as_str())
1023        );
1024        assert_eq!(sync_upstream(&target).unwrap(), "mirror");
1025    }
1026
1027    #[test]
1028    fn clone_remote_bare_rejects_sources_outside_the_allow_list() {
1029        let tmp = tempfile::tempdir().unwrap();
1030        let source_dir = tmp.path().join("source");
1031        commit_in_new_work_tree(&source_dir, "main");
1032        let local_path = source_dir.to_string_lossy().into_owned();
1033        let file_url = format!("file://{local_path}");
1034        for source in [
1035            local_path.as_str(),
1036            file_url.as_str(),
1037            "--option=value",
1038            "-c",
1039            "",
1040        ] {
1041            let target = tmp.path().join("repos/group/rejected");
1042            assert!(
1043                clone_remote_bare(source, &target, "fork").is_err(),
1044                "{source:?} should be rejected"
1045            );
1046            assert!(!target.exists());
1047        }
1048    }
1049
1050    #[test]
1051    fn sync_upstream_rejects_stored_source_outside_the_allow_list() {
1052        let tmp = tempfile::tempdir().unwrap();
1053        let repo = tmp.path().join("repo.git");
1054        ensure_bare_repository(&repo).unwrap();
1055        for (key, value) in [
1056            ("repository.rngit.type", "fork"),
1057            ("repository.rngit.upstream.source", "--option=value"),
1058        ] {
1059            test_git(
1060                Command::new("git")
1061                    .arg("--git-dir")
1062                    .arg(&repo)
1063                    .args(["config", "--", key, value]),
1064            );
1065        }
1066
1067        assert!(sync_upstream(&repo).is_err());
1068    }
1069
1070    #[test]
1071    fn local_ref_lookup_ignores_option_like_names() {
1072        assert_eq!(local_ref_sha("--all").unwrap(), None);
1073        assert_eq!(local_ref_sha("").unwrap(), None);
1074        assert!(!object_exists_local("--all"));
1075        assert!(!object_exists_in_repo(Path::new("."), "--all"));
1076    }
1077
1078    fn test_git(cmd: &mut Command) -> String {
1079        let output = cmd.output().unwrap();
1080        assert!(
1081            output.status.success(),
1082            "git command failed: {}",
1083            String::from_utf8_lossy(&output.stderr)
1084        );
1085        String::from_utf8_lossy(&output.stdout).into_owned()
1086    }
1087}