1 From ab1398c19bf7c626ec81a11eef52ad666643f774 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