close #54 dumb clone

6f379f8gl0w

all commits

Message

close #54 dumb clone

Diff

.forge/issues/d2ea6e8e-41d9-4051-9db3-cb362ec1199f/issue.md+2 −2
diff --git a/.forge/issues/d2ea6e8e-41d9-4051-9db3-cb362ec1199f/issue.md b/.forge/issues/d2ea6e8e-41d9-4051-9db3-cb362ec1199f/issue.mdindex c4b8d0c..78e0da0 100644--- a/.forge/issues/d2ea6e8e-41d9-4051-9db3-cb362ec1199f/issue.md+++ b/.forge/issues/d2ea6e8e-41d9-4051-9db3-cb362ec1199f/issue.md@@ -1,11 +1,11 @@11 ---22 id: d2ea6e8e-41d9-4051-9db3-cb362ec1199f33 title: Dumb static git clone via repo.git4-state: open4+state: closed55 labels: []66 assignees: []77 milestone: null88 created: 2026-10-27T14:03:54.332624090Z9-updated: null9+updated: 2026-10-27T14:08:48.357103283Z1010 ---1111 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.
.forge/merges/2026-10-27T14-08-45.703895943Z.md+9 −0
diff --git a/.forge/merges/2026-10-27T14-08-45.703895943Z.md b/.forge/merges/2026-10-27T14-08-45.703895943Z.mdnew file mode 100644index 0000000..b9a06b7--- /dev/null+++ b/.forge/merges/2026-10-27T14-08-45.703895943Z.md@@ -0,0 +1,9 @@1+---2+pr: 65e5642d-e72d-4514-9791-39bf0ff0f5903+applied: true4+verdict: pass5+resolved: []6+created: 2026-10-27T14:08:45.703895943Z7+---8+clean apply9+patch: ffba8ba474d14aeb8400b30999f444deeada1fb5
.forge/prs/65e5642d-e72d-4514-9791-39bf0ff0f590/patch.diff+482 −0
diff --git a/.forge/prs/65e5642d-e72d-4514-9791-39bf0ff0f590/patch.diff b/.forge/prs/65e5642d-e72d-4514-9791-39bf0ff0f590/patch.diffnew file mode 100644index 0000000..5ecc32c--- /dev/null+++ b/.forge/prs/65e5642d-e72d-4514-9791-39bf0ff0f590/patch.diff@@ -0,0 +1,482 @@1+From 3df50af3fa2023250dc356c2a0abf938039cdbfd 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+
.forge/prs/65e5642d-e72d-4514-9791-39bf0ff0f590/pr.md+13 −0
diff --git a/.forge/prs/65e5642d-e72d-4514-9791-39bf0ff0f590/pr.md b/.forge/prs/65e5642d-e72d-4514-9791-39bf0ff0f590/pr.mdnew file mode 100644index 0000000..b9b564f--- /dev/null+++ b/.forge/prs/65e5642d-e72d-4514-9791-39bf0ff0f590/pr.md@@ -0,0 +1,13 @@1+---2+id: 65e5642d-e72d-4514-9791-39bf0ff0f5903+title: issue-54-dumb-clone4+state: merged5+base: master6+head: issue-54-dumb-clone7+draft: false8+issues:9+- d2ea6e8e-41d9-4051-9db3-cb362ec1199f10+ci: pass11+created: 2026-10-27T14:08:09.695482416Z12+updated: 2026-10-27T14:08:45.703895943Z13+---