#51 Build repo.git from the bundle; strip paths and hooks (#55 #56 #57 #58 #59)
Patch
.forge/wiki/deploy+18 −10--- 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--- a/.gitignore+++ b/.gitignore@@ -2,3 +2,4 @@22 .forge/ci/runs/33 .forge/releases/out/44 *.tmp5+/dist
Dockerfile+3 −1--- 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--- 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--- 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--- 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--- /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--
Comments (0)
No comments yet.
Reviews (0)
No reviews yet.