From a8d9b5fb7a61aa1605571ceabcc9ba78c8542395 Mon Sep 17 00:00:00 2001 From: hu13-test Date: Tue, 27 Oct 2026 14:08:06 +0000 Subject: [PATCH] dumb static clone via repo.git (#54) --- .../issue.md | 11 ++ entrypoint.sh | 2 +- src/agents.rs | 2 +- src/build.rs | 46 ++++-- src/gitx.rs | 134 ++++++++++++++++++ src/main.rs | 2 +- src/serve.rs | 10 ++ tests/clone_origin.rs | 88 ++++++++++-- 8 files changed, 266 insertions(+), 29 deletions(-) create mode 100644 .forge/issues/d2ea6e8e-41d9-4051-9db3-cb362ec1199f/issue.md diff --git a/.forge/issues/d2ea6e8e-41d9-4051-9db3-cb362ec1199f/issue.md b/.forge/issues/d2ea6e8e-41d9-4051-9db3-cb362ec1199f/issue.md new file mode 100644 index 0000000..c4b8d0c --- /dev/null +++ b/.forge/issues/d2ea6e8e-41d9-4051-9db3-cb362ec1199f/issue.md @@ -0,0 +1,11 @@ +--- +id: d2ea6e8e-41d9-4051-9db3-cb362ec1199f +title: Dumb static git clone via repo.git +state: open +labels: [] +assignees: [] +milestone: null +created: 2026-10-27T14:03:54.332624090Z +updated: null +--- +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. diff --git a/entrypoint.sh b/entrypoint.sh index 21a094f..69cc5f6 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -9,7 +9,7 @@ # HU13_BRANCH branch to build (default: main/master auto-detect) # HU13_CLONE_URL clone URL to advertise (default: repo origin remote) # HU13_PUBLIC_ORIGIN public base URL of this server (default: unset; -# when set, clone block advertises `/repo.bundle`) +# when set, clone block advertises `/repo.git`) # HU13_REPO_NAME repo display name (default: repo dir name; never blank) # HU13_DESCRIPTION # site description (default: hidden, no empty

) diff --git a/src/agents.rs b/src/agents.rs index d4e49f1..11a1a72 100644 --- a/src/agents.rs +++ b/src/agents.rs @@ -40,7 +40,7 @@ fn agents_markdown( - Site: `{site_name}`, {clone_line}\n\ - Raw file bytes: `/raw/` (byte-identical to the repo).\n\ - 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\ - - Snapshots: `/repo.tar.gz` (extract) + `/repo.bundle` (`git clone -b {branch} repo.bundle`). The bundle is authoritative when no clone URL is configured.\n\ + - Clone: `git clone /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\ - Releases + artifacts: `/releases/index.html`, `/releases/.html`, downloads under `/releases//`.\n\ - Feed: `/feed.atom` (up to 50 entries, commits + releases newest-first — poll this, not the HTML).\n\ - Human pages mirror the same data: `/commits.html`, `/commit/.html`, `/issues/index.html`, `/pulls/index.html`, `/tasks/index.html`, `/ci/index.html`, `/wiki/index.html`.\n\n\ diff --git a/src/build.rs b/src/build.rs index 68698e3..5ca0e2a 100644 --- a/src/build.rs +++ b/src/build.rs @@ -7,7 +7,9 @@ //! `tasks/index.html`, `ci/index.html`, `releases/index.html`, //! `releases/.html`, `releases//` artifacts, //! `wiki/index.html`, `wiki/.html`, `api/*.json`, `agents.html` + -//! `agents.md`, `repo.bundle` + `repo.tar.gz`, and `style.css`. +//! `agents.md`, `repo.bundle` + `repo.tar.gz` + `repo.git/` (dumb-HTTP, +//! issue #54: `git clone /repo.git` works on any static server), +//! and `style.css`. //! //! Bodies from the repo are semi-trusted but treated as untrusted: //! markdown goes through [`markdown_to_html`], which escapes raw HTML; @@ -35,7 +37,7 @@ pub struct BuildOptions { pub clone_url_flag: Option, /// `--public-origin` flag (else `$HU13_PUBLIC_ORIGIN`, else unset). /// Base URL of this server, e.g. `http://host:39313`. When set, the - /// clone block advertises `git clone /repo.bundle`. + /// clone block advertises `git clone /repo.git` (dumb-HTTP). /// Empty/whitespace falls through to unset. pub public_origin_flag: Option, /// `--repo-name` flag (else `$HU13_REPO_NAME`, else repo dir name). @@ -630,15 +632,16 @@ pub fn file_table_html( } /// Prominent Clone & download section for the code tab (m6, f0rge -/// issues #20 + #32 + #34): collapsed native `

` (closed by -/// default, zero JS, keyboard accessible, works file://) with stacked -/// no-JS sections and selectable `
` commands inside. When a public
-/// origin is configured the primary command is
-/// `git clone /repo.bundle` (exact snapshot note + tarball
-/// secondary, origin remote demoted to "mirror source"); otherwise
-/// bundle-first as before (`git clone ` first when a clone URL is
-/// configured, else local bundle) plus the bundle/tarball links with
-/// sizes and shas. The `` always shows `Clone & download`
+/// issues #20 + #32 + #34; dumb-clone #54): collapsed native `
` +/// (closed by default, zero JS, keyboard accessible, works file://) with +/// stacked no-JS sections and selectable `
` commands inside. When a
+/// public origin is configured the primary command is
+/// `git clone /repo.git` (dumb-HTTP, works on Pages/nginx/serve
+/// with zero dynamic; exact snapshot note + bundle/tarball secondary,
+/// origin remote demoted to "mirror source"); otherwise dumb-local first
+/// (`git clone ` first when a clone URL is configured, else local
+/// `git clone repo.git`) plus the bundle/tarball links with sizes and
+/// shas. The `` always shows `Clone & download`
 /// plus a branch@sha hint. The summary also carries the CI verdict badge
 /// (m6 finish, f0rge #53): `Clone & download · branch @ sha · CI badge`
 /// so the standalone repo-meta branch/CI line is gone (no duplication).
@@ -687,16 +690,24 @@ pub fn clone_block(
     );
     if let Some(origin) = public_origin.map(str::trim).filter(|s| !s.is_empty()) {
         let base = origin.trim_end_matches('/');
+        let dumb_url = format!("{base}/repo.git");
         let bundle_url = format!("{base}/repo.bundle");
         let tarball_url = format!("{base}/repo.tar.gz");
         out.push_str("

Clone with git

\n"); out.push_str(&format!( "
git clone {}
\n\

exact snapshot of {} @ {}.

\n", - esc(&bundle_url), + esc(&dumb_url), esc(branch), esc(short_sha) )); + out.push_str("

Download the snapshot

\n"); + out.push_str(&format!( + "
curl -O {}
\n\ +
git clone repo.bundle -b {}
\n

{bundle_link}

\n", + esc(&bundle_url), + esc(branch) + )); out.push_str("

Download the tarball

\n"); out.push_str(&format!( "
curl -O {}
\n

{tarball_link}

\n", @@ -718,6 +729,12 @@ pub fn clone_block( esc(url) )); } + out.push_str("

Clone this mirror

\n"); + out.push_str(&format!( + "
git clone repo.git -b {}
\n\ +

dumb-HTTP checkout served from this server; ask the host for its address.

\n", + esc(branch) + )); out.push_str("

Clone the bundle

\n"); out.push_str(&format!( "
git clone repo.bundle -b {}
\n

{bundle_link}

\n", @@ -1067,8 +1084,11 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result/repo.git` + // works on any static server (Pages/nginx/serve), no smart HTTP. let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?; + gitx::export_dumb_repo(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?; let bundle_size = std::fs::metadata(&snap.bundle) .map(|m| m.len()) .unwrap_or(0); diff --git a/src/gitx.rs b/src/gitx.rs index 219d25a..ac68f8f 100644 --- a/src/gitx.rs +++ b/src/gitx.rs @@ -767,6 +767,140 @@ pub fn snapshot(repo: &Path, branch: &str, out: &Path) -> Result Result { + let mut cmd = Command::new("git"); + cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped()); + let mut child = cmd.spawn().map_err(|e| GitError::Spawn(e.to_string()))?; + let stdout_handle = child.stdout.take(); + let stderr_handle = child.stderr.take(); + let (tx_out, rx_out) = mpsc::channel::>(); + let (tx_err, rx_err) = mpsc::channel::>(); + if let Some(mut out) = stdout_handle { + let tx = tx_out; + thread::spawn(move || { + let mut buf = Vec::new(); + let _ = out.read_to_end(&mut buf); + let _ = tx.send(buf); + }); + } else { + let _ = tx_out.send(Vec::new()); + } + if let Some(mut err) = stderr_handle { + let tx = tx_err; + thread::spawn(move || { + let mut buf = Vec::new(); + let _ = err.read_to_end(&mut buf); + let _ = tx.send(buf); + }); + } else { + let _ = tx_err.send(Vec::new()); + } + let start = Instant::now(); + loop { + match child.try_wait() { + Err(e) => { + return Err(GitError::Failed { + cmd: format!( + "git {}", + args.iter() + .map(|a| a.to_string_lossy()) + .collect::>() + .join(" ") + ), + status: "wait-error".to_string(), + stderr: e.to_string(), + }); + } + Ok(Some(status)) => { + let stdout = rx_out + .recv_timeout(Duration::from_secs(5)) + .unwrap_or_default(); + let stderr = rx_err + .recv_timeout(Duration::from_secs(5)) + .unwrap_or_default(); + return Ok(GitOutput { + status_ok: status.success(), + status_str: status.to_string(), + stdout, + stderr, + }); + } + Ok(None) => { + if start.elapsed() >= GIT_TIMEOUT { + let _ = child.kill(); + let _ = child.wait(); + return Err(GitError::Timeout( + "git clone/update-server-info".to_string(), + )); + } + thread::sleep(Duration::from_millis(10)); + } + } + } +} + +/// Export a dumb-HTTP clone dir into `out/repo.git` (issue #54). +/// +/// Runs `git clone --bare --single-branch` plus +/// `git --git-dir= update-server-info`, so plain static servers +/// (Pages/nginx/`hu13 serve`) all serve `git clone /repo.git` +/// with zero dynamic smart HTTP. No GitHub, no config. +/// +/// Failures are loud (build fails, never a silent missing dir), and an +/// existing `out/repo.git` is removed first so stale objects never survive. +pub fn export_dumb_repo(repo: &Path, branch: &str, out: &Path) -> Result { + std::fs::create_dir_all(out) + .map_err(|e| GitError::Io(format!("create out dir {}: {e}", out.to_string_lossy())))?; + let dest = out.join("repo.git"); + if dest.exists() { + std::fs::remove_dir_all(&dest) + .map_err(|e| GitError::Io(format!("clean {}: {e}", dest.to_string_lossy())))?; + } + let repo_abs = std::fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf()); + let repo_str = repo_abs.to_string_lossy().into_owned(); + let dest_str = dest.to_string_lossy().into_owned(); + let os_repo = OsString::from(&repo_str); + let os_dest = OsString::from(&dest_str); + let os_branch = OsString::from(branch); + let a_clone: &OsStr = OsStr::new("clone"); + let a_bare: &OsStr = OsStr::new("--bare"); + let a_single: &OsStr = OsStr::new("--single-branch"); + let a_branch: &OsStr = OsStr::new("--branch"); + let a_sep: &OsStr = OsStr::new("--"); + let args: Vec<&OsStr> = vec![ + a_clone, + a_bare, + a_single, + a_branch, + os_branch.as_os_str(), + a_sep, + OsStr::new(os_repo.as_os_str()), + os_dest.as_os_str(), + ]; + let out1 = run_plain_git(&args)?; + if !out1.status_ok { + let stderr = String::from_utf8_lossy(&out1.stderr).trim().to_string(); + return Err(GitError::Failed { + cmd: format!("git clone --bare --single-branch --branch {branch} repo.git"), + status: out1.status_str, + stderr, + }); + } + let git_dir_flag = OsString::from(format!("--git-dir={dest_str}")); + let a_usi: &OsStr = OsStr::new("update-server-info"); + let args2: Vec<&OsStr> = vec![git_dir_flag.as_os_str(), a_usi]; + let out2 = run_plain_git(&args2)?; + if !out2.status_ok { + let stderr = String::from_utf8_lossy(&out2.stderr).trim().to_string(); + return Err(GitError::Failed { + cmd: "git --git-dir= update-server-info".to_string(), + status: out2.status_str, + stderr, + }); + } + Ok(dest) +} + // --------------------------------------------------------------------------- // tests (std-only fixture repos) // --------------------------------------------------------------------------- diff --git a/src/main.rs b/src/main.rs index cffc87e..148c678 100644 --- a/src/main.rs +++ b/src/main.rs @@ -51,7 +51,7 @@ OPTIONS: --branch Branch to pin (else HU13_BRANCH, else try main/master) --clone-url Clone URL to advertise (else git origin remote, else none) --public-origin Public base URL of this server (else HU13_PUBLIC_ORIGIN, else unset; - when set, clone block advertises `/repo.bundle`) + when set, clone block advertises `/repo.git`) --repo-name Repo display name (else HU13_REPO_NAME, else repo dir name; --title is a deprecated alias; empty falls back, never blank) --description Site description (else HU13_DESCRIPTION, else hidden; diff --git a/src/serve.rs b/src/serve.rs index d6429f5..a119e85 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -394,6 +394,16 @@ fn content_type_for(name: &str) -> &'static str { "application/atom+xml; charset=utf-8" } else if lower.ends_with(".md") || lower.ends_with(".txt") { "text/plain; charset=utf-8" + } else if lower.ends_with("/head") + || lower.ends_with("/info/refs") + || lower.ends_with("/info/packs") + || lower.ends_with("/info/alternates") + || lower.ends_with("packed-refs") + { + // Dumb-HTTP git files (#54): plain text so any static server + // (Pages/nginx/serve) serves `repo.git` identically. Git ignores + // mime for dumb reads, but explicit beats octet-stream guessing. + "text/plain; charset=utf-8" } else if lower.ends_with(".gz") { "application/gzip" } else { diff --git a/tests/clone_origin.rs b/tests/clone_origin.rs index d5881c7..0d514a3 100644 --- a/tests/clone_origin.rs +++ b/tests/clone_origin.rs @@ -1,7 +1,8 @@ -//! Clone-from-server (m6, f0rge issue #32): +//! Clone-from-server (m6, f0rge issue #32; dumb-clone #54): //! `--public-origin` / `HU13_PUBLIC_ORIGIN` (flag > env > unset), //! clone-block both-mode asserts, serve `/repo.bundle` byte-identical, -//! live `git clone http://host:port/repo.bundle`, entrypoint passthrough. +//! live `git clone http://host:port/repo.bundle` (smart, serve-only) + +//! `git clone /repo.git` (dumb, any static server), entrypoint passthrough. use std::io::{BufRead, BufReader, Read, Write}; use std::net::TcpStream; @@ -113,15 +114,23 @@ fn public_origin_primary_is_absolute_bundle_with_snapshot_note() { let out = build_with(&repo, &["--public-origin", origin]); let index = std::fs::read_to_string(out.join("index.html")).expect("index"); assert!( - index.contains(&format!("git clone {origin}/repo.bundle")), - "primary absolute bundle cmd" + index.contains(&format!("git clone {origin}/repo.git")), + "primary dumb clone cmd" ); assert!(index.contains("exact snapshot of"), "snapshot note present"); assert!(index.contains("main"), "branch in snapshot note"); assert!(index.contains("/repo.tar.gz"), "secondary tarball link"); assert!( - !index.contains("git clone repo.bundle -b"), - "local bundle cmd replaced by absolute one" + index.contains(&format!("curl -O {origin}/repo.bundle")), + "bundle dumb download secondary" + ); + assert!( + out.join("repo.git").join("HEAD").exists(), + "repo.git emitted" + ); + assert!( + out.join("repo.git").join("info/refs").exists(), + "update-server-info ran" ); set_env("HU13_PUBLIC_ORIGIN", None); } @@ -136,14 +145,14 @@ fn public_origin_with_clone_url_shows_mirror_source() { let out = build_with(&repo, &["--public-origin", origin, "--clone-url", mirror]); let index = std::fs::read_to_string(out.join("index.html")).expect("index"); assert!( - index.contains(&format!("git clone {origin}/repo.bundle")), - "public origin still primary" + index.contains(&format!("git clone {origin}/repo.git")), + "public origin still primary (dumb)" ); assert!(index.contains("mirror source"), "mirror source label"); assert!(index.contains(mirror), "origin remote shown"); - // Primary (absolute bundle) must come before the mirror source line. + // Primary (dumb) must come before the mirror source line. let primary = index - .find(&format!("git clone {origin}/repo.bundle")) + .find(&format!("git clone {origin}/repo.git")) .expect("primary pos"); let mirror_pos = index.find("mirror source").expect("mirror pos"); assert!(primary < mirror_pos, "primary before mirror source"); @@ -159,15 +168,15 @@ fn public_origin_flag_beats_env_and_trailing_slash_stripped() { let out = build_with(&repo, &[]); let index = std::fs::read_to_string(out.join("index.html")).expect("index"); assert!( - index.contains("git clone http://env.example:39313/repo.bundle"), + index.contains("git clone http://env.example:39313/repo.git"), "env origin used, trailing slash stripped (no //)" ); - assert!(!index.contains("//repo.bundle"), "no doubled slash"); + assert!(!index.contains("//repo.git"), "no doubled slash"); // Flag beats env. let out = build_with(&repo, &["--public-origin", "http://flag.example:39313"]); let index = std::fs::read_to_string(out.join("index.html")).expect("index"); assert!( - index.contains("git clone http://flag.example:39313/repo.bundle"), + index.contains("git clone http://flag.example:39313/repo.git"), "flag wins over env" ); assert!(!index.contains("env.example"), "env suppressed by flag"); @@ -326,3 +335,56 @@ fn git_clone_over_http_bundle_works() { assert!(dest.join("hello.txt").exists(), "cloned file present"); set_env("HU13_PUBLIC_ORIGIN", None); } + +#[test] +fn dumb_repo_git_clones_file_and_http() { + // #54: dist/repo.git is a bare dumb-HTTP dir — `git clone ` and + // `git clone http://host/repo.git` both work, no smart HTTP, no GitHub. + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + set_env("HU13_PUBLIC_ORIGIN", None); + let repo = fixture_repo(); + let out = build_with(&repo, &[]); + let git_dir = out.join("repo.git"); + assert!(git_dir.join("HEAD").exists(), "HEAD emitted"); + assert!(git_dir.join("info/refs").exists(), "info/refs emitted"); + assert!( + git_dir.join("objects/info/packs").exists(), + "objects/info/packs emitted" + ); + // File-path clone (works on file:// + any static host). + let dest_parent = fresh("dumb-file"); + let dest = dest_parent.join("cloned"); + let st = Command::new("git") + .arg("clone") + .arg("-b") + .arg("main") + .arg(&git_dir) + .arg(&dest) + .status() + .expect("spawn git clone"); + assert!(st.success(), "file clone of repo.git works"); + assert!(dest.join("README.md").exists(), "cloned README present"); + // HTTP clone via hu13 serve (same bytes Pages/nginx would serve). + let inbox = fresh("inbox-dumb"); + let srv = start_server(&out, &inbox); + let raw = get(&srv.addr, "/repo.git/info/refs"); + let (head, body) = split_response(&raw); + let head = String::from_utf8_lossy(head).into_owned(); + assert!(head.contains("200"), "GET info/refs -> 200, got: {head}"); + assert!(!body.is_empty(), "info/refs non-empty"); + let url = format!("http://{}/repo.git", srv.addr); + let dest_parent = fresh("dumb-http"); + let dest = dest_parent.join("cloned"); + let st = Command::new("git") + .arg("clone") + .arg("-b") + .arg("main") + .arg(&url) + .arg(&dest) + .status() + .expect("spawn git clone"); + assert!(st.success(), "git clone {url} works"); + assert!(dest.join("README.md").exists(), "http cloned README"); + assert!(dest.join("hello.txt").exists(), "http cloned file"); + set_env("HU13_PUBLIC_ORIGIN", None); +} -- 2.56.0