merge #55 #56 #57 #58 #59 clone export + close

c7fddefgl0w

all commits

Message

merge #55 #56 #57 #58 #59 clone export + close

Diff

.forge/issues/4491edd4-3697-437c-9ff7-98235bf1c743/comments/12feba5e-af45-4002-a314-f78854167812.md+6 −0
diff --git a/.forge/issues/4491edd4-3697-437c-9ff7-98235bf1c743/comments/12feba5e-af45-4002-a314-f78854167812.md b/.forge/issues/4491edd4-3697-437c-9ff7-98235bf1c743/comments/12feba5e-af45-4002-a314-f78854167812.mdnew file mode 100644index 0000000..238ec39--- /dev/null+++ b/.forge/issues/4491edd4-3697-437c-9ff7-98235bf1c743/comments/12feba5e-af45-4002-a314-f78854167812.md@@ -0,0 +1,6 @@1+---2+id: 12feba5e-af45-4002-a314-f788541678123+author: null4+created: 2026-10-27T15:53:18.553596374Z5+---6+Fixed in e2792258-fd01-4be6-83ca-c68ef435cfed: export now clones from repo.bundle (one pack, no loose/dangling objects), remote.origin removed, sample hooks removed, single run_git_opt runner with OsStr paths + 120s export budget, content-type anchored to *.git/HEAD+info/*, docs updated. tests/clone_export.rs asserts all of it.
.forge/issues/4491edd4-3697-437c-9ff7-98235bf1c743/issue.md+2 −2
diff --git a/.forge/issues/4491edd4-3697-437c-9ff7-98235bf1c743/issue.md b/.forge/issues/4491edd4-3697-437c-9ff7-98235bf1c743/issue.mdindex ff9c43e..36c5005 100644--- a/.forge/issues/4491edd4-3697-437c-9ff7-98235bf1c743/issue.md+++ b/.forge/issues/4491edd4-3697-437c-9ff7-98235bf1c743/issue.md@@ -1,13 +1,13 @@11 ---22 id: 4491edd4-3697-437c-9ff7-98235bf1c74333 title: repo.git is 260 files / 226 loose objects (Pages 20k-file cap, dumb clone = 1 GET per object)4-state: open4+state: closed55 labels:66 - perf77 assignees: []88 milestone: null99 created: 2026-10-27T14:47:44.637520264Z10-updated: null10+updated: 2026-10-27T15:53:18.544645290Z1111 ---1212 Follow-up to #55 (same root cause, different symptom). The export path produces a bloated multi-file artifact.1313 
.forge/issues/462c4759-677d-49bb-be01-cf267dbbd106/comments/12fdc255-7bce-4492-b494-5f8e7fb61d09.md+6 −0
diff --git a/.forge/issues/462c4759-677d-49bb-be01-cf267dbbd106/comments/12fdc255-7bce-4492-b494-5f8e7fb61d09.md b/.forge/issues/462c4759-677d-49bb-be01-cf267dbbd106/comments/12fdc255-7bce-4492-b494-5f8e7fb61d09.mdnew file mode 100644index 0000000..fcaa359--- /dev/null+++ b/.forge/issues/462c4759-677d-49bb-be01-cf267dbbd106/comments/12fdc255-7bce-4492-b494-5f8e7fb61d09.md@@ -0,0 +1,6 @@1+---2+id: 12fdc255-7bce-4492-b494-5f8e7fb61d093+author: null4+created: 2026-10-27T15:53:18.842756783Z5+---6+Fixed in e2792258-fd01-4be6-83ca-c68ef435cfed: export now clones from repo.bundle (one pack, no loose/dangling objects), remote.origin removed, sample hooks removed, single run_git_opt runner with OsStr paths + 120s export budget, content-type anchored to *.git/HEAD+info/*, docs updated. tests/clone_export.rs asserts all of it.
.forge/issues/462c4759-677d-49bb-be01-cf267dbbd106/issue.md+2 −2
diff --git a/.forge/issues/462c4759-677d-49bb-be01-cf267dbbd106/issue.md b/.forge/issues/462c4759-677d-49bb-be01-cf267dbbd106/issue.mdindex 37b39d2..53ecdde 100644--- a/.forge/issues/462c4759-677d-49bb-be01-cf267dbbd106/issue.md+++ b/.forge/issues/462c4759-677d-49bb-be01-cf267dbbd106/issue.md@@ -1,13 +1,13 @@11 ---22 id: 462c4759-677d-49bb-be01-cf267dbbd10633 title: 'Clone docs + repo hygiene after #54 (Dockerfile, wiki/deploy, .gitignore, unset-mode UX)'4-state: open4+state: closed55 labels:66 - docs77 assignees: []88 milestone: null99 created: 2026-10-27T14:48:05.298412934Z10-updated: null10+updated: 2026-10-27T15:53:18.825774275Z1111 ---1212 Small leftovers from the #54 review — docs and repo hygiene.1313 
.forge/issues/50c21acd-1a7e-4e98-84e9-f1d07e647641/comments/737364d0-e1e8-4f0c-a8e1-cc0e3f4aeae9.md+6 −0
diff --git a/.forge/issues/50c21acd-1a7e-4e98-84e9-f1d07e647641/comments/737364d0-e1e8-4f0c-a8e1-cc0e3f4aeae9.md b/.forge/issues/50c21acd-1a7e-4e98-84e9-f1d07e647641/comments/737364d0-e1e8-4f0c-a8e1-cc0e3f4aeae9.mdnew file mode 100644index 0000000..b05e040--- /dev/null+++ b/.forge/issues/50c21acd-1a7e-4e98-84e9-f1d07e647641/comments/737364d0-e1e8-4f0c-a8e1-cc0e3f4aeae9.md@@ -0,0 +1,6 @@1+---2+id: 737364d0-e1e8-4f0c-a8e1-cc0e3f4aeae93+author: null4+created: 2026-10-27T15:53:18.471014249Z5+---6+Fixed in e2792258-fd01-4be6-83ca-c68ef435cfed: export now clones from repo.bundle (one pack, no loose/dangling objects), remote.origin removed, sample hooks removed, single run_git_opt runner with OsStr paths + 120s export budget, content-type anchored to *.git/HEAD+info/*, docs updated. tests/clone_export.rs asserts all of it.
.forge/issues/50c21acd-1a7e-4e98-84e9-f1d07e647641/issue.md+2 −2
diff --git a/.forge/issues/50c21acd-1a7e-4e98-84e9-f1d07e647641/issue.md b/.forge/issues/50c21acd-1a7e-4e98-84e9-f1d07e647641/issue.mdindex cd965c1..7b82da1 100644--- a/.forge/issues/50c21acd-1a7e-4e98-84e9-f1d07e647641/issue.md+++ b/.forge/issues/50c21acd-1a7e-4e98-84e9-f1d07e647641/issue.md@@ -1,13 +1,13 @@11 ---22 id: 50c21acd-1a7e-4e98-84e9-f1d07e64764133 title: repo.git leaks unreachable objects (not an exact snapshot)4-state: open4+state: closed55 labels:66 - security77 assignees: []88 milestone: null99 created: 2026-10-27T14:47:37.930020568Z10-updated: null10+updated: 2026-10-27T15:53:18.448696060Z1111 ---1212 Adversarial review of #54 found a real information leak: `dist/repo.git` is not the snapshot it claims to be.1313 
.forge/issues/ebb9556e-9e38-4ba0-8081-2b71e45cd51e/comments/aed597ca-89d5-47af-9aea-9bedc9dbb2dd.md+6 −0
diff --git a/.forge/issues/ebb9556e-9e38-4ba0-8081-2b71e45cd51e/comments/aed597ca-89d5-47af-9aea-9bedc9dbb2dd.md b/.forge/issues/ebb9556e-9e38-4ba0-8081-2b71e45cd51e/comments/aed597ca-89d5-47af-9aea-9bedc9dbb2dd.mdnew file mode 100644index 0000000..7f45c06--- /dev/null+++ b/.forge/issues/ebb9556e-9e38-4ba0-8081-2b71e45cd51e/comments/aed597ca-89d5-47af-9aea-9bedc9dbb2dd.md@@ -0,0 +1,6 @@1+---2+id: aed597ca-89d5-47af-9aea-9bedc9dbb2dd3+author: null4+created: 2026-10-27T15:53:18.651730345Z5+---6+Fixed in e2792258-fd01-4be6-83ca-c68ef435cfed: export now clones from repo.bundle (one pack, no loose/dangling objects), remote.origin removed, sample hooks removed, single run_git_opt runner with OsStr paths + 120s export budget, content-type anchored to *.git/HEAD+info/*, docs updated. tests/clone_export.rs asserts all of it.
.forge/issues/ebb9556e-9e38-4ba0-8081-2b71e45cd51e/issue.md+2 −2
diff --git a/.forge/issues/ebb9556e-9e38-4ba0-8081-2b71e45cd51e/issue.md b/.forge/issues/ebb9556e-9e38-4ba0-8081-2b71e45cd51e/issue.mdindex dd0a1e4..5ee532e 100644--- a/.forge/issues/ebb9556e-9e38-4ba0-8081-2b71e45cd51e/issue.md+++ b/.forge/issues/ebb9556e-9e38-4ba0-8081-2b71e45cd51e/issue.md@@ -1,13 +1,13 @@11 ---22 id: ebb9556e-9e38-4ba0-8081-2b71e45cd51e33 title: repo.git publishes local filesystem path in config4-state: open4+state: closed55 labels:66 - security77 assignees: []88 milestone: null99 created: 2026-10-27T14:47:50.464582699Z10-updated: null10+updated: 2026-10-27T15:53:18.628243968Z1111 ---1212 `dist/repo.git/config` is published and contains the builder's absolute filesystem path.1313 
.forge/issues/f16cf2b4-6ebf-4fbf-b78f-2550f44bd76b/comments/65edeaa0-30d3-4687-8f21-6f6aa11d434d.md+6 −0
diff --git a/.forge/issues/f16cf2b4-6ebf-4fbf-b78f-2550f44bd76b/comments/65edeaa0-30d3-4687-8f21-6f6aa11d434d.md b/.forge/issues/f16cf2b4-6ebf-4fbf-b78f-2550f44bd76b/comments/65edeaa0-30d3-4687-8f21-6f6aa11d434d.mdnew file mode 100644index 0000000..cae6fe2--- /dev/null+++ b/.forge/issues/f16cf2b4-6ebf-4fbf-b78f-2550f44bd76b/comments/65edeaa0-30d3-4687-8f21-6f6aa11d434d.md@@ -0,0 +1,6 @@1+---2+id: 65edeaa0-30d3-4687-8f21-6f6aa11d434d3+author: null4+created: 2026-10-27T15:53:18.748496001Z5+---6+Fixed in e2792258-fd01-4be6-83ca-c68ef435cfed: export now clones from repo.bundle (one pack, no loose/dangling objects), remote.origin removed, sample hooks removed, single run_git_opt runner with OsStr paths + 120s export budget, content-type anchored to *.git/HEAD+info/*, docs updated. tests/clone_export.rs asserts all of it.
.forge/issues/f16cf2b4-6ebf-4fbf-b78f-2550f44bd76b/issue.md+2 −2
diff --git a/.forge/issues/f16cf2b4-6ebf-4fbf-b78f-2550f44bd76b/issue.md b/.forge/issues/f16cf2b4-6ebf-4fbf-b78f-2550f44bd76b/issue.mdindex d4780a9..1b920e5 100644--- a/.forge/issues/f16cf2b4-6ebf-4fbf-b78f-2550f44bd76b/issue.md+++ b/.forge/issues/f16cf2b4-6ebf-4fbf-b78f-2550f44bd76b/issue.md@@ -1,13 +1,13 @@11 ---22 id: f16cf2b4-6ebf-4fbf-b78f-2550f44bd76b33 title: Export duplicates the git runner, shares the 10s timeout, and round-trips paths through UTF-84-state: open4+state: closed55 labels:66 - refactor77 assignees: []88 milestone: null99 created: 2026-10-27T14:47:57.173716686Z10-updated: null10+updated: 2026-10-27T15:53:18.730189081Z1111 ---1212 `export_dumb_repo` re-implements the git subprocess plumbing (~75 lines) instead of reusing `run_git`, and runs the export under the same 10 s `GIT_TIMEOUT`.1313 
.forge/merges/2026-10-27T15-53-18.340961332Z.md+9 −0
diff --git a/.forge/merges/2026-10-27T15-53-18.340961332Z.md b/.forge/merges/2026-10-27T15-53-18.340961332Z.mdnew file mode 100644index 0000000..7a7065c--- /dev/null+++ b/.forge/merges/2026-10-27T15-53-18.340961332Z.md@@ -0,0 +1,9 @@1+---2+pr: e2792258-fd01-4be6-83ca-c68ef435cfed3+applied: true4+verdict: pass5+resolved: []6+created: 2026-10-27T15:53:18.340961332Z7+---8+clean apply9+patch: 3d1c1eff77fe91d16eb4612e6bfdc71b8f24bd37
.forge/prs/e2792258-fd01-4be6-83ca-c68ef435cfed/patch.diff+705 −0
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+
.forge/prs/e2792258-fd01-4be6-83ca-c68ef435cfed/pr.md+13 −0
diff --git a/.forge/prs/e2792258-fd01-4be6-83ca-c68ef435cfed/pr.md b/.forge/prs/e2792258-fd01-4be6-83ca-c68ef435cfed/pr.mdnew file mode 100644index 0000000..feec1e7--- /dev/null+++ b/.forge/prs/e2792258-fd01-4be6-83ca-c68ef435cfed/pr.md@@ -0,0 +1,13 @@1+---2+id: e2792258-fd01-4be6-83ca-c68ef435cfed3+title: 'Build repo.git from the bundle; strip paths and hooks (#55 #56 #57 #58 #59)'4+state: merged5+base: master6+head: issue-55-clone-export7+draft: false8+issues:9+- 50c21acd-1a7e-4e98-84e9-f1d07e64764110+ci: pass11+created: 2026-10-27T15:52:32.401304195Z12+updated: 2026-10-27T15:53:18.340961332Z13+---