1 //! f0rge #55/#56/#57: `dist/repo.git` is the snapshot, not the object DB.2 //!3 //! A local-path clone copies every object (including unreachable history)4 //! and leaves one loose object per blob; building from `repo.bundle`5 //! negotiates reachable objects only, giving one pack and no dangling6 //! commits. The export must also not publish the build machine's path.7 8 use std::path::{Path, PathBuf};9 use std::process::Command;10 11 fn fresh(prefix: &str) -> PathBuf {12 let dir = std::env::temp_dir().join(format!(13 "hu13-export-{prefix}-{}-{}",14 std::process::id(),15 std::time::SystemTime::now()16 .duration_since(std::time::UNIX_EPOCH)17 .map(|d| d.as_nanos())18 .unwrap_or(0)19 ));20 std::fs::create_dir_all(&dir).unwrap();21 dir22 }23 24 fn git(repo: &Path, args: &[&str]) -> String {25 let out = Command::new("git")26 .arg("-C")27 .arg(repo)28 .args(args)29 .output()30 .expect("spawn git");31 assert!(32 out.status.success(),33 "git {args:?}: {}",34 String::from_utf8_lossy(&out.stderr)35 );36 String::from_utf8_lossy(&out.stdout).trim().to_string()37 }38 39 fn count_files(dir: &Path) -> usize {40 let mut n = 0;41 let mut stack = vec![dir.to_path_buf()];42 while let Some(d) = stack.pop() {43 if let Ok(rd) = std::fs::read_dir(&d) {44 for e in rd.flatten() {45 let p = e.path();46 if p.is_dir() {47 stack.push(p);48 } else {49 n += 1;50 }51 }52 }53 }54 n55 }56 57 #[test]58 fn export_is_exact_snapshot_single_pack_no_paths() {59 let repo = fresh("repo");60 let out = fresh("out");61 for args in [62 ["init", "-b", "main"].as_slice(),63 &["config", "user.name", "hu13-test"],64 &["config", "user.email", "[email protected]"],65 &["config", "commit.gpgsign", "false"],66 ] {67 let st = Command::new("git")68 .arg("-C")69 .arg(&repo)70 .args(args)71 .status()72 .unwrap();73 assert!(st.success());74 }75 std::fs::write(repo.join("README.md"), "# export\n").unwrap();76 std::fs::write(repo.join("a.txt"), "one\n").unwrap();77 git(&repo, &["add", "-A"]);78 git(&repo, &["commit", "-m", "one"]);79 std::fs::write(repo.join("b.txt"), "two\n").unwrap();80 git(&repo, &["add", "-A"]);81 git(&repo, &["commit", "-m", "two"]);82 83 // Unreachable history: commit on a detached HEAD, then move away.84 // This is the #55 leak: local-transport clones carry it, bundles don't.85 let base = git(&repo, &["rev-parse", "HEAD"]);86 git(&repo, &["checkout", "-q", "--detach"]);87 std::fs::write(repo.join("secret.txt"), "unreachable secret\n").unwrap();88 git(&repo, &["add", "-A"]);89 git(&repo, &["commit", "-q", "-m", "dangling secret"]);90 let dangling = git(&repo, &["rev-parse", "HEAD"]);91 git(&repo, &["checkout", "-q", "main"]);92 std::fs::write(repo.join("secret.txt"), "").ok();93 let _ = std::fs::remove_file(repo.join("secret.txt"));94 git(&repo, &["reset", "-q", "--hard", &base]);95 assert_ne!(dangling, base);96 97 let produced = Command::new(env!("CARGO_BIN_EXE_hu13"))98 .arg("build")99 .arg(&repo)100 .arg("-o")101 .arg(&out)102 .output()103 .unwrap();104 assert!(105 produced.status.success(),106 "build: {}",107 String::from_utf8_lossy(&produced.stderr)108 );109 110 let gd = out.join("repo.git");111 assert!(gd.is_dir(), "repo.git emitted");112 113 // One pack, zero loose objects, few files (Pages 20k cap comfort).114 let packs = std::fs::read_dir(gd.join("objects/pack"))115 .map(|rd| {116 rd.flatten()117 .filter(|e| e.path().extension().is_some_and(|x| x == "pack"))118 .count()119 })120 .unwrap_or(0);121 assert_eq!(packs, 1, "exactly one pack");122 let loose = std::fs::read_dir(gd.join("objects"))123 .map(|rd| {124 rd.flatten()125 .filter(|e| e.path().is_dir())126 .filter(|e| e.file_name().len() == 2)127 .map(|e| std::fs::read_dir(e.path()).map(|r| r.count()).unwrap_or(0))128 .sum::<usize>()129 })130 .unwrap_or(0);131 assert_eq!(loose, 0, "no loose objects");132 let files = count_files(&gd);133 assert!(files < 40, "dumb repo stays small, got {files} files");134 135 // No unreachable/dangling objects: the snapshot is exact.136 let fsck = Command::new("git")137 .arg("--git-dir")138 .arg(&gd)139 .args(["fsck", "--unreachable", "--dangling", "--no-progress"])140 .output()141 .unwrap();142 let report = String::from_utf8_lossy(&fsck.stdout);143 assert!(144 report.trim().is_empty(),145 "no unreachable/dangling objects, got: {report}"146 );147 148 // The dangling commit is genuinely absent from the export.149 let has = Command::new("git")150 .arg("--git-dir")151 .arg(&gd)152 .args(["cat-file", "-e", &dangling])153 .status()154 .unwrap();155 assert!(!has.success(), "dangling commit must not be exported");156 157 // No local filesystem path in config.158 let config = std::fs::read_to_string(gd.join("config")).unwrap();159 assert!(160 !config.contains(&repo.to_string_lossy().to_string()),161 "config must not leak the source path: {config}"162 );163 assert!(!config.contains("remote"), "no remote section: {config}");164 165 // Sample hooks are gone.166 let samples = std::fs::read_dir(gd.join("hooks"))167 .map(|rd| {168 rd.flatten()169 .filter(|e| e.path().extension().is_some_and(|x| x == "sample"))170 .count()171 })172 .unwrap_or(0);173 assert_eq!(samples, 0, "no sample hooks");174 175 // And it still clones.176 let dest = fresh("cloned").join("c");177 let st = Command::new("git")178 .arg("clone")179 .arg("--quiet")180 .arg("-b")181 .arg("main")182 .arg(&gd)183 .arg(&dest)184 .status()185 .unwrap();186 assert!(st.success(), "clone from exported repo.git works");187 assert!(dest.join("README.md").is_file());188 let log = git(&dest, &["log", "--oneline"]);189 assert_eq!(log.lines().count(), 2, "only reachable commits: {log}");190 }