#51 Build repo.git from the bundle; strip paths and hooks (#55 #56 #57 #58 #59)

mergedpassopened
details
branches
master ← issue-55-clone-export
issues
50c21acd-1a7e-4e98-84e9-f1d07e647641
updated
id
e2792258-fd01-4be6-83ca-c68ef435cfed

Patch

.forge/wiki/deploy+18 −10
diff --git a/.forge/wiki/deploy b/.forge/wiki/deployindex 15b953e..3e94c60 100644--- a/.forge/wiki/deploy+++ b/.forge/wiki/deploy@@ -1,10 +1,5 @@11 # Deploy hu13 with Docker22 3-> NOTE (m5/docker): this file is `deploy.DOC.md` in the worktree root so the4-> branch stays free of `.forge/` writes. Supervisor: move it to the wiki5-> with `f0rge wiki put deploy` (or copy the body to `.forge/wiki/deploy.md`6-> via the CLI) and delete this file.7-83 ## Build the image94 105 From the repo root (build context is sources only — see `.dockerignore`):@@ -27,6 +22,7 @@ docker run --rm -p 39313:39313 \2722   -v "$PWD:/data/repo:ro" \2823   -v hu13-inbox:/data/inbox \2924   -e HU13_BRANCH=main \25+  -e HU13_PUBLIC_ORIGIN=http://localhost:39313 \3026   -e HU13_CLONE_URL=https://example.com/mirror.git \3127   hu133228 ```@@ -35,10 +31,13 @@ Then open `http://localhost:39313/`.3531 3632 What happens inside (`entrypoint.sh`):3733 38-1. `hu13 build /data/repo -o /srv/www` (+ `--branch` / `--clone-url`39-   only when `HU13_BRANCH` / `HU13_CLONE_URL` are set). Any failure —40-   bad branch, unreadable repo — aborts LOUD before serving starts41-   (`set -eu`; serve never runs on a stale/empty site).34+1. `hu13 build /data/repo -o /srv/www` (+ `--branch` / `--clone-url` /35+   `--public-origin` only when `HU13_BRANCH` / `HU13_CLONE_URL` /36+   `HU13_PUBLIC_ORIGIN` are set). Any failure — bad branch, unreadable37+   repo — aborts LOUD before serving starts (`set -eu`; serve never runs38+   on a stale/empty site). The build emits `repo.git` (a dumb-HTTP bare39+   repo: `git clone <origin>/repo.git`, no smart server needed),40+   `repo.bundle` and `repo.tar.gz`.4241 2. `exec hu13 serve --www /srv/www --inbox-dir /data/inbox4342    --listen 0.0.0.0:39313` (+ `--max-title`, `--max-body`,4443    `--max-bytes`, `--rate-per-hour`, `--quota` only when the matching@@ -52,7 +51,8 @@ What happens inside (`entrypoint.sh`):5251 | `/data/repo` (ro)      | —                  | repo checkout to render (required)                 |5352 | `/data/inbox`          | —                  | inbox storage; MUST sit outside `--www` (serve refuses otherwise) |5453 | `HU13_BRANCH`          | `main`, else `master` | branch to build; wrong name fails the container |55-| `HU13_CLONE_URL`       | git origin remote  | clone URL advertised on the index                  |54+| `HU13_PUBLIC_ORIGIN`   | unset              | absolute base URL; enables `git clone <origin>/repo.git` + absolute bundle/tarball links |55+| `HU13_CLONE_URL`       | git origin remote  | fallback/mirror label shown as "mirror source"     |5656 | `HU13_LISTEN`          | `0.0.0.0:39313`    | serve address                                      |5757 | `HU13_MAX_TITLE`       | `300`              | inbox title cap (chars)                            |5858 | `HU13_MAX_BODY`        | `20000`            | inbox body/prompt cap (chars)                      |@@ -62,6 +62,14 @@ What happens inside (`entrypoint.sh`):6262 6363 ## Notes6464 65+- `dist/` in the repo is gitignored; build outside the checkout (or to66+  `/srv/www`) so the derived `repo.git` directory never looks tracked.67+- Static hosting (Cloudflare Pages/nginx): publish the whole `dist/`68+  directory. `dist/repo.git` is plain files + `update-server-info`, and69+  `dist/_headers` carries the `/raw/**` download policy for Pages. On70+  nginx, add the equivalent `add_header Content-Disposition attachment`71+  for `/raw/` — committed HTML/SVG must never render from that path.72+6573 - Binary size: release build is ~2.1MB unstripped, ~1.7MB stripped.6674   If it ever tops 2MB stripped, the cut list is: replace `yaml_serde`6775   (libyaml) with the hand-rolled frontmatter split already used for
.gitignore+1 −0
diff --git a/.gitignore b/.gitignoreindex c374b5b..5f196bf 100644--- a/.gitignore+++ b/.gitignore@@ -2,3 +2,4 @@22 .forge/ci/runs/33 .forge/releases/out/44 *.tmp5+/dist
Dockerfile+3 −1
diff --git a/Dockerfile b/Dockerfileindex ea5b517..6c86577 100644--- a/Dockerfile+++ b/Dockerfile@@ -34,7 +34,9 @@ RUN chmod +x /usr/local/bin/entrypoint.sh3434 # Header config defaults (m6, issues #21 + #24): empty means fallback —3535 # repo dir name for the name (never blank), hidden line for the description.3636 # Public origin default (m6, issue #32): empty means unset (bundle-first37-# presentation; set HU13_PUBLIC_ORIGIN to advertise `<origin>/repo.bundle`).37+# presentation; set HU13_PUBLIC_ORIGIN to advertise `<origin>/repo.git`38+# (`git clone <origin>/repo.git` needs no smart server) and39+# `<origin>/repo.bundle` for the snapshot download).3840 ENV HU13_REPO_NAME=""3941 ENV HU13_DESCRIPTION=""4042 ENV HU13_PUBLIC_ORIGIN=""
src/build.rs+11 −7
diff --git a/src/build.rs b/src/build.rsindex 52aeedb..a88adba 100644--- a/src/build.rs+++ b/src/build.rs@@ -745,17 +745,21 @@ pub fn clone_block(745745             esc(url)746746         ));747747     }748-    out.push_str("<h3>Clone this mirror</h3>\n");749-    out.push_str(&format!(750-        "<pre class=\"clone-cmd\"><code>git clone repo.git -b {}</code></pre>\n\751-         <p>dumb-HTTP checkout served from this server; ask the host for its address.</p>\n",752-        esc(branch)753-    ));748+    // Unset origin: the bundle is the authoritative, self-contained749+    // clone method (no host address needed). `repo.git` is the same750+    // snapshot as a served directory, mentioned second (f0rge #59).754751     out.push_str("<h3>Clone the bundle</h3>\n");755752     out.push_str(&format!(756753         "<pre class=\"clone-cmd\"><code>git clone repo.bundle -b {}</code></pre>\n<p>{bundle_link}</p>\n",757754         esc(branch)758755     ));756+    out.push_str("<h3>Served as a directory</h3>\n");757+    out.push_str(&format!(758+        "<pre class=\"clone-cmd\"><code>git clone repo.git -b {}</code></pre>\n\759+         <p>the same snapshot as a dumb-HTTP checkout under this site's \760+         web root (<code>/repo.git</code>), no smart server needed.</p>\n",761+        esc(branch)762+    ));759763     out.push_str("<h3>Download the tarball</h3>\n");760764     out.push_str(&format!(761765         "<pre class=\"clone-cmd\"><code>tar -xzf repo.tar.gz</code></pre>\n<p>{tarball_link}</p>\n"@@ -1192,7 +1196,7 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu11921196     // rides alongside the bundle/tarball so `git clone <origin>/repo.git`11931197     // works on any static server (Pages/nginx/serve), no smart HTTP.11941198     let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;1195-    gitx::export_dumb_repo(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;1199+    gitx::export_dumb_repo(&snap.bundle, &branch, out).map_err(|e| format!("hu13: {e}"))?;11961200     let bundle_size = std::fs::metadata(&snap.bundle)11971201         .map(|m| m.len())11981202         .unwrap_or(0);
src/gitx.rs+99 −114
diff --git a/src/gitx.rs b/src/gitx.rsindex 8d09364..56acaf5 100644--- a/src/gitx.rs+++ b/src/gitx.rs@@ -14,9 +14,16 @@ use std::sync::mpsc;1414 use std::thread;1515 use std::time::{Duration, Instant};1616 17-/// Timeout for every `git` subprocess.17+/// Timeout for metadata `git` subprocesses.1818 pub const GIT_TIMEOUT: Duration = Duration::from_secs(10);1919 20+/// Timeout for the dumb-export step (a full clone + repack).21+///22+/// Deliberately separate from [`GIT_TIMEOUT`]: the export reproduces a23+/// whole history, so sharing the 10s metadata budget would fail builds24+/// whose bundle already took seconds (f0rge #58).25+pub const EXPORT_TIMEOUT: Duration = Duration::from_secs(120);26+2027 /// Default commit count for [`log_n`].2128 pub const DEFAULT_LOG_N: usize = 100;2229 @@ -145,12 +152,26 @@ fn cmd_string(repo: &Path, args: &[&OsStr]) -> String {145152 }146153 147154 fn run_git(repo: &Path, args: &[&OsStr]) -> Result<GitOutput, GitError> {155+    run_git_opt(Some(repo), args, GIT_TIMEOUT, cmd_string(repo, args))156+}157+158+/// Shared runner for `git` subprocesses with a timeout.159+///160+/// `repo` is `None` for commands that must not pass `-C` (the dumb-export161+/// clone/update-server-info pair); `display` is the command label used in162+/// errors. Paths stay `OsStr` end to end (f0rge #58): no lossy UTF-8163+/// round-trip.164+fn run_git_opt(165+    repo: Option<&Path>,166+    args: &[&OsStr],167+    timeout: Duration,168+    display: String,169+) -> Result<GitOutput, GitError> {148170     let mut cmd = Command::new("git");149-    cmd.arg("-C")150-        .arg(repo)151-        .args(args)152-        .stdout(Stdio::piped())153-        .stderr(Stdio::piped());171+    if let Some(repo) = repo {172+        cmd.arg("-C").arg(repo);173+    }174+    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());154175     let mut child = cmd.spawn().map_err(|e| GitError::Spawn(e.to_string()))?;155176 156177     let stdout_handle = child.stdout.take();@@ -185,7 +206,7 @@ fn run_git(repo: &Path, args: &[&OsStr]) -> Result<GitOutput, GitError> {185206         match child.try_wait() {186207             Err(e) => {187208                 return Err(GitError::Failed {188-                    cmd: cmd_string(repo, args),209+                    cmd: display.clone(),189210                     status: "wait-error".to_string(),190211                     stderr: e.to_string(),191212                 })@@ -205,10 +226,10 @@ fn run_git(repo: &Path, args: &[&OsStr]) -> Result<GitOutput, GitError> {205226                 });206227             }207228             Ok(None) => {208-                if start.elapsed() >= GIT_TIMEOUT {229+                if start.elapsed() >= timeout {209230                     let _ = child.kill();210231                     let _ = child.wait();211-                    return Err(GitError::Timeout(cmd_string(repo, args)));232+                    return Err(GitError::Timeout(display.clone()));212233                 }213234                 thread::sleep(Duration::from_millis(10));214235             }@@ -847,15 +868,13 @@ pub fn snapshot(repo: &Path, branch: &str, out: &Path) -> Result<Snapshot, GitEr847868     let bundle = out.join("repo.bundle");848869     let tarball = out.join("repo.tar.gz");849870 850-    let bundle_str = bundle.to_string_lossy().into_owned();851-    let os_bundle = OsString::from(&bundle_str);852871     let os_branch = OsString::from(branch);853872     let a_bundle: &OsStr = OsStr::new("bundle");854873     let a_create: &OsStr = OsStr::new("create");855874     let args: Vec<&OsStr> = vec![856875         a_bundle,857876         a_create,858-        os_bundle.as_os_str(),877+        bundle.as_os_str(),859878         os_branch.as_os_str(),860879     ];861880     let bytes = run_git(repo, &args);@@ -869,14 +888,14 @@ pub fn snapshot(repo: &Path, branch: &str, out: &Path) -> Result<Snapshot, GitEr869888         });870889     }871890 872-    let tar_str = tarball.to_string_lossy().into_owned();873-    let os_tar = OsString::from(&tar_str);874891     let os_branch2 = OsString::from(branch);875892     let a_archive: &OsStr = OsStr::new("archive");876893     let a_fmt: &OsStr = OsStr::new("--format=tar.gz");877-    let a_out = OsString::from(format!("-o{tar_str}"));894+    // `git archive -o<path>`: build the flag as an OsString (no lossy895+    // UTF-8 round-trip for non-UTF-8 output paths).896+    let mut a_out = OsString::from("-o");897+    a_out.push(tarball.as_os_str());878898     let args2: Vec<&OsStr> = vec![a_archive, a_fmt, a_out.as_os_str(), os_branch2.as_os_str()];879-    let _ = os_tar;880899     let out2 = run_git(repo, &args2)?;881900     if !out2.status_ok {882901         let stderr = String::from_utf8_lossy(&out2.stderr).trim().to_string();@@ -889,88 +908,24 @@ pub fn snapshot(repo: &Path, branch: &str, out: &Path) -> Result<Snapshot, GitEr889908     Ok(Snapshot { bundle, tarball })890909 }891910 892-fn run_plain_git(args: &[&OsStr]) -> Result<GitOutput, GitError> {893-    let mut cmd = Command::new("git");894-    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());895-    let mut child = cmd.spawn().map_err(|e| GitError::Spawn(e.to_string()))?;896-    let stdout_handle = child.stdout.take();897-    let stderr_handle = child.stderr.take();898-    let (tx_out, rx_out) = mpsc::channel::<Vec<u8>>();899-    let (tx_err, rx_err) = mpsc::channel::<Vec<u8>>();900-    if let Some(mut out) = stdout_handle {901-        let tx = tx_out;902-        thread::spawn(move || {903-            let mut buf = Vec::new();904-            let _ = out.read_to_end(&mut buf);905-            let _ = tx.send(buf);906-        });907-    } else {908-        let _ = tx_out.send(Vec::new());909-    }910-    if let Some(mut err) = stderr_handle {911-        let tx = tx_err;912-        thread::spawn(move || {913-            let mut buf = Vec::new();914-            let _ = err.read_to_end(&mut buf);915-            let _ = tx.send(buf);916-        });917-    } else {918-        let _ = tx_err.send(Vec::new());919-    }920-    let start = Instant::now();921-    loop {922-        match child.try_wait() {923-            Err(e) => {924-                return Err(GitError::Failed {925-                    cmd: format!(926-                        "git {}",927-                        args.iter()928-                            .map(|a| a.to_string_lossy())929-                            .collect::<Vec<_>>()930-                            .join(" ")931-                    ),932-                    status: "wait-error".to_string(),933-                    stderr: e.to_string(),934-                });935-            }936-            Ok(Some(status)) => {937-                let stdout = rx_out938-                    .recv_timeout(Duration::from_secs(5))939-                    .unwrap_or_default();940-                let stderr = rx_err941-                    .recv_timeout(Duration::from_secs(5))942-                    .unwrap_or_default();943-                return Ok(GitOutput {944-                    status_ok: status.success(),945-                    status_str: status.to_string(),946-                    stdout,947-                    stderr,948-                });949-            }950-            Ok(None) => {951-                if start.elapsed() >= GIT_TIMEOUT {952-                    let _ = child.kill();953-                    let _ = child.wait();954-                    return Err(GitError::Timeout(955-                        "git clone/update-server-info".to_string(),956-                    ));957-                }958-                thread::sleep(Duration::from_millis(10));959-            }960-        }961-    }962-}963-964-/// Export a dumb-HTTP clone dir into `out/repo.git` (issue #54).911+/// Export a dumb-HTTP clone dir into `out/repo.git` (issues #54/#55/#56/#57).965912 ///966-/// Runs `git clone --bare --single-branch` plus967-/// `git --git-dir=<dest> update-server-info`, so plain static servers968-/// (Pages/nginx/`hu13 serve`) all serve `git clone <origin>/repo.git`969-/// with zero dynamic smart HTTP. No GitHub, no config.913+/// The source is the **bundle** written by [`snapshot`], not the live914+/// repo: bundle transport negotiates reachable objects only, so the915+/// export is exactly the snapshot — one pack, no loose objects, no916+/// unreachable/dangling history leaking out of `refs` (f0rge #55/#56).917+/// A local-path clone (`git clone <repo>`) uses local transport instead918+/// and copies the whole object DB, including unreachable commits.919+///920+/// After cloning we drop `remote.origin` (it held the build machine's921+/// absolute path, f0rge #57), remove the sample hooks (file-count noise),922+/// and run `update-server-info` so plain static servers923+/// (Pages/nginx/`hu13 serve`) can serve `git clone <origin>/repo.git`924+/// with zero dynamic smart HTTP.970925 ///971926 /// Failures are loud (build fails, never a silent missing dir), and an972927 /// existing `out/repo.git` is removed first so stale objects never survive.973-pub fn export_dumb_repo(repo: &Path, branch: &str, out: &Path) -> Result<PathBuf, GitError> {928+pub fn export_dumb_repo(bundle: &Path, branch: &str, out: &Path) -> Result<PathBuf, GitError> {974929     std::fs::create_dir_all(out)975930         .map_err(|e| GitError::Io(format!("create out dir {}: {e}", out.to_string_lossy())))?;976931     let dest = out.join("repo.git");@@ -978,46 +933,76 @@ pub fn export_dumb_repo(repo: &Path, branch: &str, out: &Path) -> Result<PathBuf978933         std::fs::remove_dir_all(&dest)979934             .map_err(|e| GitError::Io(format!("clean {}: {e}", dest.to_string_lossy())))?;980935     }981-    let repo_abs = std::fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());982-    let repo_str = repo_abs.to_string_lossy().into_owned();983-    let dest_str = dest.to_string_lossy().into_owned();984-    let os_repo = OsString::from(&repo_str);985-    let os_dest = OsString::from(&dest_str);986-    let os_branch = OsString::from(branch);936+937+    // Prefer a relative `.git`-free repo: clone with `--no-hardlinks` is938+    // irrelevant for bundles, but `--quiet` keeps build output clean.987939     let a_clone: &OsStr = OsStr::new("clone");988940     let a_bare: &OsStr = OsStr::new("--bare");941+    let a_quiet: &OsStr = OsStr::new("--quiet");989942     let a_single: &OsStr = OsStr::new("--single-branch");990943     let a_branch: &OsStr = OsStr::new("--branch");991944     let a_sep: &OsStr = OsStr::new("--");992945     let args: Vec<&OsStr> = vec![993946         a_clone,994947         a_bare,948+        a_quiet,995949         a_single,996950         a_branch,997-        os_branch.as_os_str(),951+        OsStr::new(branch),998952         a_sep,999-        OsStr::new(os_repo.as_os_str()),1000-        os_dest.as_os_str(),953+        bundle.as_os_str(),954+        dest.as_os_str(),1001955     ];1002-    let out1 = run_plain_git(&args)?;956+    let display =957+        format!("git clone --bare --single-branch --branch {branch} <repo.bundle> repo.git");958+    let out1 = run_git_opt(None, &args, EXPORT_TIMEOUT, display.clone())?;1003959     if !out1.status_ok {1004-        let stderr = String::from_utf8_lossy(&out1.stderr).trim().to_string();1005960         return Err(GitError::Failed {1006-            cmd: format!("git clone --bare --single-branch --branch {branch} <repo> repo.git"),961+            cmd: display,1007962             status: out1.status_str,1008-            stderr,963+            stderr: String::from_utf8_lossy(&out1.stderr).trim().to_string(),1009964         });1010965     }1011-    let git_dir_flag = OsString::from(format!("--git-dir={dest_str}"));1012-    let a_usi: &OsStr = OsStr::new("update-server-info");1013-    let args2: Vec<&OsStr> = vec![git_dir_flag.as_os_str(), a_usi];1014-    let out2 = run_plain_git(&args2)?;966+967+    // `remote.origin.url` would publish the local filesystem path.968+    // Build `--git-dir=<path>` as an OsString: no UTF-8 round-trip.969+    let mut git_dir_arg = OsString::from("--git-dir=");970+    git_dir_arg.push(dest.as_os_str());971+    let a_remote: &OsStr = OsStr::new("remote");972+    let a_remove: &OsStr = OsStr::new("remove");973+    let a_origin: &OsStr = OsStr::new("origin");974+    let args_remote: Vec<&OsStr> = vec![git_dir_arg.as_os_str(), a_remote, a_remove, a_origin];975+    let display_remote = "git --git-dir=<dest> remote remove origin".to_string();976+    let out2 = run_git_opt(None, &args_remote, GIT_TIMEOUT, display_remote.clone())?;1015977     if !out2.status_ok {1016-        let stderr = String::from_utf8_lossy(&out2.stderr).trim().to_string();1017978         return Err(GitError::Failed {1018-            cmd: "git --git-dir=<dest> update-server-info".to_string(),979+            cmd: display_remote,1019980             status: out2.status_str,1020-            stderr,981+            stderr: String::from_utf8_lossy(&out2.stderr).trim().to_string(),982+        });983+    }984+985+    // Sample hooks are noise for a mirror; removing them also keeps the986+    // dumb-HTTP file count down for the Pages 20k-file cap.987+    let hooks = dest.join("hooks");988+    if let Ok(rd) = std::fs::read_dir(&hooks) {989+        for ent in rd.flatten() {990+            let path = ent.path();991+            if path.extension().is_some_and(|e| e == "sample") {992+                let _ = std::fs::remove_file(path);993+            }994+        }995+    }996+997+    let a_usi: &OsStr = OsStr::new("update-server-info");998+    let args2: Vec<&OsStr> = vec![git_dir_arg.as_os_str(), a_usi];999+    let display_usi = "git --git-dir=<dest> update-server-info".to_string();1000+    let out3 = run_git_opt(None, &args2, EXPORT_TIMEOUT, display_usi.clone())?;1001+    if !out3.status_ok {1002+        return Err(GitError::Failed {1003+            cmd: display_usi,1004+            status: out3.status_str,1005+            stderr: String::from_utf8_lossy(&out3.stderr).trim().to_string(),10211006         });10221007     }10231008     Ok(dest)
src/serve.rs+6 −5
diff --git a/src/serve.rs b/src/serve.rsindex dc71347..12718d1 100644--- a/src/serve.rs+++ b/src/serve.rs@@ -459,11 +459,12 @@ fn content_type_for(name: &str) -> &'static str {459459         "application/atom+xml; charset=utf-8"460460     } else if lower.ends_with(".md") || lower.ends_with(".txt") {461461         "text/plain; charset=utf-8"462-    } else if lower.ends_with("/head")463-        || lower.ends_with("/info/refs")464-        || lower.ends_with("/info/packs")465-        || lower.ends_with("/info/alternates")466-        || lower.ends_with("packed-refs")462+    } else if lower.ends_with(".git/head")463+        || (lower.contains(".git/")464+            && (lower.ends_with("/info/refs")465+                || lower.ends_with("/info/packs")466+                || lower.ends_with("/info/alternates")467+                || lower.ends_with("/packed-refs")))467468     {468469         // Dumb-HTTP git files (#54): plain text so any static server469470         // (Pages/nginx/serve) serves `repo.git` identically. Git ignores
tests/clone_export.rs+190 −1
diff --git a/tests/clone_export.rs b/tests/clone_export.rsnew file mode 100644index 0000000..6c95ec5--- /dev/null+++ b/tests/clone_export.rs@@ -0,0 +1,190 @@1+//! f0rge #55/#56/#57: `dist/repo.git` is the snapshot, not the object DB.2+//!3+//! A local-path clone copies every object (including unreachable history)4+//! and leaves one loose object per blob; building from `repo.bundle`5+//! negotiates reachable objects only, giving one pack and no dangling6+//! commits. The export must also not publish the build machine's path.7+8+use std::path::{Path, PathBuf};9+use std::process::Command;10+11+fn fresh(prefix: &str) -> PathBuf {12+    let dir = std::env::temp_dir().join(format!(13+        "hu13-export-{prefix}-{}-{}",14+        std::process::id(),15+        std::time::SystemTime::now()16+            .duration_since(std::time::UNIX_EPOCH)17+            .map(|d| d.as_nanos())18+            .unwrap_or(0)19+    ));20+    std::fs::create_dir_all(&dir).unwrap();21+    dir22+}23+24+fn git(repo: &Path, args: &[&str]) -> String {25+    let out = Command::new("git")26+        .arg("-C")27+        .arg(repo)28+        .args(args)29+        .output()30+        .expect("spawn git");31+    assert!(32+        out.status.success(),33+        "git {args:?}: {}",34+        String::from_utf8_lossy(&out.stderr)35+    );36+    String::from_utf8_lossy(&out.stdout).trim().to_string()37+}38+39+fn count_files(dir: &Path) -> usize {40+    let mut n = 0;41+    let mut stack = vec![dir.to_path_buf()];42+    while let Some(d) = stack.pop() {43+        if let Ok(rd) = std::fs::read_dir(&d) {44+            for e in rd.flatten() {45+                let p = e.path();46+                if p.is_dir() {47+                    stack.push(p);48+                } else {49+                    n += 1;50+                }51+            }52+        }53+    }54+    n55+}56+57+#[test]58+fn export_is_exact_snapshot_single_pack_no_paths() {59+    let repo = fresh("repo");60+    let out = fresh("out");61+    for args in [62+        ["init", "-b", "main"].as_slice(),63+        &["config", "user.name", "hu13-test"],64+        &["config", "user.email", "[email protected]"],65+        &["config", "commit.gpgsign", "false"],66+    ] {67+        let st = Command::new("git")68+            .arg("-C")69+            .arg(&repo)70+            .args(args)71+            .status()72+            .unwrap();73+        assert!(st.success());74+    }75+    std::fs::write(repo.join("README.md"), "# export\n").unwrap();76+    std::fs::write(repo.join("a.txt"), "one\n").unwrap();77+    git(&repo, &["add", "-A"]);78+    git(&repo, &["commit", "-m", "one"]);79+    std::fs::write(repo.join("b.txt"), "two\n").unwrap();80+    git(&repo, &["add", "-A"]);81+    git(&repo, &["commit", "-m", "two"]);82+83+    // Unreachable history: commit on a detached HEAD, then move away.84+    // This is the #55 leak: local-transport clones carry it, bundles don't.85+    let base = git(&repo, &["rev-parse", "HEAD"]);86+    git(&repo, &["checkout", "-q", "--detach"]);87+    std::fs::write(repo.join("secret.txt"), "unreachable secret\n").unwrap();88+    git(&repo, &["add", "-A"]);89+    git(&repo, &["commit", "-q", "-m", "dangling secret"]);90+    let dangling = git(&repo, &["rev-parse", "HEAD"]);91+    git(&repo, &["checkout", "-q", "main"]);92+    std::fs::write(repo.join("secret.txt"), "").ok();93+    let _ = std::fs::remove_file(repo.join("secret.txt"));94+    git(&repo, &["reset", "-q", "--hard", &base]);95+    assert_ne!(dangling, base);96+97+    let produced = Command::new(env!("CARGO_BIN_EXE_hu13"))98+        .arg("build")99+        .arg(&repo)100+        .arg("-o")101+        .arg(&out)102+        .output()103+        .unwrap();104+    assert!(105+        produced.status.success(),106+        "build: {}",107+        String::from_utf8_lossy(&produced.stderr)108+    );109+110+    let gd = out.join("repo.git");111+    assert!(gd.is_dir(), "repo.git emitted");112+113+    // One pack, zero loose objects, few files (Pages 20k cap comfort).114+    let packs = std::fs::read_dir(gd.join("objects/pack"))115+        .map(|rd| {116+            rd.flatten()117+                .filter(|e| e.path().extension().is_some_and(|x| x == "pack"))118+                .count()119+        })120+        .unwrap_or(0);121+    assert_eq!(packs, 1, "exactly one pack");122+    let loose = std::fs::read_dir(gd.join("objects"))123+        .map(|rd| {124+            rd.flatten()125+                .filter(|e| e.path().is_dir())126+                .filter(|e| e.file_name().len() == 2)127+                .map(|e| std::fs::read_dir(e.path()).map(|r| r.count()).unwrap_or(0))128+                .sum::<usize>()129+        })130+        .unwrap_or(0);131+    assert_eq!(loose, 0, "no loose objects");132+    let files = count_files(&gd);133+    assert!(files < 40, "dumb repo stays small, got {files} files");134+135+    // No unreachable/dangling objects: the snapshot is exact.136+    let fsck = Command::new("git")137+        .arg("--git-dir")138+        .arg(&gd)139+        .args(["fsck", "--unreachable", "--dangling", "--no-progress"])140+        .output()141+        .unwrap();142+    let report = String::from_utf8_lossy(&fsck.stdout);143+    assert!(144+        report.trim().is_empty(),145+        "no unreachable/dangling objects, got: {report}"146+    );147+148+    // The dangling commit is genuinely absent from the export.149+    let has = Command::new("git")150+        .arg("--git-dir")151+        .arg(&gd)152+        .args(["cat-file", "-e", &dangling])153+        .status()154+        .unwrap();155+    assert!(!has.success(), "dangling commit must not be exported");156+157+    // No local filesystem path in config.158+    let config = std::fs::read_to_string(gd.join("config")).unwrap();159+    assert!(160+        !config.contains(&repo.to_string_lossy().to_string()),161+        "config must not leak the source path: {config}"162+    );163+    assert!(!config.contains("remote"), "no remote section: {config}");164+165+    // Sample hooks are gone.166+    let samples = std::fs::read_dir(gd.join("hooks"))167+        .map(|rd| {168+            rd.flatten()169+                .filter(|e| e.path().extension().is_some_and(|x| x == "sample"))170+                .count()171+        })172+        .unwrap_or(0);173+    assert_eq!(samples, 0, "no sample hooks");174+175+    // And it still clones.176+    let dest = fresh("cloned").join("c");177+    let st = Command::new("git")178+        .arg("clone")179+        .arg("--quiet")180+        .arg("-b")181+        .arg("main")182+        .arg(&gd)183+        .arg(&dest)184+        .status()185+        .unwrap();186+    assert!(st.success(), "clone from exported repo.git works");187+    assert!(dest.join("README.md").is_file());188+    let log = git(&dest, &["log", "--oneline"]);189+    assert_eq!(log.lines().count(), 2, "only reachable commits: {log}");190+}0-- 2.56.0

Comments (0)

No comments yet.

Reviews (0)

No reviews yet.

all pulls