1 From a8d9b5fb7a61aa1605571ceabcc9ba78c8542395 Mon Sep 17 00:00:00 20012 From: hu13-test <[email protected]>3 Date: Tue, 27 Oct 2026 14:08:06 +00004 Subject: [PATCH] dumb static clone via repo.git (#54)5 6 ---7 .../issue.md | 11 ++8 entrypoint.sh | 2 +-9 src/agents.rs | 2 +-10 src/build.rs | 46 ++++--11 src/gitx.rs | 134 ++++++++++++++++++12 src/main.rs | 2 +-13 src/serve.rs | 10 ++14 tests/clone_origin.rs | 88 ++++++++++--15 8 files changed, 266 insertions(+), 29 deletions(-)16 create mode 100644 .forge/issues/d2ea6e8e-41d9-4051-9db3-cb362ec1199f/issue.md17 18 diff --git a/.forge/issues/d2ea6e8e-41d9-4051-9db3-cb362ec1199f/issue.md b/.forge/issues/d2ea6e8e-41d9-4051-9db3-cb362ec1199f/issue.md19 new file mode 10064420 index 0000000..c4b8d0c21 --- /dev/null22 +++ b/.forge/issues/d2ea6e8e-41d9-4051-9db3-cb362ec1199f/issue.md23 @@ -0,0 +1,11 @@24 +---25 +id: d2ea6e8e-41d9-4051-9db3-cb362ec1199f26 +title: Dumb static git clone via repo.git27 +state: open28 +labels: []29 +assignees: []30 +milestone: null31 +created: 2026-10-27T14:03:54.332624090Z32 +updated: null33 +---34 +Approved: emit dist/repo.git bare + update-server-info at build so git clone https://hub13link/repo.git works on docker/Pages/nginx with one shape. No GitHub URL. Keep bundle+tarball. Update clone_block to advertise dumb URL with public-origin, keep serve smart intact.35 diff --git a/entrypoint.sh b/entrypoint.sh36 index 21a094f..69cc5f6 10075537 --- a/entrypoint.sh38 +++ b/entrypoint.sh39 @@ -9,7 +9,7 @@40 # HU13_BRANCH branch to build (default: main/master auto-detect)41 # HU13_CLONE_URL clone URL to advertise (default: repo origin remote)42 # HU13_PUBLIC_ORIGIN public base URL of this server (default: unset;43 -# when set, clone block advertises `<origin>/repo.bundle`)44 +# when set, clone block advertises `<origin>/repo.git`)45 # HU13_REPO_NAME repo display name (default: repo dir name; never blank)46 # HU13_DESCRIPTION47 # site description (default: hidden, no empty <p>)48 diff --git a/src/agents.rs b/src/agents.rs49 index d4e49f1..11a1a72 10064450 --- a/src/agents.rs51 +++ b/src/agents.rs52 @@ -40,7 +40,7 @@ fn agents_markdown(53 - Site: `{site_name}`, {clone_line}\n\54 - Raw file bytes: `/raw/<path>` (byte-identical to the repo).\n\55 - JSON API (compact fixed-order, same data as HTML): `/api/issues.json`, `/api/pulls.json`, `/api/commits.json`, `/api/releases.json`, `/api/wiki.json`.\n\56 - - Snapshots: `/repo.tar.gz` (extract) + `/repo.bundle` (`git clone -b {branch} repo.bundle`). The bundle is authoritative when no clone URL is configured.\n\57 + - Clone: `git clone <origin>/repo.git` (dumb-HTTP, works on any static server) or `/repo.tar.gz` (extract) + `/repo.bundle` (`git clone -b {branch} repo.bundle`). The bundle is authoritative when no clone URL is configured.\n\58 - Releases + artifacts: `/releases/index.html`, `/releases/<ver>.html`, downloads under `/releases/<ver>/<file>`.\n\59 - Feed: `/feed.atom` (up to 50 entries, commits + releases newest-first — poll this, not the HTML).\n\60 - Human pages mirror the same data: `/commits.html`, `/commit/<sha>.html`, `/issues/index.html`, `/pulls/index.html`, `/tasks/index.html`, `/ci/index.html`, `/wiki/index.html`.\n\n\61 diff --git a/src/build.rs b/src/build.rs62 index 68698e3..5ca0e2a 10064463 --- a/src/build.rs64 +++ b/src/build.rs65 @@ -7,7 +7,9 @@66 //! `tasks/index.html`, `ci/index.html`, `releases/index.html`,67 //! `releases/<ver>.html`, `releases/<ver>/<file>` artifacts,68 //! `wiki/index.html`, `wiki/<path>.html`, `api/*.json`, `agents.html` +69 -//! `agents.md`, `repo.bundle` + `repo.tar.gz`, and `style.css`.70 +//! `agents.md`, `repo.bundle` + `repo.tar.gz` + `repo.git/` (dumb-HTTP,71 +//! issue #54: `git clone <origin>/repo.git` works on any static server),72 +//! and `style.css`.73 //!74 //! Bodies from the repo are semi-trusted but treated as untrusted:75 //! markdown goes through [`markdown_to_html`], which escapes raw HTML;76 @@ -35,7 +37,7 @@ pub struct BuildOptions {77 pub clone_url_flag: Option<String>,78 /// `--public-origin` flag (else `$HU13_PUBLIC_ORIGIN`, else unset).79 /// Base URL of this server, e.g. `http://host:39313`. When set, the80 - /// clone block advertises `git clone <origin>/repo.bundle`.81 + /// clone block advertises `git clone <origin>/repo.git` (dumb-HTTP).82 /// Empty/whitespace falls through to unset.83 pub public_origin_flag: Option<String>,84 /// `--repo-name` flag (else `$HU13_REPO_NAME`, else repo dir name).85 @@ -630,15 +632,16 @@ pub fn file_table_html(86 }87 88 /// Prominent Clone & download section for the code tab (m6, f0rge89 -/// issues #20 + #32 + #34): collapsed native `<details>` (closed by90 -/// default, zero JS, keyboard accessible, works file://) with stacked91 -/// no-JS sections and selectable `<pre>` commands inside. When a public92 -/// origin is configured the primary command is93 -/// `git clone <origin>/repo.bundle` (exact snapshot note + tarball94 -/// secondary, origin remote demoted to "mirror source"); otherwise95 -/// bundle-first as before (`git clone <url>` first when a clone URL is96 -/// configured, else local bundle) plus the bundle/tarball links with97 -/// sizes and shas. The `<summary>` always shows `Clone & download`98 +/// issues #20 + #32 + #34; dumb-clone #54): collapsed native `<details>`99 +/// (closed by default, zero JS, keyboard accessible, works file://) with100 +/// stacked no-JS sections and selectable `<pre>` commands inside. When a101 +/// public origin is configured the primary command is102 +/// `git clone <origin>/repo.git` (dumb-HTTP, works on Pages/nginx/serve103 +/// with zero dynamic; exact snapshot note + bundle/tarball secondary,104 +/// origin remote demoted to "mirror source"); otherwise dumb-local first105 +/// (`git clone <url>` first when a clone URL is configured, else local106 +/// `git clone repo.git`) plus the bundle/tarball links with sizes and107 +/// shas. The `<summary>` always shows `Clone & download`108 /// plus a branch@sha hint. The summary also carries the CI verdict badge109 /// (m6 finish, f0rge #53): `Clone & download · branch @ sha · CI badge`110 /// so the standalone repo-meta branch/CI line is gone (no duplication).111 @@ -687,16 +690,24 @@ pub fn clone_block(112 );113 if let Some(origin) = public_origin.map(str::trim).filter(|s| !s.is_empty()) {114 let base = origin.trim_end_matches('/');115 + let dumb_url = format!("{base}/repo.git");116 let bundle_url = format!("{base}/repo.bundle");117 let tarball_url = format!("{base}/repo.tar.gz");118 out.push_str("<h3>Clone with git</h3>\n");119 out.push_str(&format!(120 "<pre class=\"clone-cmd\"><code>git clone {}</code></pre>\n\121 <p>exact snapshot of <code>{}</code> @ <code>{}</code>.</p>\n",122 - esc(&bundle_url),123 + esc(&dumb_url),124 esc(branch),125 esc(short_sha)126 ));127 + out.push_str("<h3>Download the snapshot</h3>\n");128 + out.push_str(&format!(129 + "<pre class=\"clone-cmd\"><code>curl -O {}</code></pre>\n\130 + <pre class=\"clone-cmd\"><code>git clone repo.bundle -b {}</code></pre>\n<p>{bundle_link}</p>\n",131 + esc(&bundle_url),132 + esc(branch)133 + ));134 out.push_str("<h3>Download the tarball</h3>\n");135 out.push_str(&format!(136 "<pre class=\"clone-cmd\"><code>curl -O {}</code></pre>\n<p>{tarball_link}</p>\n",137 @@ -718,6 +729,12 @@ pub fn clone_block(138 esc(url)139 ));140 }141 + out.push_str("<h3>Clone this mirror</h3>\n");142 + out.push_str(&format!(143 + "<pre class=\"clone-cmd\"><code>git clone repo.git -b {}</code></pre>\n\144 + <p>dumb-HTTP checkout served from this server; ask the host for its address.</p>\n",145 + esc(branch)146 + ));147 out.push_str("<h3>Clone the bundle</h3>\n");148 out.push_str(&format!(149 "<pre class=\"clone-cmd\"><code>git clone repo.bundle -b {}</code></pre>\n<p>{bundle_link}</p>\n",150 @@ -1067,8 +1084,11 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu151 // Mailbox forms (m4, issue #9): free textboxes, honeypot, license box.152 crate::inbox::render_forms(out, &name, &nav, &branch, &short_sha, desc_opt)?;153 154 - // Snapshot last so the index can link sizes.155 + // Snapshot last so the index can link sizes. Dumb-HTTP repo.git (#54)156 + // rides alongside the bundle/tarball so `git clone <origin>/repo.git`157 + // works on any static server (Pages/nginx/serve), no smart HTTP.158 let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;159 + gitx::export_dumb_repo(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;160 let bundle_size = std::fs::metadata(&snap.bundle)161 .map(|m| m.len())162 .unwrap_or(0);163 diff --git a/src/gitx.rs b/src/gitx.rs164 index 219d25a..ac68f8f 100644165 --- a/src/gitx.rs166 +++ b/src/gitx.rs167 @@ -767,6 +767,140 @@ pub fn snapshot(repo: &Path, branch: &str, out: &Path) -> Result<Snapshot, GitEr168 Ok(Snapshot { bundle, tarball })169 }170 171 +fn run_plain_git(args: &[&OsStr]) -> Result<GitOutput, GitError> {172 + let mut cmd = Command::new("git");173 + cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());174 + let mut child = cmd.spawn().map_err(|e| GitError::Spawn(e.to_string()))?;175 + let stdout_handle = child.stdout.take();176 + let stderr_handle = child.stderr.take();177 + let (tx_out, rx_out) = mpsc::channel::<Vec<u8>>();178 + let (tx_err, rx_err) = mpsc::channel::<Vec<u8>>();179 + if let Some(mut out) = stdout_handle {180 + let tx = tx_out;181 + thread::spawn(move || {182 + let mut buf = Vec::new();183 + let _ = out.read_to_end(&mut buf);184 + let _ = tx.send(buf);185 + });186 + } else {187 + let _ = tx_out.send(Vec::new());188 + }189 + if let Some(mut err) = stderr_handle {190 + let tx = tx_err;191 + thread::spawn(move || {192 + let mut buf = Vec::new();193 + let _ = err.read_to_end(&mut buf);194 + let _ = tx.send(buf);195 + });196 + } else {197 + let _ = tx_err.send(Vec::new());198 + }199 + let start = Instant::now();200 + loop {201 + match child.try_wait() {202 + Err(e) => {203 + return Err(GitError::Failed {204 + cmd: format!(205 + "git {}",206 + args.iter()207 + .map(|a| a.to_string_lossy())208 + .collect::<Vec<_>>()209 + .join(" ")210 + ),211 + status: "wait-error".to_string(),212 + stderr: e.to_string(),213 + });214 + }215 + Ok(Some(status)) => {216 + let stdout = rx_out217 + .recv_timeout(Duration::from_secs(5))218 + .unwrap_or_default();219 + let stderr = rx_err220 + .recv_timeout(Duration::from_secs(5))221 + .unwrap_or_default();222 + return Ok(GitOutput {223 + status_ok: status.success(),224 + status_str: status.to_string(),225 + stdout,226 + stderr,227 + });228 + }229 + Ok(None) => {230 + if start.elapsed() >= GIT_TIMEOUT {231 + let _ = child.kill();232 + let _ = child.wait();233 + return Err(GitError::Timeout(234 + "git clone/update-server-info".to_string(),235 + ));236 + }237 + thread::sleep(Duration::from_millis(10));238 + }239 + }240 + }241 +}242 +243 +/// Export a dumb-HTTP clone dir into `out/repo.git` (issue #54).244 +///245 +/// Runs `git clone --bare --single-branch` plus246 +/// `git --git-dir=<dest> update-server-info`, so plain static servers247 +/// (Pages/nginx/`hu13 serve`) all serve `git clone <origin>/repo.git`248 +/// with zero dynamic smart HTTP. No GitHub, no config.249 +///250 +/// Failures are loud (build fails, never a silent missing dir), and an251 +/// existing `out/repo.git` is removed first so stale objects never survive.252 +pub fn export_dumb_repo(repo: &Path, branch: &str, out: &Path) -> Result<PathBuf, GitError> {253 + std::fs::create_dir_all(out)254 + .map_err(|e| GitError::Io(format!("create out dir {}: {e}", out.to_string_lossy())))?;255 + let dest = out.join("repo.git");256 + if dest.exists() {257 + std::fs::remove_dir_all(&dest)258 + .map_err(|e| GitError::Io(format!("clean {}: {e}", dest.to_string_lossy())))?;259 + }260 + let repo_abs = std::fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());261 + let repo_str = repo_abs.to_string_lossy().into_owned();262 + let dest_str = dest.to_string_lossy().into_owned();263 + let os_repo = OsString::from(&repo_str);264 + let os_dest = OsString::from(&dest_str);265 + let os_branch = OsString::from(branch);266 + let a_clone: &OsStr = OsStr::new("clone");267 + let a_bare: &OsStr = OsStr::new("--bare");268 + let a_single: &OsStr = OsStr::new("--single-branch");269 + let a_branch: &OsStr = OsStr::new("--branch");270 + let a_sep: &OsStr = OsStr::new("--");271 + let args: Vec<&OsStr> = vec![272 + a_clone,273 + a_bare,274 + a_single,275 + a_branch,276 + os_branch.as_os_str(),277 + a_sep,278 + OsStr::new(os_repo.as_os_str()),279 + os_dest.as_os_str(),280 + ];281 + let out1 = run_plain_git(&args)?;282 + if !out1.status_ok {283 + let stderr = String::from_utf8_lossy(&out1.stderr).trim().to_string();284 + return Err(GitError::Failed {285 + cmd: format!("git clone --bare --single-branch --branch {branch} <repo> repo.git"),286 + status: out1.status_str,287 + stderr,288 + });289 + }290 + let git_dir_flag = OsString::from(format!("--git-dir={dest_str}"));291 + let a_usi: &OsStr = OsStr::new("update-server-info");292 + let args2: Vec<&OsStr> = vec![git_dir_flag.as_os_str(), a_usi];293 + let out2 = run_plain_git(&args2)?;294 + if !out2.status_ok {295 + let stderr = String::from_utf8_lossy(&out2.stderr).trim().to_string();296 + return Err(GitError::Failed {297 + cmd: "git --git-dir=<dest> update-server-info".to_string(),298 + status: out2.status_str,299 + stderr,300 + });301 + }302 + Ok(dest)303 +}304 +305 // ---------------------------------------------------------------------------306 // tests (std-only fixture repos)307 // ---------------------------------------------------------------------------308 diff --git a/src/main.rs b/src/main.rs309 index cffc87e..148c678 100644310 --- a/src/main.rs311 +++ b/src/main.rs312 @@ -51,7 +51,7 @@ OPTIONS:313 --branch <NAME> Branch to pin (else HU13_BRANCH, else try main/master)314 --clone-url <URL> Clone URL to advertise (else git origin remote, else none)315 --public-origin <URL> Public base URL of this server (else HU13_PUBLIC_ORIGIN, else unset;316 - when set, clone block advertises `<origin>/repo.bundle`)317 + when set, clone block advertises `<origin>/repo.git`)318 --repo-name <S> Repo display name (else HU13_REPO_NAME, else repo dir name;319 --title is a deprecated alias; empty falls back, never blank)320 --description <S> Site description (else HU13_DESCRIPTION, else hidden;321 diff --git a/src/serve.rs b/src/serve.rs322 index d6429f5..a119e85 100644323 --- a/src/serve.rs324 +++ b/src/serve.rs325 @@ -394,6 +394,16 @@ fn content_type_for(name: &str) -> &'static str {326 "application/atom+xml; charset=utf-8"327 } else if lower.ends_with(".md") || lower.ends_with(".txt") {328 "text/plain; charset=utf-8"329 + } else if lower.ends_with("/head")330 + || lower.ends_with("/info/refs")331 + || lower.ends_with("/info/packs")332 + || lower.ends_with("/info/alternates")333 + || lower.ends_with("packed-refs")334 + {335 + // Dumb-HTTP git files (#54): plain text so any static server336 + // (Pages/nginx/serve) serves `repo.git` identically. Git ignores337 + // mime for dumb reads, but explicit beats octet-stream guessing.338 + "text/plain; charset=utf-8"339 } else if lower.ends_with(".gz") {340 "application/gzip"341 } else {342 diff --git a/tests/clone_origin.rs b/tests/clone_origin.rs343 index d5881c7..0d514a3 100644344 --- a/tests/clone_origin.rs345 +++ b/tests/clone_origin.rs346 @@ -1,7 +1,8 @@347 -//! Clone-from-server (m6, f0rge issue #32):348 +//! Clone-from-server (m6, f0rge issue #32; dumb-clone #54):349 //! `--public-origin` / `HU13_PUBLIC_ORIGIN` (flag > env > unset),350 //! clone-block both-mode asserts, serve `/repo.bundle` byte-identical,351 -//! live `git clone http://host:port/repo.bundle`, entrypoint passthrough.352 +//! live `git clone http://host:port/repo.bundle` (smart, serve-only) +353 +//! `git clone <origin>/repo.git` (dumb, any static server), entrypoint passthrough.354 355 use std::io::{BufRead, BufReader, Read, Write};356 use std::net::TcpStream;357 @@ -113,15 +114,23 @@ fn public_origin_primary_is_absolute_bundle_with_snapshot_note() {358 let out = build_with(&repo, &["--public-origin", origin]);359 let index = std::fs::read_to_string(out.join("index.html")).expect("index");360 assert!(361 - index.contains(&format!("git clone {origin}/repo.bundle")),362 - "primary absolute bundle cmd"363 + index.contains(&format!("git clone {origin}/repo.git")),364 + "primary dumb clone cmd"365 );366 assert!(index.contains("exact snapshot of"), "snapshot note present");367 assert!(index.contains("main"), "branch in snapshot note");368 assert!(index.contains("/repo.tar.gz"), "secondary tarball link");369 assert!(370 - !index.contains("git clone repo.bundle -b"),371 - "local bundle cmd replaced by absolute one"372 + index.contains(&format!("curl -O {origin}/repo.bundle")),373 + "bundle dumb download secondary"374 + );375 + assert!(376 + out.join("repo.git").join("HEAD").exists(),377 + "repo.git emitted"378 + );379 + assert!(380 + out.join("repo.git").join("info/refs").exists(),381 + "update-server-info ran"382 );383 set_env("HU13_PUBLIC_ORIGIN", None);384 }385 @@ -136,14 +145,14 @@ fn public_origin_with_clone_url_shows_mirror_source() {386 let out = build_with(&repo, &["--public-origin", origin, "--clone-url", mirror]);387 let index = std::fs::read_to_string(out.join("index.html")).expect("index");388 assert!(389 - index.contains(&format!("git clone {origin}/repo.bundle")),390 - "public origin still primary"391 + index.contains(&format!("git clone {origin}/repo.git")),392 + "public origin still primary (dumb)"393 );394 assert!(index.contains("mirror source"), "mirror source label");395 assert!(index.contains(mirror), "origin remote shown");396 - // Primary (absolute bundle) must come before the mirror source line.397 + // Primary (dumb) must come before the mirror source line.398 let primary = index399 - .find(&format!("git clone {origin}/repo.bundle"))400 + .find(&format!("git clone {origin}/repo.git"))401 .expect("primary pos");402 let mirror_pos = index.find("mirror source").expect("mirror pos");403 assert!(primary < mirror_pos, "primary before mirror source");404 @@ -159,15 +168,15 @@ fn public_origin_flag_beats_env_and_trailing_slash_stripped() {405 let out = build_with(&repo, &[]);406 let index = std::fs::read_to_string(out.join("index.html")).expect("index");407 assert!(408 - index.contains("git clone http://env.example:39313/repo.bundle"),409 + index.contains("git clone http://env.example:39313/repo.git"),410 "env origin used, trailing slash stripped (no //)"411 );412 - assert!(!index.contains("//repo.bundle"), "no doubled slash");413 + assert!(!index.contains("//repo.git"), "no doubled slash");414 // Flag beats env.415 let out = build_with(&repo, &["--public-origin", "http://flag.example:39313"]);416 let index = std::fs::read_to_string(out.join("index.html")).expect("index");417 assert!(418 - index.contains("git clone http://flag.example:39313/repo.bundle"),419 + index.contains("git clone http://flag.example:39313/repo.git"),420 "flag wins over env"421 );422 assert!(!index.contains("env.example"), "env suppressed by flag");423 @@ -326,3 +335,56 @@ fn git_clone_over_http_bundle_works() {424 assert!(dest.join("hello.txt").exists(), "cloned file present");425 set_env("HU13_PUBLIC_ORIGIN", None);426 }427 +428 +#[test]429 +fn dumb_repo_git_clones_file_and_http() {430 + // #54: dist/repo.git is a bare dumb-HTTP dir — `git clone <path>` and431 + // `git clone http://host/repo.git` both work, no smart HTTP, no GitHub.432 + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());433 + set_env("HU13_PUBLIC_ORIGIN", None);434 + let repo = fixture_repo();435 + let out = build_with(&repo, &[]);436 + let git_dir = out.join("repo.git");437 + assert!(git_dir.join("HEAD").exists(), "HEAD emitted");438 + assert!(git_dir.join("info/refs").exists(), "info/refs emitted");439 + assert!(440 + git_dir.join("objects/info/packs").exists(),441 + "objects/info/packs emitted"442 + );443 + // File-path clone (works on file:// + any static host).444 + let dest_parent = fresh("dumb-file");445 + let dest = dest_parent.join("cloned");446 + let st = Command::new("git")447 + .arg("clone")448 + .arg("-b")449 + .arg("main")450 + .arg(&git_dir)451 + .arg(&dest)452 + .status()453 + .expect("spawn git clone");454 + assert!(st.success(), "file clone of repo.git works");455 + assert!(dest.join("README.md").exists(), "cloned README present");456 + // HTTP clone via hu13 serve (same bytes Pages/nginx would serve).457 + let inbox = fresh("inbox-dumb");458 + let srv = start_server(&out, &inbox);459 + let raw = get(&srv.addr, "/repo.git/info/refs");460 + let (head, body) = split_response(&raw);461 + let head = String::from_utf8_lossy(head).into_owned();462 + assert!(head.contains("200"), "GET info/refs -> 200, got: {head}");463 + assert!(!body.is_empty(), "info/refs non-empty");464 + let url = format!("http://{}/repo.git", srv.addr);465 + let dest_parent = fresh("dumb-http");466 + let dest = dest_parent.join("cloned");467 + let st = Command::new("git")468 + .arg("clone")469 + .arg("-b")470 + .arg("main")471 + .arg(&url)472 + .arg(&dest)473 + .status()474 + .expect("spawn git clone");475 + assert!(st.success(), "git clone {url} works");476 + assert!(dest.join("README.md").exists(), "http cloned README");477 + assert!(dest.join("hello.txt").exists(), "http cloned file");478 + set_env("HU13_PUBLIC_ORIGIN", None);479 +}480 -- 481 2.56.0482