From ab1398c19bf7c626ec81a11eef52ad666643f774 Mon Sep 17 00:00:00 2001 From: hu13-test Date: Tue, 27 Oct 2026 15:52:32 +0000 Subject: [PATCH] build repo.git from the bundle; strip paths and hooks (#55 #56 #57 #58 #59) #55/#56: export now clones from out/repo.bundle instead of the live repo. Bundle transport negotiates reachable objects only, so the dumb export is the exact snapshot: one pack, zero loose objects, no unreachable history. On this repo: 260 files/226 loose -> 10 files/ 1 pack/0 loose, fsck reports nothing, and a dangling commit in the source is absent from the clone. #57: 'git remote remove origin' drops remote.origin.url, so config no longer publishes the build machine's path. #58: run_plain_git deleted; one run_git_opt(repo: Option, timeout, display) runner serves both -C and plain git. Paths stay OsStr end to end (snapshot bundle/archive and --git-dir no longer round-trip through to_string_lossy). Export gets EXPORT_TIMEOUT (120s) instead of sharing the 10s metadata budget. #59: content-type rule anchored to *.git/HEAD and info/* (no more matches for any file named 'head'); unset-origin clone block is bundle-first with repo.git as the served-directory form; Dockerfile and wiki/deploy describe /repo.git + HU13_PUBLIC_ORIGIN and the /raw/ header rule; /dist gitignored. tests/clone_export.rs asserts pack/loose/file counts, clean fsck, no dangling commit, no path in config, no sample hooks, clone works. --- .forge/wiki/deploy | 28 ++++-- .gitignore | 1 + Dockerfile | 4 +- src/build.rs | 18 ++-- src/gitx.rs | 213 ++++++++++++++++++++---------------------- src/serve.rs | 11 ++- tests/clone_export.rs | 190 +++++++++++++++++++++++++++++++++++++ 7 files changed, 328 insertions(+), 137 deletions(-) create mode 100644 tests/clone_export.rs diff --git a/.forge/wiki/deploy b/.forge/wiki/deploy index 15b953e..3e94c60 100644 --- a/.forge/wiki/deploy +++ b/.forge/wiki/deploy @@ -1,10 +1,5 @@ # Deploy hu13 with Docker -> NOTE (m5/docker): this file is `deploy.DOC.md` in the worktree root so the -> branch stays free of `.forge/` writes. Supervisor: move it to the wiki -> with `f0rge wiki put deploy` (or copy the body to `.forge/wiki/deploy.md` -> via the CLI) and delete this file. - ## Build the image From the repo root (build context is sources only — see `.dockerignore`): @@ -27,6 +22,7 @@ docker run --rm -p 39313:39313 \ -v "$PWD:/data/repo:ro" \ -v hu13-inbox:/data/inbox \ -e HU13_BRANCH=main \ + -e HU13_PUBLIC_ORIGIN=http://localhost:39313 \ -e HU13_CLONE_URL=https://example.com/mirror.git \ hu13 ``` @@ -35,10 +31,13 @@ Then open `http://localhost:39313/`. What happens inside (`entrypoint.sh`): -1. `hu13 build /data/repo -o /srv/www` (+ `--branch` / `--clone-url` - only when `HU13_BRANCH` / `HU13_CLONE_URL` are set). Any failure — - bad branch, unreadable repo — aborts LOUD before serving starts - (`set -eu`; serve never runs on a stale/empty site). +1. `hu13 build /data/repo -o /srv/www` (+ `--branch` / `--clone-url` / + `--public-origin` only when `HU13_BRANCH` / `HU13_CLONE_URL` / + `HU13_PUBLIC_ORIGIN` are set). Any failure — bad branch, unreadable + repo — aborts LOUD before serving starts (`set -eu`; serve never runs + on a stale/empty site). The build emits `repo.git` (a dumb-HTTP bare + repo: `git clone /repo.git`, no smart server needed), + `repo.bundle` and `repo.tar.gz`. 2. `exec hu13 serve --www /srv/www --inbox-dir /data/inbox --listen 0.0.0.0:39313` (+ `--max-title`, `--max-body`, `--max-bytes`, `--rate-per-hour`, `--quota` only when the matching @@ -52,7 +51,8 @@ What happens inside (`entrypoint.sh`): | `/data/repo` (ro) | — | repo checkout to render (required) | | `/data/inbox` | — | inbox storage; MUST sit outside `--www` (serve refuses otherwise) | | `HU13_BRANCH` | `main`, else `master` | branch to build; wrong name fails the container | -| `HU13_CLONE_URL` | git origin remote | clone URL advertised on the index | +| `HU13_PUBLIC_ORIGIN` | unset | absolute base URL; enables `git clone /repo.git` + absolute bundle/tarball links | +| `HU13_CLONE_URL` | git origin remote | fallback/mirror label shown as "mirror source" | | `HU13_LISTEN` | `0.0.0.0:39313` | serve address | | `HU13_MAX_TITLE` | `300` | inbox title cap (chars) | | `HU13_MAX_BODY` | `20000` | inbox body/prompt cap (chars) | @@ -62,6 +62,14 @@ What happens inside (`entrypoint.sh`): ## Notes +- `dist/` in the repo is gitignored; build outside the checkout (or to + `/srv/www`) so the derived `repo.git` directory never looks tracked. +- Static hosting (Cloudflare Pages/nginx): publish the whole `dist/` + directory. `dist/repo.git` is plain files + `update-server-info`, and + `dist/_headers` carries the `/raw/**` download policy for Pages. On + nginx, add the equivalent `add_header Content-Disposition attachment` + for `/raw/` — committed HTML/SVG must never render from that path. + - Binary size: release build is ~2.1MB unstripped, ~1.7MB stripped. If it ever tops 2MB stripped, the cut list is: replace `yaml_serde` (libyaml) with the hand-rolled frontmatter split already used for diff --git a/.gitignore b/.gitignore index c374b5b..5f196bf 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .forge/ci/runs/ .forge/releases/out/ *.tmp +/dist diff --git a/Dockerfile b/Dockerfile index ea5b517..6c86577 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,7 +34,9 @@ RUN chmod +x /usr/local/bin/entrypoint.sh # Header config defaults (m6, issues #21 + #24): empty means fallback — # repo dir name for the name (never blank), hidden line for the description. # Public origin default (m6, issue #32): empty means unset (bundle-first -# presentation; set HU13_PUBLIC_ORIGIN to advertise `/repo.bundle`). +# presentation; set HU13_PUBLIC_ORIGIN to advertise `/repo.git` +# (`git clone /repo.git` needs no smart server) and +# `/repo.bundle` for the snapshot download). ENV HU13_REPO_NAME="" ENV HU13_DESCRIPTION="" ENV HU13_PUBLIC_ORIGIN="" diff --git a/src/build.rs b/src/build.rs index 52aeedb..a88adba 100644 --- a/src/build.rs +++ b/src/build.rs @@ -745,17 +745,21 @@ pub fn clone_block( esc(url) )); } - out.push_str("

Clone this mirror

\n"); - out.push_str(&format!( - "
git clone repo.git -b {}
\n\ -

dumb-HTTP checkout served from this server; ask the host for its address.

\n", - esc(branch) - )); + // Unset origin: the bundle is the authoritative, self-contained + // clone method (no host address needed). `repo.git` is the same + // snapshot as a served directory, mentioned second (f0rge #59). out.push_str("

Clone the bundle

\n"); out.push_str(&format!( "
git clone repo.bundle -b {}
\n

{bundle_link}

\n", esc(branch) )); + out.push_str("

Served as a directory

\n"); + out.push_str(&format!( + "
git clone repo.git -b {}
\n\ +

the same snapshot as a dumb-HTTP checkout under this site's \ + web root (/repo.git), no smart server needed.

\n", + esc(branch) + )); out.push_str("

Download the tarball

\n"); out.push_str(&format!( "
tar -xzf repo.tar.gz
\n

{tarball_link}

\n" @@ -1192,7 +1196,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result/repo.git` // works on any static server (Pages/nginx/serve), no smart HTTP. let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?; - gitx::export_dumb_repo(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?; + gitx::export_dumb_repo(&snap.bundle, &branch, out).map_err(|e| format!("hu13: {e}"))?; let bundle_size = std::fs::metadata(&snap.bundle) .map(|m| m.len()) .unwrap_or(0); diff --git a/src/gitx.rs b/src/gitx.rs index 8d09364..56acaf5 100644 --- a/src/gitx.rs +++ b/src/gitx.rs @@ -14,9 +14,16 @@ use std::sync::mpsc; use std::thread; use std::time::{Duration, Instant}; -/// Timeout for every `git` subprocess. +/// Timeout for metadata `git` subprocesses. pub const GIT_TIMEOUT: Duration = Duration::from_secs(10); +/// Timeout for the dumb-export step (a full clone + repack). +/// +/// Deliberately separate from [`GIT_TIMEOUT`]: the export reproduces a +/// whole history, so sharing the 10s metadata budget would fail builds +/// whose bundle already took seconds (f0rge #58). +pub const EXPORT_TIMEOUT: Duration = Duration::from_secs(120); + /// Default commit count for [`log_n`]. pub const DEFAULT_LOG_N: usize = 100; @@ -145,12 +152,26 @@ fn cmd_string(repo: &Path, args: &[&OsStr]) -> String { } fn run_git(repo: &Path, args: &[&OsStr]) -> Result { + run_git_opt(Some(repo), args, GIT_TIMEOUT, cmd_string(repo, args)) +} + +/// Shared runner for `git` subprocesses with a timeout. +/// +/// `repo` is `None` for commands that must not pass `-C` (the dumb-export +/// clone/update-server-info pair); `display` is the command label used in +/// errors. Paths stay `OsStr` end to end (f0rge #58): no lossy UTF-8 +/// round-trip. +fn run_git_opt( + repo: Option<&Path>, + args: &[&OsStr], + timeout: Duration, + display: String, +) -> Result { let mut cmd = Command::new("git"); - cmd.arg("-C") - .arg(repo) - .args(args) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); + if let Some(repo) = repo { + cmd.arg("-C").arg(repo); + } + cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); let mut child = cmd.spawn().map_err(|e| GitError::Spawn(e.to_string()))?; let stdout_handle = child.stdout.take(); @@ -185,7 +206,7 @@ fn run_git(repo: &Path, args: &[&OsStr]) -> Result { match child.try_wait() { Err(e) => { return Err(GitError::Failed { - cmd: cmd_string(repo, args), + cmd: display.clone(), status: "wait-error".to_string(), stderr: e.to_string(), }) @@ -205,10 +226,10 @@ fn run_git(repo: &Path, args: &[&OsStr]) -> Result { }); } Ok(None) => { - if start.elapsed() >= GIT_TIMEOUT { + if start.elapsed() >= timeout { let _ = child.kill(); let _ = child.wait(); - return Err(GitError::Timeout(cmd_string(repo, args))); + return Err(GitError::Timeout(display.clone())); } thread::sleep(Duration::from_millis(10)); } @@ -847,15 +868,13 @@ pub fn snapshot(repo: &Path, branch: &str, out: &Path) -> Result = vec![ a_bundle, a_create, - os_bundle.as_os_str(), + bundle.as_os_str(), os_branch.as_os_str(), ]; let bytes = run_git(repo, &args); @@ -869,14 +888,14 @@ pub fn snapshot(repo: &Path, branch: &str, out: &Path) -> Result`: build the flag as an OsString (no lossy + // UTF-8 round-trip for non-UTF-8 output paths). + let mut a_out = OsString::from("-o"); + a_out.push(tarball.as_os_str()); let args2: Vec<&OsStr> = vec![a_archive, a_fmt, a_out.as_os_str(), os_branch2.as_os_str()]; - let _ = os_tar; let out2 = run_git(repo, &args2)?; if !out2.status_ok { let stderr = String::from_utf8_lossy(&out2.stderr).trim().to_string(); @@ -889,88 +908,24 @@ pub fn snapshot(repo: &Path, branch: &str, out: &Path) -> Result Result { - let mut cmd = Command::new("git"); - cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); - let mut child = cmd.spawn().map_err(|e| GitError::Spawn(e.to_string()))?; - let stdout_handle = child.stdout.take(); - let stderr_handle = child.stderr.take(); - let (tx_out, rx_out) = mpsc::channel::>(); - let (tx_err, rx_err) = mpsc::channel::>(); - if let Some(mut out) = stdout_handle { - let tx = tx_out; - thread::spawn(move || { - let mut buf = Vec::new(); - let _ = out.read_to_end(&mut buf); - let _ = tx.send(buf); - }); - } else { - let _ = tx_out.send(Vec::new()); - } - if let Some(mut err) = stderr_handle { - let tx = tx_err; - thread::spawn(move || { - let mut buf = Vec::new(); - let _ = err.read_to_end(&mut buf); - let _ = tx.send(buf); - }); - } else { - let _ = tx_err.send(Vec::new()); - } - let start = Instant::now(); - loop { - match child.try_wait() { - Err(e) => { - return Err(GitError::Failed { - cmd: format!( - "git {}", - args.iter() - .map(|a| a.to_string_lossy()) - .collect::>() - .join(" ") - ), - status: "wait-error".to_string(), - stderr: e.to_string(), - }); - } - Ok(Some(status)) => { - let stdout = rx_out - .recv_timeout(Duration::from_secs(5)) - .unwrap_or_default(); - let stderr = rx_err - .recv_timeout(Duration::from_secs(5)) - .unwrap_or_default(); - return Ok(GitOutput { - status_ok: status.success(), - status_str: status.to_string(), - stdout, - stderr, - }); - } - Ok(None) => { - if start.elapsed() >= GIT_TIMEOUT { - let _ = child.kill(); - let _ = child.wait(); - return Err(GitError::Timeout( - "git clone/update-server-info".to_string(), - )); - } - thread::sleep(Duration::from_millis(10)); - } - } - } -} - -/// Export a dumb-HTTP clone dir into `out/repo.git` (issue #54). +/// Export a dumb-HTTP clone dir into `out/repo.git` (issues #54/#55/#56/#57). /// -/// Runs `git clone --bare --single-branch` plus -/// `git --git-dir= update-server-info`, so plain static servers -/// (Pages/nginx/`hu13 serve`) all serve `git clone /repo.git` -/// with zero dynamic smart HTTP. No GitHub, no config. +/// The source is the **bundle** written by [`snapshot`], not the live +/// repo: bundle transport negotiates reachable objects only, so the +/// export is exactly the snapshot — one pack, no loose objects, no +/// unreachable/dangling history leaking out of `refs` (f0rge #55/#56). +/// A local-path clone (`git clone `) uses local transport instead +/// and copies the whole object DB, including unreachable commits. +/// +/// After cloning we drop `remote.origin` (it held the build machine's +/// absolute path, f0rge #57), remove the sample hooks (file-count noise), +/// and run `update-server-info` so plain static servers +/// (Pages/nginx/`hu13 serve`) can serve `git clone /repo.git` +/// with zero dynamic smart HTTP. /// /// Failures are loud (build fails, never a silent missing dir), and an /// existing `out/repo.git` is removed first so stale objects never survive. -pub fn export_dumb_repo(repo: &Path, branch: &str, out: &Path) -> Result { +pub fn export_dumb_repo(bundle: &Path, branch: &str, out: &Path) -> Result { std::fs::create_dir_all(out) .map_err(|e| GitError::Io(format!("create out dir {}: {e}", out.to_string_lossy())))?; let dest = out.join("repo.git"); @@ -978,46 +933,76 @@ pub fn export_dumb_repo(repo: &Path, branch: &str, out: &Path) -> Result = vec![ a_clone, a_bare, + a_quiet, a_single, a_branch, - os_branch.as_os_str(), + OsStr::new(branch), a_sep, - OsStr::new(os_repo.as_os_str()), - os_dest.as_os_str(), + bundle.as_os_str(), + dest.as_os_str(), ]; - let out1 = run_plain_git(&args)?; + let display = + format!("git clone --bare --single-branch --branch {branch} repo.git"); + let out1 = run_git_opt(None, &args, EXPORT_TIMEOUT, display.clone())?; if !out1.status_ok { - let stderr = String::from_utf8_lossy(&out1.stderr).trim().to_string(); return Err(GitError::Failed { - cmd: format!("git clone --bare --single-branch --branch {branch} repo.git"), + cmd: display, status: out1.status_str, - stderr, + stderr: String::from_utf8_lossy(&out1.stderr).trim().to_string(), }); } - let git_dir_flag = OsString::from(format!("--git-dir={dest_str}")); - let a_usi: &OsStr = OsStr::new("update-server-info"); - let args2: Vec<&OsStr> = vec![git_dir_flag.as_os_str(), a_usi]; - let out2 = run_plain_git(&args2)?; + + // `remote.origin.url` would publish the local filesystem path. + // Build `--git-dir=` as an OsString: no UTF-8 round-trip. + let mut git_dir_arg = OsString::from("--git-dir="); + git_dir_arg.push(dest.as_os_str()); + let a_remote: &OsStr = OsStr::new("remote"); + let a_remove: &OsStr = OsStr::new("remove"); + let a_origin: &OsStr = OsStr::new("origin"); + let args_remote: Vec<&OsStr> = vec![git_dir_arg.as_os_str(), a_remote, a_remove, a_origin]; + let display_remote = "git --git-dir= remote remove origin".to_string(); + let out2 = run_git_opt(None, &args_remote, GIT_TIMEOUT, display_remote.clone())?; if !out2.status_ok { - let stderr = String::from_utf8_lossy(&out2.stderr).trim().to_string(); return Err(GitError::Failed { - cmd: "git --git-dir= update-server-info".to_string(), + cmd: display_remote, status: out2.status_str, - stderr, + stderr: String::from_utf8_lossy(&out2.stderr).trim().to_string(), + }); + } + + // Sample hooks are noise for a mirror; removing them also keeps the + // dumb-HTTP file count down for the Pages 20k-file cap. + let hooks = dest.join("hooks"); + if let Ok(rd) = std::fs::read_dir(&hooks) { + for ent in rd.flatten() { + let path = ent.path(); + if path.extension().is_some_and(|e| e == "sample") { + let _ = std::fs::remove_file(path); + } + } + } + + let a_usi: &OsStr = OsStr::new("update-server-info"); + let args2: Vec<&OsStr> = vec![git_dir_arg.as_os_str(), a_usi]; + let display_usi = "git --git-dir= update-server-info".to_string(); + let out3 = run_git_opt(None, &args2, EXPORT_TIMEOUT, display_usi.clone())?; + if !out3.status_ok { + return Err(GitError::Failed { + cmd: display_usi, + status: out3.status_str, + stderr: String::from_utf8_lossy(&out3.stderr).trim().to_string(), }); } Ok(dest) diff --git a/src/serve.rs b/src/serve.rs index dc71347..12718d1 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -459,11 +459,12 @@ fn content_type_for(name: &str) -> &'static str { "application/atom+xml; charset=utf-8" } else if lower.ends_with(".md") || lower.ends_with(".txt") { "text/plain; charset=utf-8" - } else if lower.ends_with("/head") - || lower.ends_with("/info/refs") - || lower.ends_with("/info/packs") - || lower.ends_with("/info/alternates") - || lower.ends_with("packed-refs") + } else if lower.ends_with(".git/head") + || (lower.contains(".git/") + && (lower.ends_with("/info/refs") + || lower.ends_with("/info/packs") + || lower.ends_with("/info/alternates") + || lower.ends_with("/packed-refs"))) { // Dumb-HTTP git files (#54): plain text so any static server // (Pages/nginx/serve) serves `repo.git` identically. Git ignores diff --git a/tests/clone_export.rs b/tests/clone_export.rs new file mode 100644 index 0000000..6c95ec5 --- /dev/null +++ b/tests/clone_export.rs @@ -0,0 +1,190 @@ +//! f0rge #55/#56/#57: `dist/repo.git` is the snapshot, not the object DB. +//! +//! A local-path clone copies every object (including unreachable history) +//! and leaves one loose object per blob; building from `repo.bundle` +//! negotiates reachable objects only, giving one pack and no dangling +//! commits. The export must also not publish the build machine's path. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn fresh(prefix: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "hu13-export-{prefix}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn git(repo: &Path, args: &[&str]) -> String { + let out = Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output() + .expect("spawn git"); + assert!( + out.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().to_string() +} + +fn count_files(dir: &Path) -> usize { + let mut n = 0; + let mut stack = vec![dir.to_path_buf()]; + while let Some(d) = stack.pop() { + if let Ok(rd) = std::fs::read_dir(&d) { + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + stack.push(p); + } else { + n += 1; + } + } + } + } + n +} + +#[test] +fn export_is_exact_snapshot_single_pack_no_paths() { + let repo = fresh("repo"); + let out = fresh("out"); + for args in [ + ["init", "-b", "main"].as_slice(), + &["config", "user.name", "hu13-test"], + &["config", "user.email", "hu13@test.local"], + &["config", "commit.gpgsign", "false"], + ] { + let st = Command::new("git") + .arg("-C") + .arg(&repo) + .args(args) + .status() + .unwrap(); + assert!(st.success()); + } + std::fs::write(repo.join("README.md"), "# export\n").unwrap(); + std::fs::write(repo.join("a.txt"), "one\n").unwrap(); + git(&repo, &["add", "-A"]); + git(&repo, &["commit", "-m", "one"]); + std::fs::write(repo.join("b.txt"), "two\n").unwrap(); + git(&repo, &["add", "-A"]); + git(&repo, &["commit", "-m", "two"]); + + // Unreachable history: commit on a detached HEAD, then move away. + // This is the #55 leak: local-transport clones carry it, bundles don't. + let base = git(&repo, &["rev-parse", "HEAD"]); + git(&repo, &["checkout", "-q", "--detach"]); + std::fs::write(repo.join("secret.txt"), "unreachable secret\n").unwrap(); + git(&repo, &["add", "-A"]); + git(&repo, &["commit", "-q", "-m", "dangling secret"]); + let dangling = git(&repo, &["rev-parse", "HEAD"]); + git(&repo, &["checkout", "-q", "main"]); + std::fs::write(repo.join("secret.txt"), "").ok(); + let _ = std::fs::remove_file(repo.join("secret.txt")); + git(&repo, &["reset", "-q", "--hard", &base]); + assert_ne!(dangling, base); + + let produced = Command::new(env!("CARGO_BIN_EXE_hu13")) + .arg("build") + .arg(&repo) + .arg("-o") + .arg(&out) + .output() + .unwrap(); + assert!( + produced.status.success(), + "build: {}", + String::from_utf8_lossy(&produced.stderr) + ); + + let gd = out.join("repo.git"); + assert!(gd.is_dir(), "repo.git emitted"); + + // One pack, zero loose objects, few files (Pages 20k cap comfort). + let packs = std::fs::read_dir(gd.join("objects/pack")) + .map(|rd| { + rd.flatten() + .filter(|e| e.path().extension().is_some_and(|x| x == "pack")) + .count() + }) + .unwrap_or(0); + assert_eq!(packs, 1, "exactly one pack"); + let loose = std::fs::read_dir(gd.join("objects")) + .map(|rd| { + rd.flatten() + .filter(|e| e.path().is_dir()) + .filter(|e| e.file_name().len() == 2) + .map(|e| std::fs::read_dir(e.path()).map(|r| r.count()).unwrap_or(0)) + .sum::() + }) + .unwrap_or(0); + assert_eq!(loose, 0, "no loose objects"); + let files = count_files(&gd); + assert!(files < 40, "dumb repo stays small, got {files} files"); + + // No unreachable/dangling objects: the snapshot is exact. + let fsck = Command::new("git") + .arg("--git-dir") + .arg(&gd) + .args(["fsck", "--unreachable", "--dangling", "--no-progress"]) + .output() + .unwrap(); + let report = String::from_utf8_lossy(&fsck.stdout); + assert!( + report.trim().is_empty(), + "no unreachable/dangling objects, got: {report}" + ); + + // The dangling commit is genuinely absent from the export. + let has = Command::new("git") + .arg("--git-dir") + .arg(&gd) + .args(["cat-file", "-e", &dangling]) + .status() + .unwrap(); + assert!(!has.success(), "dangling commit must not be exported"); + + // No local filesystem path in config. + let config = std::fs::read_to_string(gd.join("config")).unwrap(); + assert!( + !config.contains(&repo.to_string_lossy().to_string()), + "config must not leak the source path: {config}" + ); + assert!(!config.contains("remote"), "no remote section: {config}"); + + // Sample hooks are gone. + let samples = std::fs::read_dir(gd.join("hooks")) + .map(|rd| { + rd.flatten() + .filter(|e| e.path().extension().is_some_and(|x| x == "sample")) + .count() + }) + .unwrap_or(0); + assert_eq!(samples, 0, "no sample hooks"); + + // And it still clones. + let dest = fresh("cloned").join("c"); + let st = Command::new("git") + .arg("clone") + .arg("--quiet") + .arg("-b") + .arg("main") + .arg(&gd) + .arg(&dest) + .status() + .unwrap(); + assert!(st.success(), "clone from exported repo.git works"); + assert!(dest.join("README.md").is_file()); + let log = git(&dest, &["log", "--oneline"]); + assert_eq!(log.lines().count(), 2, "only reachable commits: {log}"); +} -- 2.56.0