dumb static clone via repo.git (#54)

a8d9b5fgl0w

all commits

Message

dumb static clone via repo.git (#54)

Diff

.forge/issues/d2ea6e8e-41d9-4051-9db3-cb362ec1199f/issue.md+11 −0
diff --git a/.forge/issues/d2ea6e8e-41d9-4051-9db3-cb362ec1199f/issue.md b/.forge/issues/d2ea6e8e-41d9-4051-9db3-cb362ec1199f/issue.mdnew file mode 100644index 0000000..c4b8d0c--- /dev/null+++ b/.forge/issues/d2ea6e8e-41d9-4051-9db3-cb362ec1199f/issue.md@@ -0,0 +1,11 @@1+---2+id: d2ea6e8e-41d9-4051-9db3-cb362ec1199f3+title: Dumb static git clone via repo.git4+state: open5+labels: []6+assignees: []7+milestone: null8+created: 2026-10-27T14:03:54.332624090Z9+updated: null10+---11+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.
entrypoint.sh+1 −1
diff --git a/entrypoint.sh b/entrypoint.shindex 21a094f..69cc5f6 100755--- a/entrypoint.sh+++ b/entrypoint.sh@@ -9,7 +9,7 @@99 #   HU13_BRANCH     branch to build (default: main/master auto-detect)1010 #   HU13_CLONE_URL  clone URL to advertise (default: repo origin remote)1111 #   HU13_PUBLIC_ORIGIN public base URL of this server (default: unset;12-#                   when set, clone block advertises `<origin>/repo.bundle`)12+#                   when set, clone block advertises `<origin>/repo.git`)1313 #   HU13_REPO_NAME  repo display name (default: repo dir name; never blank)1414 #   HU13_DESCRIPTION1515 #                   site description (default: hidden, no empty <p>)
src/agents.rs+1 −1
diff --git a/src/agents.rs b/src/agents.rsindex d4e49f1..11a1a72 100644--- a/src/agents.rs+++ b/src/agents.rs@@ -40,7 +40,7 @@ fn agents_markdown(4040          - Site: `{site_name}`, {clone_line}\n\4141          - Raw file bytes: `/raw/<path>` (byte-identical to the repo).\n\4242          - 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\43-         - Snapshots: `/repo.tar.gz` (extract) + `/repo.bundle` (`git clone -b {branch} repo.bundle`). The bundle is authoritative when no clone URL is configured.\n\43+         - 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\4444          - Releases + artifacts: `/releases/index.html`, `/releases/<ver>.html`, downloads under `/releases/<ver>/<file>`.\n\4545          - Feed: `/feed.atom` (up to 50 entries, commits + releases newest-first — poll this, not the HTML).\n\4646          - 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\
src/build.rs+33 −13
diff --git a/src/build.rs b/src/build.rsindex 68698e3..5ca0e2a 100644--- a/src/build.rs+++ b/src/build.rs@@ -7,7 +7,9 @@77 //! `tasks/index.html`, `ci/index.html`, `releases/index.html`,88 //! `releases/<ver>.html`, `releases/<ver>/<file>` artifacts,99 //! `wiki/index.html`, `wiki/<path>.html`, `api/*.json`, `agents.html` +10-//! `agents.md`, `repo.bundle` + `repo.tar.gz`, and `style.css`.10+//! `agents.md`, `repo.bundle` + `repo.tar.gz` + `repo.git/` (dumb-HTTP,11+//! issue #54: `git clone <origin>/repo.git` works on any static server),12+//! and `style.css`.1113 //!1214 //! Bodies from the repo are semi-trusted but treated as untrusted:1315 //! markdown goes through [`markdown_to_html`], which escapes raw HTML;@@ -35,7 +37,7 @@ pub struct BuildOptions {3537     pub clone_url_flag: Option<String>,3638     /// `--public-origin` flag (else `$HU13_PUBLIC_ORIGIN`, else unset).3739     /// Base URL of this server, e.g. `http://host:39313`. When set, the38-    /// clone block advertises `git clone <origin>/repo.bundle`.40+    /// clone block advertises `git clone <origin>/repo.git` (dumb-HTTP).3941     /// Empty/whitespace falls through to unset.4042     pub public_origin_flag: Option<String>,4143     /// `--repo-name` flag (else `$HU13_REPO_NAME`, else repo dir name).@@ -630,15 +632,16 @@ pub fn file_table_html(630632 }631633 632634 /// Prominent Clone & download section for the code tab (m6, f0rge633-/// issues #20 + #32 + #34): collapsed native `<details>` (closed by634-/// default, zero JS, keyboard accessible, works file://) with stacked635-/// no-JS sections and selectable `<pre>` commands inside. When a public636-/// origin is configured the primary command is637-/// `git clone <origin>/repo.bundle` (exact snapshot note + tarball638-/// secondary, origin remote demoted to "mirror source"); otherwise639-/// bundle-first as before (`git clone <url>` first when a clone URL is640-/// configured, else local bundle) plus the bundle/tarball links with641-/// sizes and shas. The `<summary>` always shows `Clone & download`635+/// issues #20 + #32 + #34; dumb-clone #54): collapsed native `<details>`636+/// (closed by default, zero JS, keyboard accessible, works file://) with637+/// stacked no-JS sections and selectable `<pre>` commands inside. When a638+/// public origin is configured the primary command is639+/// `git clone <origin>/repo.git` (dumb-HTTP, works on Pages/nginx/serve640+/// with zero dynamic; exact snapshot note + bundle/tarball secondary,641+/// origin remote demoted to "mirror source"); otherwise dumb-local first642+/// (`git clone <url>` first when a clone URL is configured, else local643+/// `git clone repo.git`) plus the bundle/tarball links with sizes and644+/// shas. The `<summary>` always shows `Clone & download`642645 /// plus a branch@sha hint. The summary also carries the CI verdict badge643646 /// (m6 finish, f0rge #53): `Clone & download · branch @ sha · CI badge`644647 /// so the standalone repo-meta branch/CI line is gone (no duplication).@@ -687,16 +690,24 @@ pub fn clone_block(687690     );688691     if let Some(origin) = public_origin.map(str::trim).filter(|s| !s.is_empty()) {689692         let base = origin.trim_end_matches('/');693+        let dumb_url = format!("{base}/repo.git");690694         let bundle_url = format!("{base}/repo.bundle");691695         let tarball_url = format!("{base}/repo.tar.gz");692696         out.push_str("<h3>Clone with git</h3>\n");693697         out.push_str(&format!(694698             "<pre class=\"clone-cmd\"><code>git clone {}</code></pre>\n\695699              <p>exact snapshot of <code>{}</code> @ <code>{}</code>.</p>\n",696-            esc(&bundle_url),700+            esc(&dumb_url),697701             esc(branch),698702             esc(short_sha)699703         ));704+        out.push_str("<h3>Download the snapshot</h3>\n");705+        out.push_str(&format!(706+            "<pre class=\"clone-cmd\"><code>curl -O {}</code></pre>\n\707+             <pre class=\"clone-cmd\"><code>git clone repo.bundle -b {}</code></pre>\n<p>{bundle_link}</p>\n",708+            esc(&bundle_url),709+            esc(branch)710+        ));700711         out.push_str("<h3>Download the tarball</h3>\n");701712         out.push_str(&format!(702713             "<pre class=\"clone-cmd\"><code>curl -O {}</code></pre>\n<p>{tarball_link}</p>\n",@@ -718,6 +729,12 @@ pub fn clone_block(718729             esc(url)719730         ));720731     }732+    out.push_str("<h3>Clone this mirror</h3>\n");733+    out.push_str(&format!(734+        "<pre class=\"clone-cmd\"><code>git clone repo.git -b {}</code></pre>\n\735+         <p>dumb-HTTP checkout served from this server; ask the host for its address.</p>\n",736+        esc(branch)737+    ));721738     out.push_str("<h3>Clone the bundle</h3>\n");722739     out.push_str(&format!(723740         "<pre class=\"clone-cmd\"><code>git clone repo.bundle -b {}</code></pre>\n<p>{bundle_link}</p>\n",@@ -1067,8 +1084,11 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu10671084     // Mailbox forms (m4, issue #9): free textboxes, honeypot, license box.10681085     crate::inbox::render_forms(out, &name, &nav, &branch, &short_sha, desc_opt)?;10691086 1070-    // Snapshot last so the index can link sizes.1087+    // Snapshot last so the index can link sizes. Dumb-HTTP repo.git (#54)1088+    // rides alongside the bundle/tarball so `git clone <origin>/repo.git`1089+    // works on any static server (Pages/nginx/serve), no smart HTTP.10711090     let snap = gitx::snapshot(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;1091+    gitx::export_dumb_repo(repo, &branch, out).map_err(|e| format!("hu13: {e}"))?;10721092     let bundle_size = std::fs::metadata(&snap.bundle)10731093         .map(|m| m.len())10741094         .unwrap_or(0);
src/gitx.rs+134 −0
diff --git a/src/gitx.rs b/src/gitx.rsindex 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<Snapshot, GitEr767767     Ok(Snapshot { bundle, tarball })768768 }769769 770+fn run_plain_git(args: &[&OsStr]) -> Result<GitOutput, GitError> {771+    let mut cmd = Command::new("git");772+    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());773+    let mut child = cmd.spawn().map_err(|e| GitError::Spawn(e.to_string()))?;774+    let stdout_handle = child.stdout.take();775+    let stderr_handle = child.stderr.take();776+    let (tx_out, rx_out) = mpsc::channel::<Vec<u8>>();777+    let (tx_err, rx_err) = mpsc::channel::<Vec<u8>>();778+    if let Some(mut out) = stdout_handle {779+        let tx = tx_out;780+        thread::spawn(move || {781+            let mut buf = Vec::new();782+            let _ = out.read_to_end(&mut buf);783+            let _ = tx.send(buf);784+        });785+    } else {786+        let _ = tx_out.send(Vec::new());787+    }788+    if let Some(mut err) = stderr_handle {789+        let tx = tx_err;790+        thread::spawn(move || {791+            let mut buf = Vec::new();792+            let _ = err.read_to_end(&mut buf);793+            let _ = tx.send(buf);794+        });795+    } else {796+        let _ = tx_err.send(Vec::new());797+    }798+    let start = Instant::now();799+    loop {800+        match child.try_wait() {801+            Err(e) => {802+                return Err(GitError::Failed {803+                    cmd: format!(804+                        "git {}",805+                        args.iter()806+                            .map(|a| a.to_string_lossy())807+                            .collect::<Vec<_>>()808+                            .join(" ")809+                    ),810+                    status: "wait-error".to_string(),811+                    stderr: e.to_string(),812+                });813+            }814+            Ok(Some(status)) => {815+                let stdout = rx_out816+                    .recv_timeout(Duration::from_secs(5))817+                    .unwrap_or_default();818+                let stderr = rx_err819+                    .recv_timeout(Duration::from_secs(5))820+                    .unwrap_or_default();821+                return Ok(GitOutput {822+                    status_ok: status.success(),823+                    status_str: status.to_string(),824+                    stdout,825+                    stderr,826+                });827+            }828+            Ok(None) => {829+                if start.elapsed() >= GIT_TIMEOUT {830+                    let _ = child.kill();831+                    let _ = child.wait();832+                    return Err(GitError::Timeout(833+                        "git clone/update-server-info".to_string(),834+                    ));835+                }836+                thread::sleep(Duration::from_millis(10));837+            }838+        }839+    }840+}841+842+/// Export a dumb-HTTP clone dir into `out/repo.git` (issue #54).843+///844+/// Runs `git clone --bare --single-branch` plus845+/// `git --git-dir=<dest> update-server-info`, so plain static servers846+/// (Pages/nginx/`hu13 serve`) all serve `git clone <origin>/repo.git`847+/// with zero dynamic smart HTTP. No GitHub, no config.848+///849+/// Failures are loud (build fails, never a silent missing dir), and an850+/// existing `out/repo.git` is removed first so stale objects never survive.851+pub fn export_dumb_repo(repo: &Path, branch: &str, out: &Path) -> Result<PathBuf, GitError> {852+    std::fs::create_dir_all(out)853+        .map_err(|e| GitError::Io(format!("create out dir {}: {e}", out.to_string_lossy())))?;854+    let dest = out.join("repo.git");855+    if dest.exists() {856+        std::fs::remove_dir_all(&dest)857+            .map_err(|e| GitError::Io(format!("clean {}: {e}", dest.to_string_lossy())))?;858+    }859+    let repo_abs = std::fs::canonicalize(repo).unwrap_or_else(|_| repo.to_path_buf());860+    let repo_str = repo_abs.to_string_lossy().into_owned();861+    let dest_str = dest.to_string_lossy().into_owned();862+    let os_repo = OsString::from(&repo_str);863+    let os_dest = OsString::from(&dest_str);864+    let os_branch = OsString::from(branch);865+    let a_clone: &OsStr = OsStr::new("clone");866+    let a_bare: &OsStr = OsStr::new("--bare");867+    let a_single: &OsStr = OsStr::new("--single-branch");868+    let a_branch: &OsStr = OsStr::new("--branch");869+    let a_sep: &OsStr = OsStr::new("--");870+    let args: Vec<&OsStr> = vec![871+        a_clone,872+        a_bare,873+        a_single,874+        a_branch,875+        os_branch.as_os_str(),876+        a_sep,877+        OsStr::new(os_repo.as_os_str()),878+        os_dest.as_os_str(),879+    ];880+    let out1 = run_plain_git(&args)?;881+    if !out1.status_ok {882+        let stderr = String::from_utf8_lossy(&out1.stderr).trim().to_string();883+        return Err(GitError::Failed {884+            cmd: format!("git clone --bare --single-branch --branch {branch} <repo> repo.git"),885+            status: out1.status_str,886+            stderr,887+        });888+    }889+    let git_dir_flag = OsString::from(format!("--git-dir={dest_str}"));890+    let a_usi: &OsStr = OsStr::new("update-server-info");891+    let args2: Vec<&OsStr> = vec![git_dir_flag.as_os_str(), a_usi];892+    let out2 = run_plain_git(&args2)?;893+    if !out2.status_ok {894+        let stderr = String::from_utf8_lossy(&out2.stderr).trim().to_string();895+        return Err(GitError::Failed {896+            cmd: "git --git-dir=<dest> update-server-info".to_string(),897+            status: out2.status_str,898+            stderr,899+        });900+    }901+    Ok(dest)902+}903+770904 // ---------------------------------------------------------------------------771905 // tests (std-only fixture repos)772906 // ---------------------------------------------------------------------------
src/main.rs+1 −1
diff --git a/src/main.rs b/src/main.rsindex cffc87e..148c678 100644--- a/src/main.rs+++ b/src/main.rs@@ -51,7 +51,7 @@ OPTIONS:5151         --branch <NAME>    Branch to pin (else HU13_BRANCH, else try main/master)5252         --clone-url <URL>  Clone URL to advertise (else git origin remote, else none)5353         --public-origin <URL> Public base URL of this server (else HU13_PUBLIC_ORIGIN, else unset;54-                           when set, clone block advertises `<origin>/repo.bundle`)54+                           when set, clone block advertises `<origin>/repo.git`)5555         --repo-name <S>    Repo display name (else HU13_REPO_NAME, else repo dir name;5656                            --title is a deprecated alias; empty falls back, never blank)5757         --description <S>  Site description (else HU13_DESCRIPTION, else hidden;
src/serve.rs+10 −0
diff --git a/src/serve.rs b/src/serve.rsindex d6429f5..a119e85 100644--- a/src/serve.rs+++ b/src/serve.rs@@ -394,6 +394,16 @@ fn content_type_for(name: &str) -> &'static str {394394         "application/atom+xml; charset=utf-8"395395     } else if lower.ends_with(".md") || lower.ends_with(".txt") {396396         "text/plain; charset=utf-8"397+    } else if lower.ends_with("/head")398+        || lower.ends_with("/info/refs")399+        || lower.ends_with("/info/packs")400+        || lower.ends_with("/info/alternates")401+        || lower.ends_with("packed-refs")402+    {403+        // Dumb-HTTP git files (#54): plain text so any static server404+        // (Pages/nginx/serve) serves `repo.git` identically. Git ignores405+        // mime for dumb reads, but explicit beats octet-stream guessing.406+        "text/plain; charset=utf-8"397407     } else if lower.ends_with(".gz") {398408         "application/gzip"399409     } else {
tests/clone_origin.rs+75 −13
diff --git a/tests/clone_origin.rs b/tests/clone_origin.rsindex d5881c7..0d514a3 100644--- a/tests/clone_origin.rs+++ b/tests/clone_origin.rs@@ -1,7 +1,8 @@1-//! Clone-from-server (m6, f0rge issue #32):1+//! Clone-from-server (m6, f0rge issue #32; dumb-clone #54):22 //! `--public-origin` / `HU13_PUBLIC_ORIGIN` (flag > env > unset),33 //! clone-block both-mode asserts, serve `/repo.bundle` byte-identical,4-//! live `git clone http://host:port/repo.bundle`, entrypoint passthrough.4+//! live `git clone http://host:port/repo.bundle` (smart, serve-only) +5+//! `git clone <origin>/repo.git` (dumb, any static server), entrypoint passthrough.56 67 use std::io::{BufRead, BufReader, Read, Write};78 use std::net::TcpStream;@@ -113,15 +114,23 @@ fn public_origin_primary_is_absolute_bundle_with_snapshot_note() {113114     let out = build_with(&repo, &["--public-origin", origin]);114115     let index = std::fs::read_to_string(out.join("index.html")).expect("index");115116     assert!(116-        index.contains(&format!("git clone {origin}/repo.bundle")),117-        "primary absolute bundle cmd"117+        index.contains(&format!("git clone {origin}/repo.git")),118+        "primary dumb clone cmd"118119     );119120     assert!(index.contains("exact snapshot of"), "snapshot note present");120121     assert!(index.contains("main"), "branch in snapshot note");121122     assert!(index.contains("/repo.tar.gz"), "secondary tarball link");122123     assert!(123-        !index.contains("git clone repo.bundle -b"),124-        "local bundle cmd replaced by absolute one"124+        index.contains(&format!("curl -O {origin}/repo.bundle")),125+        "bundle dumb download secondary"126+    );127+    assert!(128+        out.join("repo.git").join("HEAD").exists(),129+        "repo.git emitted"130+    );131+    assert!(132+        out.join("repo.git").join("info/refs").exists(),133+        "update-server-info ran"125134     );126135     set_env("HU13_PUBLIC_ORIGIN", None);127136 }@@ -136,14 +145,14 @@ fn public_origin_with_clone_url_shows_mirror_source() {136145     let out = build_with(&repo, &["--public-origin", origin, "--clone-url", mirror]);137146     let index = std::fs::read_to_string(out.join("index.html")).expect("index");138147     assert!(139-        index.contains(&format!("git clone {origin}/repo.bundle")),140-        "public origin still primary"148+        index.contains(&format!("git clone {origin}/repo.git")),149+        "public origin still primary (dumb)"141150     );142151     assert!(index.contains("mirror source"), "mirror source label");143152     assert!(index.contains(mirror), "origin remote shown");144-    // Primary (absolute bundle) must come before the mirror source line.153+    // Primary (dumb) must come before the mirror source line.145154     let primary = index146-        .find(&format!("git clone {origin}/repo.bundle"))155+        .find(&format!("git clone {origin}/repo.git"))147156         .expect("primary pos");148157     let mirror_pos = index.find("mirror source").expect("mirror pos");149158     assert!(primary < mirror_pos, "primary before mirror source");@@ -159,15 +168,15 @@ fn public_origin_flag_beats_env_and_trailing_slash_stripped() {159168     let out = build_with(&repo, &[]);160169     let index = std::fs::read_to_string(out.join("index.html")).expect("index");161170     assert!(162-        index.contains("git clone http://env.example:39313/repo.bundle"),171+        index.contains("git clone http://env.example:39313/repo.git"),163172         "env origin used, trailing slash stripped (no //)"164173     );165-    assert!(!index.contains("//repo.bundle"), "no doubled slash");174+    assert!(!index.contains("//repo.git"), "no doubled slash");166175     // Flag beats env.167176     let out = build_with(&repo, &["--public-origin", "http://flag.example:39313"]);168177     let index = std::fs::read_to_string(out.join("index.html")).expect("index");169178     assert!(170-        index.contains("git clone http://flag.example:39313/repo.bundle"),179+        index.contains("git clone http://flag.example:39313/repo.git"),171180         "flag wins over env"172181     );173182     assert!(!index.contains("env.example"), "env suppressed by flag");@@ -326,3 +335,56 @@ fn git_clone_over_http_bundle_works() {326335     assert!(dest.join("hello.txt").exists(), "cloned file present");327336     set_env("HU13_PUBLIC_ORIGIN", None);328337 }338+339+#[test]340+fn dumb_repo_git_clones_file_and_http() {341+    // #54: dist/repo.git is a bare dumb-HTTP dir — `git clone <path>` and342+    // `git clone http://host/repo.git` both work, no smart HTTP, no GitHub.343+    let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());344+    set_env("HU13_PUBLIC_ORIGIN", None);345+    let repo = fixture_repo();346+    let out = build_with(&repo, &[]);347+    let git_dir = out.join("repo.git");348+    assert!(git_dir.join("HEAD").exists(), "HEAD emitted");349+    assert!(git_dir.join("info/refs").exists(), "info/refs emitted");350+    assert!(351+        git_dir.join("objects/info/packs").exists(),352+        "objects/info/packs emitted"353+    );354+    // File-path clone (works on file:// + any static host).355+    let dest_parent = fresh("dumb-file");356+    let dest = dest_parent.join("cloned");357+    let st = Command::new("git")358+        .arg("clone")359+        .arg("-b")360+        .arg("main")361+        .arg(&git_dir)362+        .arg(&dest)363+        .status()364+        .expect("spawn git clone");365+    assert!(st.success(), "file clone of repo.git works");366+    assert!(dest.join("README.md").exists(), "cloned README present");367+    // HTTP clone via hu13 serve (same bytes Pages/nginx would serve).368+    let inbox = fresh("inbox-dumb");369+    let srv = start_server(&out, &inbox);370+    let raw = get(&srv.addr, "/repo.git/info/refs");371+    let (head, body) = split_response(&raw);372+    let head = String::from_utf8_lossy(head).into_owned();373+    assert!(head.contains("200"), "GET info/refs -> 200, got: {head}");374+    assert!(!body.is_empty(), "info/refs non-empty");375+    let url = format!("http://{}/repo.git", srv.addr);376+    let dest_parent = fresh("dumb-http");377+    let dest = dest_parent.join("cloned");378+    let st = Command::new("git")379+        .arg("clone")380+        .arg("-b")381+        .arg("main")382+        .arg(&url)383+        .arg(&dest)384+        .status()385+        .expect("spawn git clone");386+    assert!(st.success(), "git clone {url} works");387+    assert!(dest.join("README.md").exists(), "http cloned README");388+    assert!(dest.join("hello.txt").exists(), "http cloned file");389+    set_env("HU13_PUBLIC_ORIGIN", None);390+}