diff --git a/.forge/prs/e2792258-fd01-4be6-83ca-c68ef435cfed/patch.diff b/.forge/prs/e2792258-fd01-4be6-83ca-c68ef435cfed/patch.diffnew file mode 100644index 0000000..95d66c1--- /dev/null+++ b/.forge/prs/e2792258-fd01-4be6-83ca-c68ef435cfed/patch.diff@@ -0,0 +1,705 @@1+From acf3c53dca00242c81fe629160f7ca5f4a8fedc8 Mon Sep 17 00:00:00 20012+From: hu13-test <[email protected]>3+Date: Tue, 27 Oct 2026 15:52:32 +00004+Subject: [PATCH] build repo.git from the bundle; strip paths and hooks (#555+ #56 #57 #58 #59)6+7+#55/#56: export now clones from out/repo.bundle instead of the live8+repo. Bundle transport negotiates reachable objects only, so the9+dumb export is the exact snapshot: one pack, zero loose objects, no10+unreachable history. On this repo: 260 files/226 loose -> 10 files/11+1 pack/0 loose, fsck reports nothing, and a dangling commit in the12+source is absent from the clone.13+14+#57: 'git remote remove origin' drops remote.origin.url, so config15+no longer publishes the build machine's path.16+17+#58: run_plain_git deleted; one run_git_opt(repo: Option, timeout,18+display) runner serves both -C and plain git. Paths stay OsStr19+end to end (snapshot bundle/archive and --git-dir no longer20+round-trip through to_string_lossy). Export gets EXPORT_TIMEOUT21+(120s) instead of sharing the 10s metadata budget.22+23+#59: content-type rule anchored to *.git/HEAD and info/* (no more24+matches for any file named 'head'); unset-origin clone block is25+bundle-first with repo.git as the served-directory form; Dockerfile26+and wiki/deploy describe <origin>/repo.git + HU13_PUBLIC_ORIGIN and27+the /raw/ header rule; /dist gitignored.28+29+tests/clone_export.rs asserts pack/loose/file counts, clean fsck,30+no dangling commit, no path in config, no sample hooks, clone works.31+---32+ .forge/wiki/deploy | 28 ++++--33+ .gitignore | 1 +34+ Dockerfile | 4 +-35+ src/build.rs | 18 ++--36+ src/gitx.rs | 213 ++++++++++++++++++++----------------------37+ src/serve.rs | 11 ++-38+ tests/clone_export.rs | 190 +++++++++++++++++++++++++++++++++++++39+ 7 files changed, 328 insertions(+), 137 deletions(-)40+ create mode 100644 tests/clone_export.rs41+42+diff --git a/.forge/wiki/deploy b/.forge/wiki/deploy43+index 15b953e..3e94c60 10064444+--- a/.forge/wiki/deploy45++++ b/.forge/wiki/deploy46+@@ -1,10 +1,5 @@47+ # Deploy hu13 with Docker48+ 49+-> NOTE (m5/docker): this file is `deploy.DOC.md` in the worktree root so the50+-> branch stays free of `.forge/` writes. Supervisor: move it to the wiki51+-> with `f0rge wiki put deploy` (or copy the body to `.forge/wiki/deploy.md`52+-> via the CLI) and delete this file.53+-54+ ## Build the image55+ 56+ From the repo root (build context is sources only — see `.dockerignore`):57+@@ -27,6 +22,7 @@ docker run --rm -p 39313:39313 \58+ -v "$PWD:/data/repo:ro" \59+ -v hu13-inbox:/data/inbox \60+ -e HU13_BRANCH=main \61++ -e HU13_PUBLIC_ORIGIN=http://localhost:39313 \62+ -e HU13_CLONE_URL=https://example.com/mirror.git \63+ hu1364+ ```65+@@ -35,10 +31,13 @@ Then open `http://localhost:39313/`.66+ 67+ What happens inside (`entrypoint.sh`):68+ 69+-1. `hu13 build /data/repo -o /srv/www` (+ `--branch` / `--clone-url`70+- only when `HU13_BRANCH` / `HU13_CLONE_URL` are set). Any failure —71+- bad branch, unreadable repo — aborts LOUD before serving starts72+- (`set -eu`; serve never runs on a stale/empty site).73++1. `hu13 build /data/repo -o /srv/www` (+ `--branch` / `--clone-url` /74++ `--public-origin` only when `HU13_BRANCH` / `HU13_CLONE_URL` /75++ `HU13_PUBLIC_ORIGIN` are set). Any failure — bad branch, unreadable76++ repo — aborts LOUD before serving starts (`set -eu`; serve never runs77++ on a stale/empty site). The build emits `repo.git` (a dumb-HTTP bare78++ repo: `git clone <origin>/repo.git`, no smart server needed),79++ `repo.bundle` and `repo.tar.gz`.80+ 2. `exec hu13 serve --www /srv/www --inbox-dir /data/inbox81+ --listen 0.0.0.0:39313` (+ `--max-title`, `--max-body`,82+ `--max-bytes`, `--rate-per-hour`, `--quota` only when the matching83+@@ -52,7 +51,8 @@ What happens inside (`entrypoint.sh`):84+ | `/data/repo` (ro) | — | repo checkout to render (required) |85+ | `/data/inbox` | — | inbox storage; MUST sit outside `--www` (serve refuses otherwise) |86+ | `HU13_BRANCH` | `main`, else `master` | branch to build; wrong name fails the container |87+-| `HU13_CLONE_URL` | git origin remote | clone URL advertised on the index |88++| `HU13_PUBLIC_ORIGIN` | unset | absolute base URL; enables `git clone <origin>/repo.git` + absolute bundle/tarball links |89++| `HU13_CLONE_URL` | git origin remote | fallback/mirror label shown as "mirror source" |90+ | `HU13_LISTEN` | `0.0.0.0:39313` | serve address |91+ | `HU13_MAX_TITLE` | `300` | inbox title cap (chars) |92+ | `HU13_MAX_BODY` | `20000` | inbox body/prompt cap (chars) |93+@@ -62,6 +62,14 @@ What happens inside (`entrypoint.sh`):94+ 95+ ## Notes96+ 97++- `dist/` in the repo is gitignored; build outside the checkout (or to98++ `/srv/www`) so the derived `repo.git` directory never looks tracked.99++- Static hosting (Cloudflare Pages/nginx): publish the whole `dist/`100++ directory. `dist/repo.git` is plain files + `update-server-info`, and101++ `dist/_headers` carries the `/raw/**` download policy for Pages. On102++ nginx, add the equivalent `add_header Content-Disposition attachment`103++ for `/raw/` — committed HTML/SVG must never render from that path.104++105+ - Binary size: release build is ~2.1MB unstripped, ~1.7MB stripped.106+ If it ever tops 2MB stripped, the cut list is: replace `yaml_serde`107+ (libyaml) with the hand-rolled frontmatter split already used for108+diff --git a/.gitignore b/.gitignore109+index c374b5b..5f196bf 100644110+--- a/.gitignore111++++ b/.gitignore112+@@ -2,3 +2,4 @@113+ .forge/ci/runs/114+ .forge/releases/out/115+ *.tmp116++/dist117+diff --git a/Dockerfile b/Dockerfile118+index ea5b517..6c86577 100644119+--- a/Dockerfile120++++ b/Dockerfile121+@@ -34,7 +34,9 @@ RUN chmod +x /usr/local/bin/entrypoint.sh122+ # Header config defaults (m6, issues #21 + #24): empty means fallback —123+ # repo dir name for the name (never blank), hidden line for the description.124+ # Public origin default (m6, issue #32): empty means unset (bundle-first125+-# presentation; set HU13_PUBLIC_ORIGIN to advertise `<origin>/repo.bundle`).126++# presentation; set HU13_PUBLIC_ORIGIN to advertise `<origin>/repo.git`127++# (`git clone <origin>/repo.git` needs no smart server) and128++# `<origin>/repo.bundle` for the snapshot download).129+ ENV HU13_REPO_NAME=""130+ ENV HU13_DESCRIPTION=""131+ ENV HU13_PUBLIC_ORIGIN=""132+diff --git a/src/build.rs b/src/build.rs133+index 52aeedb..a88adba 100644134+--- a/src/build.rs135++++ b/src/build.rs136+@@ -745,17 +745,21 @@ pub fn clone_block(137+ esc(url)138+ ));139+ }140+- out.push_str("<h3>Clone this mirror</h3>\n");141+- out.push_str(&format!(142+- "<pre class=\"clone-cmd\"><code>git clone repo.git -b {}</code></pre>\n\143+- <p>dumb-HTTP checkout served from this server; ask the host for its address.</p>\n",144+- esc(branch)145+- ));146++ // Unset origin: the bundle is the authoritative, self-contained147++ // clone method (no host address needed). `repo.git` is the same148++ // snapshot as a served directory, mentioned second (f0rge #59).149+ out.push_str("<h3>Clone the bundle</h3>\n");150+ out.push_str(&format!(151+ "<pre class=\"clone-cmd\"><code>git clone repo.bundle -b {}</code></pre>\n<p>{bundle_link}</p>\n",152+ esc(branch)153+ ));154++ out.push_str("<h3>Served as a directory</h3>\n");155++ out.push_str(&format!(156++ "<pre class=\"clone-cmd\"><code>git clone repo.git -b {}</code></pre>\n\157++ <p>the same snapshot as a dumb-HTTP checkout under this site's \158++ web root (<code>/repo.git</code>), no smart server needed.</p>\n",159++ esc(branch)160++ ));161+ out.push_str("<h3>Download the tarball</h3>\n");162+ out.push_str(&format!(163+ "<pre class=\"clone-cmd\"><code>tar -xzf repo.tar.gz</code></pre>\n<p>{tarball_link}</p>\n"164+@@ -1192,7 +1196,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu165+ // rides alongside the bundle/tarball so `git clone <origin>/repo.git`166+ // works on any static server (Pages/nginx/serve), no smart HTTP.167+ let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;168+- gitx::export_dumb_repo(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;169++ gitx::export_dumb_repo(&snap.bundle, &branch, out).map_err(|e| format!("hu13: {e}"))?;170+ let bundle_size = std::fs::metadata(&snap.bundle)171+ .map(|m| m.len())172+ .unwrap_or(0);173+diff --git a/src/gitx.rs b/src/gitx.rs174+index 8d09364..56acaf5 100644175+--- a/src/gitx.rs176++++ b/src/gitx.rs177+@@ -14,9 +14,16 @@ use std::sync::mpsc;178+ use std::thread;179+ use std::time::{Duration, Instant};180+ 181+-/// Timeout for every `git` subprocess.182++/// Timeout for metadata `git` subprocesses.183+ pub const GIT_TIMEOUT: Duration = Duration::from_secs(10);184+ 185++/// Timeout for the dumb-export step (a full clone + repack).186++///187++/// Deliberately separate from [`GIT_TIMEOUT`]: the export reproduces a188++/// whole history, so sharing the 10s metadata budget would fail builds189++/// whose bundle already took seconds (f0rge #58).190++pub const EXPORT_TIMEOUT: Duration = Duration::from_secs(120);191++192+ /// Default commit count for [`log_n`].193+ pub const DEFAULT_LOG_N: usize = 100;194+ 195+@@ -145,12 +152,26 @@ fn cmd_string(repo: &Path, args: &[&OsStr]) -> String {196+ }197+ 198+ fn run_git(repo: &Path, args: &[&OsStr]) -> Result<GitOutput, GitError> {199++ run_git_opt(Some(repo), args, GIT_TIMEOUT, cmd_string(repo, args))200++}201++202++/// Shared runner for `git` subprocesses with a timeout.203++///204++/// `repo` is `None` for commands that must not pass `-C` (the dumb-export205++/// clone/update-server-info pair); `display` is the command label used in206++/// errors. Paths stay `OsStr` end to end (f0rge #58): no lossy UTF-8207++/// round-trip.208++fn run_git_opt(209++ repo: Option<&Path>,210++ args: &[&OsStr],211++ timeout: Duration,212++ display: String,213++) -> Result<GitOutput, GitError> {214+ let mut cmd = Command::new("git");215+- cmd.arg("-C")216+- .arg(repo)217+- .args(args)218+- .stdout(Stdio::piped())219+- .stderr(Stdio::piped());220++ if let Some(repo) = repo {221++ cmd.arg("-C").arg(repo);222++ }223++ cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());224+ let mut child = cmd.spawn().map_err(|e| GitError::Spawn(e.to_string()))?;225+ 226+ let stdout_handle = child.stdout.take();227+@@ -185,7 +206,7 @@ fn run_git(repo: &Path, args: &[&OsStr]) -> Result<GitOutput, GitError> {228+ match child.try_wait() {229+ Err(e) => {230+ return Err(GitError::Failed {231+- cmd: cmd_string(repo, args),232++ cmd: display.clone(),233+ status: "wait-error".to_string(),234+ stderr: e.to_string(),235+ })236+@@ -205,10 +226,10 @@ fn run_git(repo: &Path, args: &[&OsStr]) -> Result<GitOutput, GitError> {237+ });238+ }239+ Ok(None) => {240+- if start.elapsed() >= GIT_TIMEOUT {241++ if start.elapsed() >= timeout {242+ let _ = child.kill();243+ let _ = child.wait();244+- return Err(GitError::Timeout(cmd_string(repo, args)));245++ return Err(GitError::Timeout(display.clone()));246+ }247+ thread::sleep(Duration::from_millis(10));248+ }249+@@ -847,15 +868,13 @@ pub fn snapshot(repo: &Path, branch: &str, out: &Path) -> Result<Snapshot, GitEr250+ let bundle = out.join("repo.bundle");251+ let tarball = out.join("repo.tar.gz");252+ 253+- let bundle_str = bundle.to_string_lossy().into_owned();254+- let os_bundle = OsString::from(&bundle_str);255+ let os_branch = OsString::from(branch);256+ let a_bundle: &OsStr = OsStr::new("bundle");257+ let a_create: &OsStr = OsStr::new("create");258+ let args: Vec<&OsStr> = vec![259+ a_bundle,260+ a_create,261+- os_bundle.as_os_str(),262++ bundle.as_os_str(),263+ os_branch.as_os_str(),264+ ];265+ let bytes = run_git(repo, &args);266+@@ -869,14 +888,14 @@ pub fn snapshot(repo: &Path, branch: &str, out: &Path) -> Result<Snapshot, GitEr267+ });268+ }269+ 270+- let tar_str = tarball.to_string_lossy().into_owned();271+- let os_tar = OsString::from(&tar_str);272+ let os_branch2 = OsString::from(branch);273+ let a_archive: &OsStr = OsStr::new("archive");274+ let a_fmt: &OsStr = OsStr::new("--format=tar.gz");275+- let a_out = OsString::from(format!("-o{tar_str}"));276++ // `git archive -o<path>`: build the flag as an OsString (no lossy277++ // UTF-8 round-trip for non-UTF-8 output paths).278++ let mut a_out = OsString::from("-o");279++ a_out.push(tarball.as_os_str());280+ let args2: Vec<&OsStr> = vec![a_archive, a_fmt, a_out.as_os_str(), os_branch2.as_os_str()];281+- let _ = os_tar;282+ let out2 = run_git(repo, &args2)?;283+ if !out2.status_ok {284+ let stderr = String::from_utf8_lossy(&out2.stderr).trim().to_string();285+@@ -889,88 +908,24 @@ pub fn snapshot(repo: &Path, branch: &str, out: &Path) -> Result<Snapshot, GitEr286+ Ok(Snapshot { bundle, tarball })287+ }288+ 289+-fn run_plain_git(args: &[&OsStr]) -> Result<GitOutput, GitError> {290+- let mut cmd = Command::new("git");291+- cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());292+- let mut child = cmd.spawn().map_err(|e| GitError::Spawn(e.to_string()))?;293+- let stdout_handle = child.stdout.take();294+- let stderr_handle = child.stderr.take();295+- let (tx_out, rx_out) = mpsc::channel::<Vec<u8>>();296+- let (tx_err, rx_err) = mpsc::channel::<Vec<u8>>();297+- if let Some(mut out) = stdout_handle {298+- let tx = tx_out;299+- thread::spawn(move || {300+- let mut buf = Vec::new();301+- let _ = out.read_to_end(&mut buf);302+- let _ = tx.send(buf);303+- });304+- } else {305+- let _ = tx_out.send(Vec::new());306+- }307+- if let Some(mut err) = stderr_handle {308+- let tx = tx_err;309+- thread::spawn(move || {310+- let mut buf = Vec::new();311+- let _ = err.read_to_end(&mut buf);312+- let _ = tx.send(buf);313+- });314+- } else {315+- let _ = tx_err.send(Vec::new());316+- }317+- let start = Instant::now();318+- loop {319+- match child.try_wait() {320+- Err(e) => {321+- return Err(GitError::Failed {322+- cmd: format!(323+- "git {}",324+- args.iter()325+- .map(|a| a.to_string_lossy())326+- .collect::<Vec<_>>()327+- .join(" ")328+- ),329+- status: "wait-error".to_string(),330+- stderr: e.to_string(),331+- });332+- }333+- Ok(Some(status)) => {334+- let stdout = rx_out335+- .recv_timeout(Duration::from_secs(5))336+- .unwrap_or_default();337+- let stderr = rx_err338+- .recv_timeout(Duration::from_secs(5))339+- .unwrap_or_default();340+- return Ok(GitOutput {341+- status_ok: status.success(),342+- status_str: status.to_string(),343+- stdout,344+- stderr,345+- });346+- }347+- Ok(None) => {348+- if start.elapsed() >= GIT_TIMEOUT {349+- let _ = child.kill();350+- let _ = child.wait();351+- return Err(GitError::Timeout(352+- "git clone/update-server-info".to_string(),353+- ));354+- }355+- thread::sleep(Duration::from_millis(10));356+- }357+- }358+- }359+-}360+-361+-/// Export a dumb-HTTP clone dir into `out/repo.git` (issue #54).362++/// Export a dumb-HTTP clone dir into `out/repo.git` (issues #54/#55/#56/#57).363+ ///364+-/// Runs `git clone --bare --single-branch` plus365+-/// `git --git-dir=<dest> update-server-info`, so plain static servers366+-/// (Pages/nginx/`hu13 serve`) all serve `git clone <origin>/repo.git`367+-/// with zero dynamic smart HTTP. No GitHub, no config.368++/// The source is the **bundle** written by [`snapshot`], not the live369++/// repo: bundle transport negotiates reachable objects only, so the370++/// export is exactly the snapshot — one pack, no loose objects, no371++/// unreachable/dangling history leaking out of `refs` (f0rge #55/#56).372++/// A local-path clone (`git clone <repo>`) uses local transport instead373++/// and copies the whole object DB, including unreachable commits.374++///375++/// After cloning we drop `remote.origin` (it held the build machine's376++/// absolute path, f0rge #57), remove the sample hooks (file-count noise),377++/// and run `update-server-info` so plain static servers378++/// (Pages/nginx/`hu13 serve`) can serve `git clone <origin>/repo.git`379++/// with zero dynamic smart HTTP.380+ ///381+ /// Failures are loud (build fails, never a silent missing dir), and an382+ /// existing `out/repo.git` is removed first so stale objects never survive.383+-pub fn export_dumb_repo(repo: &Path, branch: &str, out: &Path) -> Result<PathBuf, GitError> {384++pub fn export_dumb_repo(bundle: &Path, branch: &str, out: &Path) -> Result<PathBuf, GitError> {385+ std::fs::create_dir_all(out)386+ .map_err(|e| GitError::Io(format!("create out dir {}: {e}", out.to_string_lossy())))?;387+ let dest = out.join("repo.git");388+@@ -978,46 +933,76 @@ pub fn export_dumb_repo(repo: &Path, branch: &str, out: &Path) -> Result<PathBuf389+ std::fs::remove_dir_all(&dest)390+ .map_err(|e| GitError::Io(format!("clean {}: {e}", dest.to_string_lossy())))?;391+ }392+- let repo_abs = std::fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());393+- let repo_str = repo_abs.to_string_lossy().into_owned();394+- let dest_str = dest.to_string_lossy().into_owned();395+- let os_repo = OsString::from(&repo_str);396+- let os_dest = OsString::from(&dest_str);397+- let os_branch = OsString::from(branch);398++399++ // Prefer a relative `.git`-free repo: clone with `--no-hardlinks` is400++ // irrelevant for bundles, but `--quiet` keeps build output clean.401+ let a_clone: &OsStr = OsStr::new("clone");402+ let a_bare: &OsStr = OsStr::new("--bare");403++ let a_quiet: &OsStr = OsStr::new("--quiet");404+ let a_single: &OsStr = OsStr::new("--single-branch");405+ let a_branch: &OsStr = OsStr::new("--branch");406+ let a_sep: &OsStr = OsStr::new("--");407+ let args: Vec<&OsStr> = vec![408+ a_clone,409+ a_bare,410++ a_quiet,411+ a_single,412+ a_branch,413+- os_branch.as_os_str(),414++ OsStr::new(branch),415+ a_sep,416+- OsStr::new(os_repo.as_os_str()),417+- os_dest.as_os_str(),418++ bundle.as_os_str(),419++ dest.as_os_str(),420+ ];421+- let out1 = run_plain_git(&args)?;422++ let display =423++ format!("git clone --bare --single-branch --branch {branch} <repo.bundle> repo.git");424++ let out1 = run_git_opt(None, &args, EXPORT_TIMEOUT, display.clone())?;425+ if !out1.status_ok {426+- let stderr = String::from_utf8_lossy(&out1.stderr).trim().to_string();427+ return Err(GitError::Failed {428+- cmd: format!("git clone --bare --single-branch --branch {branch} <repo> repo.git"),429++ cmd: display,430+ status: out1.status_str,431+- stderr,432++ stderr: String::from_utf8_lossy(&out1.stderr).trim().to_string(),433+ });434+ }435+- let git_dir_flag = OsString::from(format!("--git-dir={dest_str}"));436+- let a_usi: &OsStr = OsStr::new("update-server-info");437+- let args2: Vec<&OsStr> = vec![git_dir_flag.as_os_str(), a_usi];438+- let out2 = run_plain_git(&args2)?;439++440++ // `remote.origin.url` would publish the local filesystem path.441++ // Build `--git-dir=<path>` as an OsString: no UTF-8 round-trip.442++ let mut git_dir_arg = OsString::from("--git-dir=");443++ git_dir_arg.push(dest.as_os_str());444++ let a_remote: &OsStr = OsStr::new("remote");445++ let a_remove: &OsStr = OsStr::new("remove");446++ let a_origin: &OsStr = OsStr::new("origin");447++ let args_remote: Vec<&OsStr> = vec![git_dir_arg.as_os_str(), a_remote, a_remove, a_origin];448++ let display_remote = "git --git-dir=<dest> remote remove origin".to_string();449++ let out2 = run_git_opt(None, &args_remote, GIT_TIMEOUT, display_remote.clone())?;450+ if !out2.status_ok {451+- let stderr = String::from_utf8_lossy(&out2.stderr).trim().to_string();452+ return Err(GitError::Failed {453+- cmd: "git --git-dir=<dest> update-server-info".to_string(),454++ cmd: display_remote,455+ status: out2.status_str,456+- stderr,457++ stderr: String::from_utf8_lossy(&out2.stderr).trim().to_string(),458++ });459++ }460++461++ // Sample hooks are noise for a mirror; removing them also keeps the462++ // dumb-HTTP file count down for the Pages 20k-file cap.463++ let hooks = dest.join("hooks");464++ if let Ok(rd) = std::fs::read_dir(&hooks) {465++ for ent in rd.flatten() {466++ let path = ent.path();467++ if path.extension().is_some_and(|e| e == "sample") {468++ let _ = std::fs::remove_file(path);469++ }470++ }471++ }472++473++ let a_usi: &OsStr = OsStr::new("update-server-info");474++ let args2: Vec<&OsStr> = vec![git_dir_arg.as_os_str(), a_usi];475++ let display_usi = "git --git-dir=<dest> update-server-info".to_string();476++ let out3 = run_git_opt(None, &args2, EXPORT_TIMEOUT, display_usi.clone())?;477++ if !out3.status_ok {478++ return Err(GitError::Failed {479++ cmd: display_usi,480++ status: out3.status_str,481++ stderr: String::from_utf8_lossy(&out3.stderr).trim().to_string(),482+ });483+ }484+ Ok(dest)485+diff --git a/src/serve.rs b/src/serve.rs486+index dc71347..12718d1 100644487+--- a/src/serve.rs488++++ b/src/serve.rs489+@@ -459,11 +459,12 @@ fn content_type_for(name: &str) -> &'static str {490+ "application/atom+xml; charset=utf-8"491+ } else if lower.ends_with(".md") || lower.ends_with(".txt") {492+ "text/plain; charset=utf-8"493+- } else if lower.ends_with("/head")494+- || lower.ends_with("/info/refs")495+- || lower.ends_with("/info/packs")496+- || lower.ends_with("/info/alternates")497+- || lower.ends_with("packed-refs")498++ } else if lower.ends_with(".git/head")499++ || (lower.contains(".git/")500++ && (lower.ends_with("/info/refs")501++ || lower.ends_with("/info/packs")502++ || lower.ends_with("/info/alternates")503++ || lower.ends_with("/packed-refs")))504+ {505+ // Dumb-HTTP git files (#54): plain text so any static server506+ // (Pages/nginx/serve) serves `repo.git` identically. Git ignores507+diff --git a/tests/clone_export.rs b/tests/clone_export.rs508+new file mode 100644509+index 0000000..6c95ec5510+--- /dev/null511++++ b/tests/clone_export.rs512+@@ -0,0 +1,190 @@513++//! f0rge #55/#56/#57: `dist/repo.git` is the snapshot, not the object DB.514++//!515++//! A local-path clone copies every object (including unreachable history)516++//! and leaves one loose object per blob; building from `repo.bundle`517++//! negotiates reachable objects only, giving one pack and no dangling518++//! commits. The export must also not publish the build machine's path.519++520++use std::path::{Path, PathBuf};521++use std::process::Command;522++523++fn fresh(prefix: &str) -> PathBuf {524++ let dir = std::env::temp_dir().join(format!(525++ "hu13-export-{prefix}-{}-{}",526++ std::process::id(),527++ std::time::SystemTime::now()528++ .duration_since(std::time::UNIX_EPOCH)529++ .map(|d| d.as_nanos())530++ .unwrap_or(0)531++ ));532++ std::fs::create_dir_all(&dir).unwrap();533++ dir534++}535++536++fn git(repo: &Path, args: &[&str]) -> String {537++ let out = Command::new("git")538++ .arg("-C")539++ .arg(repo)540++ .args(args)541++ .output()542++ .expect("spawn git");543++ assert!(544++ out.status.success(),545++ "git {args:?}: {}",546++ String::from_utf8_lossy(&out.stderr)547++ );548++ String::from_utf8_lossy(&out.stdout).trim().to_string()549++}550++551++fn count_files(dir: &Path) -> usize {552++ let mut n = 0;553++ let mut stack = vec![dir.to_path_buf()];554++ while let Some(d) = stack.pop() {555++ if let Ok(rd) = std::fs::read_dir(&d) {556++ for e in rd.flatten() {557++ let p = e.path();558++ if p.is_dir() {559++ stack.push(p);560++ } else {561++ n += 1;562++ }563++ }564++ }565++ }566++ n567++}568++569++#[test]570++fn export_is_exact_snapshot_single_pack_no_paths() {571++ let repo = fresh("repo");572++ let out = fresh("out");573++ for args in [574++ ["init", "-b", "main"].as_slice(),575++ &["config", "user.name", "hu13-test"],576++ &["config", "user.email", "[email protected]"],577++ &["config", "commit.gpgsign", "false"],578++ ] {579++ let st = Command::new("git")580++ .arg("-C")581++ .arg(&repo)582++ .args(args)583++ .status()584++ .unwrap();585++ assert!(st.success());586++ }587++ std::fs::write(repo.join("README.md"), "# export\n").unwrap();588++ std::fs::write(repo.join("a.txt"), "one\n").unwrap();589++ git(&repo, &["add", "-A"]);590++ git(&repo, &["commit", "-m", "one"]);591++ std::fs::write(repo.join("b.txt"), "two\n").unwrap();592++ git(&repo, &["add", "-A"]);593++ git(&repo, &["commit", "-m", "two"]);594++595++ // Unreachable history: commit on a detached HEAD, then move away.596++ // This is the #55 leak: local-transport clones carry it, bundles don't.597++ let base = git(&repo, &["rev-parse", "HEAD"]);598++ git(&repo, &["checkout", "-q", "--detach"]);599++ std::fs::write(repo.join("secret.txt"), "unreachable secret\n").unwrap();600++ git(&repo, &["add", "-A"]);601++ git(&repo, &["commit", "-q", "-m", "dangling secret"]);602++ let dangling = git(&repo, &["rev-parse", "HEAD"]);603++ git(&repo, &["checkout", "-q", "main"]);604++ std::fs::write(repo.join("secret.txt"), "").ok();605++ let _ = std::fs::remove_file(repo.join("secret.txt"));606++ git(&repo, &["reset", "-q", "--hard", &base]);607++ assert_ne!(dangling, base);608++609++ let produced = Command::new(env!("CARGO_BIN_EXE_hu13"))610++ .arg("build")611++ .arg(&repo)612++ .arg("-o")613++ .arg(&out)614++ .output()615++ .unwrap();616++ assert!(617++ produced.status.success(),618++ "build: {}",619++ String::from_utf8_lossy(&produced.stderr)620++ );621++622++ let gd = out.join("repo.git");623++ assert!(gd.is_dir(), "repo.git emitted");624++625++ // One pack, zero loose objects, few files (Pages 20k cap comfort).626++ let packs = std::fs::read_dir(gd.join("objects/pack"))627++ .map(|rd| {628++ rd.flatten()629++ .filter(|e| e.path().extension().is_some_and(|x| x == "pack"))630++ .count()631++ })632++ .unwrap_or(0);633++ assert_eq!(packs, 1, "exactly one pack");634++ let loose = std::fs::read_dir(gd.join("objects"))635++ .map(|rd| {636++ rd.flatten()637++ .filter(|e| e.path().is_dir())638++ .filter(|e| e.file_name().len() == 2)639++ .map(|e| std::fs::read_dir(e.path()).map(|r| r.count()).unwrap_or(0))640++ .sum::<usize>()641++ })642++ .unwrap_or(0);643++ assert_eq!(loose, 0, "no loose objects");644++ let files = count_files(&gd);645++ assert!(files < 40, "dumb repo stays small, got {files} files");646++647++ // No unreachable/dangling objects: the snapshot is exact.648++ let fsck = Command::new("git")649++ .arg("--git-dir")650++ .arg(&gd)651++ .args(["fsck", "--unreachable", "--dangling", "--no-progress"])652++ .output()653++ .unwrap();654++ let report = String::from_utf8_lossy(&fsck.stdout);655++ assert!(656++ report.trim().is_empty(),657++ "no unreachable/dangling objects, got: {report}"658++ );659++660++ // The dangling commit is genuinely absent from the export.661++ let has = Command::new("git")662++ .arg("--git-dir")663++ .arg(&gd)664++ .args(["cat-file", "-e", &dangling])665++ .status()666++ .unwrap();667++ assert!(!has.success(), "dangling commit must not be exported");668++669++ // No local filesystem path in config.670++ let config = std::fs::read_to_string(gd.join("config")).unwrap();671++ assert!(672++ !config.contains(&repo.to_string_lossy().to_string()),673++ "config must not leak the source path: {config}"674++ );675++ assert!(!config.contains("remote"), "no remote section: {config}");676++677++ // Sample hooks are gone.678++ let samples = std::fs::read_dir(gd.join("hooks"))679++ .map(|rd| {680++ rd.flatten()681++ .filter(|e| e.path().extension().is_some_and(|x| x == "sample"))682++ .count()683++ })684++ .unwrap_or(0);685++ assert_eq!(samples, 0, "no sample hooks");686++687++ // And it still clones.688++ let dest = fresh("cloned").join("c");689++ let st = Command::new("git")690++ .arg("clone")691++ .arg("--quiet")692++ .arg("-b")693++ .arg("main")694++ .arg(&gd)695++ .arg(&dest)696++ .status()697++ .unwrap();698++ assert!(st.success(), "clone from exported repo.git works");699++ assert!(dest.join("README.md").is_file());700++ let log = git(&dest, &["log", "--oneline"]);701++ assert_eq!(log.lines().count(), 2, "only reachable commits: {log}");702++}703+-- 704+2.56.0705+