From eb52baabc17dd9e2637a89e87fa690ca2f8e01bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Geoffrey=20Gu=C3=A9ret?= Date: Fri, 19 Jun 2026 14:30:35 +0200 Subject: [PATCH] feat: serve non-bare repositories, add --bare-only flag Discovery now records any repository gix can open, not just bare ones, so a directory of working clones can be served for clone/fetch without converting each repo to bare. A recorded repository is no longer descended into, which also avoids walking working-tree noise such as target/ or node_modules/. Pass --bare-only to restore the previous behavior of serving bare repositories exclusively. --- README.md | 9 +- crates/git-server-bench/benches/concurrent.rs | 2 +- crates/git-server-bench/benches/git_clone.rs | 2 +- crates/git-server-bench/benches/http_clone.rs | 2 +- crates/git-server-core/src/discovery.rs | 92 +++++++++++++++---- crates/git-server-http/src/handlers.rs | 2 +- crates/git-server-http/tests/helpers/mod.rs | 54 ++++++++++- crates/git-server-http/tests/integration.rs | 43 ++++++++- crates/git-server/src/main.rs | 8 +- 9 files changed, 184 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 0fa28f1..5fb2f90 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ Dependency-free smart HTTP Git server for local testing. -A standalone server that serves bare Git repositories over HTTP for `git clone` and `git fetch`, without requiring the `git` binary at runtime. Built with [gitoxide](https://github.com/GitoxideLabs/gitoxide) for native Git operations and [Axum](https://github.com/tokio-rs/axum) / [Tokio](https://tokio.rs) for asynchronous HTTP. +A standalone server that serves Git repositories over HTTP for `git clone` and `git fetch`, without requiring the `git` binary at runtime. Built with [gitoxide](https://github.com/GitoxideLabs/gitoxide) for native Git operations and [Axum](https://github.com/tokio-rs/axum) / [Tokio](https://tokio.rs) for asynchronous HTTP. ## Features - **Single binary, no git required** -- all Git operations are handled natively, no runtime dependencies -- **Multi-repository** -- serves all bare repos under a root directory with configurable scan depth +- **Multi-repository** -- serves all repos (bare or with a working tree) under a root directory with configurable scan depth - **JSON API** -- repository listing endpoint for programmatic discovery - **Structured logging** -- text or JSON log output via tracing @@ -16,7 +16,7 @@ A standalone server that serves bare Git repositories over HTTP for `git clone` ```sh cargo install --path crates/git-server -# Serve all bare repos under ./repos +# Serve all repos under ./repos git-server ./repos # Clone from the server @@ -29,7 +29,7 @@ git clone http://127.0.0.1:3000/my-project.git git-server [OPTIONS] Arguments: - Root directory containing bare Git repositories + Root directory to scan for Git repositories Options: -b, --bind Bind address [default: 127.0.0.1] @@ -38,6 +38,7 @@ Options: --log-format Log format: text or json [default: text] -w, --workers Number of Tokio worker threads --max-depth Max directory depth for repo discovery [default: 3] + --bare-only Only serve bare repositories (skip working trees) ``` ## API diff --git a/crates/git-server-bench/benches/concurrent.rs b/crates/git-server-bench/benches/concurrent.rs index 9471528..364db38 100644 --- a/crates/git-server-bench/benches/concurrent.rs +++ b/crates/git-server-bench/benches/concurrent.rs @@ -55,7 +55,7 @@ fn bench_concurrent_clones(c: &mut Criterion) { async fn start_server( repo_path: &Path, ) -> (SocketAddr, oneshot::Sender<()>, tokio::task::JoinHandle<()>) { - let store = RepoStore::discover(repo_path.parent().unwrap().to_path_buf(), 0).unwrap(); + let store = RepoStore::discover(repo_path.parent().unwrap().to_path_buf(), 0, false).unwrap(); let router = git_server_http::router(store); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); diff --git a/crates/git-server-bench/benches/git_clone.rs b/crates/git-server-bench/benches/git_clone.rs index ee2b4e2..68574b8 100644 --- a/crates/git-server-bench/benches/git_clone.rs +++ b/crates/git-server-bench/benches/git_clone.rs @@ -49,7 +49,7 @@ fn bench_git_clone(c: &mut Criterion) { async fn start_server( repo_path: &Path, ) -> (SocketAddr, oneshot::Sender<()>, tokio::task::JoinHandle<()>) { - let store = RepoStore::discover(repo_path.parent().unwrap().to_path_buf(), 0).unwrap(); + let store = RepoStore::discover(repo_path.parent().unwrap().to_path_buf(), 0, false).unwrap(); let router = git_server_http::router(store); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); diff --git a/crates/git-server-bench/benches/http_clone.rs b/crates/git-server-bench/benches/http_clone.rs index 4f40b00..176c4ab 100644 --- a/crates/git-server-bench/benches/http_clone.rs +++ b/crates/git-server-bench/benches/http_clone.rs @@ -128,7 +128,7 @@ fn build_upload_pack_request(oid: &str) -> Vec { async fn start_server( repo_path: &Path, ) -> (SocketAddr, oneshot::Sender<()>, tokio::task::JoinHandle<()>) { - let store = RepoStore::discover(repo_path.parent().unwrap().to_path_buf(), 0).unwrap(); + let store = RepoStore::discover(repo_path.parent().unwrap().to_path_buf(), 0, false).unwrap(); let router = git_server_http::router(store); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); diff --git a/crates/git-server-core/src/discovery.rs b/crates/git-server-core/src/discovery.rs index 0e4d5f3..215d609 100644 --- a/crates/git-server-core/src/discovery.rs +++ b/crates/git-server-core/src/discovery.rs @@ -8,7 +8,7 @@ use crate::error::{Error, Result}; const DEFAULT_GIT_DESCRIPTION: &str = "Unnamed repository; edit this file 'description' to name the repository."; -/// Information about a discovered bare git repository. +/// Information about a discovered git repository (bare or with a working tree). #[derive(Debug, Clone, Serialize)] pub struct RepoInfo { pub name: String, @@ -27,17 +27,22 @@ pub struct RepoStore { } impl RepoStore { - /// Scan `root` recursively up to `max_depth` levels for bare git repositories. + /// Scan `root` recursively up to `max_depth` levels for git repositories. /// /// `max_depth = 0` means only repositories directly inside `root`. /// `max_depth = 3` means up to 3 levels of subdirectories below `root`. - pub fn discover(root: PathBuf, max_depth: u32) -> Result { + /// + /// When `bare_only` is true, only bare repositories are recorded and a + /// directory with a working tree is descended into as an ordinary + /// directory. When false, any repository (bare or with a working tree) is + /// recorded. + pub fn discover(root: PathBuf, max_depth: u32, bare_only: bool) -> Result { let root = root.canonicalize()?; let mut repos = Vec::new(); // Walk starts at depth 0 (root itself). We scan children of root at depth 1, // and allow descending up to max_depth subdirectory levels below root. - walk_dir(&root, &root, 0, max_depth, &mut repos)?; + walk_dir(&root, &root, 0, max_depth, bare_only, &mut repos)?; repos.sort_by(|a, b| a.relative_path.cmp(&b.relative_path)); @@ -67,7 +72,12 @@ impl RepoStore { } } -/// Recursively walk `dir`, recording bare repositories. +/// Recursively walk `dir`, recording git repositories. +/// +/// A recorded repository is not descended into, so repositories nested inside +/// another repository's working tree are not exposed. When `bare_only` is true, +/// only bare repositories are recorded; a working-tree repository is then +/// treated as an ordinary directory and descended into. /// /// `depth` is the current depth relative to `root` (root itself is depth 0). /// Entries *inside* root are at depth 1. We descend while `depth <= max_depth`. @@ -76,6 +86,7 @@ fn walk_dir( dir: &Path, depth: u32, max_depth: u32, + bare_only: bool, repos: &mut Vec, ) -> Result<()> { let read = match fs::read_dir(dir) { @@ -97,9 +108,10 @@ fn walk_dir( continue; } - // Try to open as a git repository. + // Try to open as a git repository. In bare-only mode, skip repositories + // that have a working tree. match gix::open(&path) { - Ok(repo) if repo.is_bare() => { + Ok(repo) if !bare_only || repo.is_bare() => { let absolute_path = path.canonicalize()?; let relative_path = absolute_path .strip_prefix(root) @@ -121,9 +133,10 @@ fn walk_dir( // Do not descend into a repository directory. } _ => { - // Not a bare repo (or open failed). Descend if within max_depth. + // Not a repository to record here (open failed, or a working-tree + // repo in bare-only mode). Descend if within max_depth. if depth < max_depth { - walk_dir(root, &path, depth + 1, max_depth, repos)?; + walk_dir(root, &path, depth + 1, max_depth, bare_only, repos)?; } } } @@ -163,13 +176,20 @@ mod tests { .expect("git init --bare failed"); } + fn create_working_repo(path: &Path) { + Command::new("git") + .args(["init", path.to_str().unwrap()]) + .output() + .expect("git init failed"); + } + #[test] fn discover_finds_bare_repos() { let dir = TempDir::new().unwrap(); create_bare_repo(&dir.path().join("alpha.git")); create_bare_repo(&dir.path().join("beta.git")); - let store = RepoStore::discover(dir.path().to_path_buf(), 0).unwrap(); + let store = RepoStore::discover(dir.path().to_path_buf(), 0, false).unwrap(); assert_eq!(store.list().len(), 2); } @@ -180,7 +200,7 @@ mod tests { std::fs::create_dir_all(&repo_path).unwrap(); create_bare_repo(&repo_path); - let store = RepoStore::discover(dir.path().to_path_buf(), 1).unwrap(); + let store = RepoStore::discover(dir.path().to_path_buf(), 1, false).unwrap(); assert_eq!(store.list().len(), 1); assert_eq!(store.list()[0].relative_path, "org/project.git"); } @@ -193,11 +213,11 @@ mod tests { create_bare_repo(&deep); // max_depth 2 should not find it (it is 3 levels below root) - let store_shallow = RepoStore::discover(dir.path().to_path_buf(), 2).unwrap(); + let store_shallow = RepoStore::discover(dir.path().to_path_buf(), 2, false).unwrap(); assert_eq!(store_shallow.list().len(), 0); // max_depth 3 should find it - let store_deep = RepoStore::discover(dir.path().to_path_buf(), 3).unwrap(); + let store_deep = RepoStore::discover(dir.path().to_path_buf(), 3, false).unwrap(); assert_eq!(store_deep.list().len(), 1); } @@ -209,7 +229,7 @@ mod tests { std::fs::create_dir_all(&nested).unwrap(); create_bare_repo(&nested); - let store = RepoStore::discover(dir.path().to_path_buf(), 0).unwrap(); + let store = RepoStore::discover(dir.path().to_path_buf(), 0, false).unwrap(); assert_eq!(store.list().len(), 1); assert_eq!(store.list()[0].relative_path, "root-level.git"); } @@ -220,16 +240,52 @@ mod tests { // A plain directory -- not a git repo std::fs::create_dir(dir.path().join("just-a-dir")).unwrap(); - let store = RepoStore::discover(dir.path().to_path_buf(), 0).unwrap(); + let store = RepoStore::discover(dir.path().to_path_buf(), 0, false).unwrap(); assert_eq!(store.list().len(), 0); } + #[test] + fn discover_finds_non_bare_repos() { + let dir = TempDir::new().unwrap(); + create_working_repo(&dir.path().join("myproject")); + + let store = RepoStore::discover(dir.path().to_path_buf(), 0, false).unwrap(); + assert_eq!(store.list().len(), 1); + assert_eq!(store.list()[0].relative_path, "myproject"); + assert_eq!(store.list()[0].name, "myproject"); + } + + #[test] + fn discover_does_not_descend_into_working_tree() { + // A bare repo nested inside a working repo's tree must not be exposed: + // discovery stops at the repository boundary. + let dir = TempDir::new().unwrap(); + let project = dir.path().join("project"); + create_working_repo(&project); + create_bare_repo(&project.join("vendor").join("inner.git")); + + let store = RepoStore::discover(dir.path().to_path_buf(), 3, false).unwrap(); + assert_eq!(store.list().len(), 1); + assert_eq!(store.list()[0].relative_path, "project"); + } + + #[test] + fn discover_bare_only_skips_non_bare_repos() { + let dir = TempDir::new().unwrap(); + create_bare_repo(&dir.path().join("served.git")); + create_working_repo(&dir.path().join("ignored")); + + let store = RepoStore::discover(dir.path().to_path_buf(), 0, true).unwrap(); + assert_eq!(store.list().len(), 1); + assert_eq!(store.list()[0].relative_path, "served.git"); + } + #[test] fn resolve_existing_repo() { let dir = TempDir::new().unwrap(); create_bare_repo(&dir.path().join("myrepo.git")); - let store = RepoStore::discover(dir.path().to_path_buf(), 0).unwrap(); + let store = RepoStore::discover(dir.path().to_path_buf(), 0, false).unwrap(); let info = store.resolve("myrepo.git").unwrap(); assert_eq!(info.relative_path, "myrepo.git"); assert_eq!(info.name, "myrepo.git"); @@ -240,7 +296,7 @@ mod tests { let dir = TempDir::new().unwrap(); create_bare_repo(&dir.path().join("exists.git")); - let store = RepoStore::discover(dir.path().to_path_buf(), 0).unwrap(); + let store = RepoStore::discover(dir.path().to_path_buf(), 0, false).unwrap(); let err = store.resolve("nope.git").unwrap_err(); assert!(matches!(err, Error::RepoNotFound(_))); } @@ -252,7 +308,7 @@ mod tests { create_bare_repo(&repo_path); std::fs::write(repo_path.join("description"), "A test repository\n").unwrap(); - let store = RepoStore::discover(dir.path().to_path_buf(), 0).unwrap(); + let store = RepoStore::discover(dir.path().to_path_buf(), 0, false).unwrap(); assert_eq!(store.list().len(), 1); assert_eq!( store.list()[0].description.as_deref(), diff --git a/crates/git-server-http/src/handlers.rs b/crates/git-server-http/src/handlers.rs index f92fc6d..888f42b 100644 --- a/crates/git-server-http/src/handlers.rs +++ b/crates/git-server-http/src/handlers.rs @@ -134,7 +134,7 @@ mod tests { fn test_store(tmp: &TempDir) -> RepoStore { create_bare_repo(&tmp.path().join("test.git")); - RepoStore::discover(tmp.path().to_path_buf(), 0).unwrap() + RepoStore::discover(tmp.path().to_path_buf(), 0, false).unwrap() } #[tokio::test] diff --git a/crates/git-server-http/tests/helpers/mod.rs b/crates/git-server-http/tests/helpers/mod.rs index 4db04fb..59722a4 100644 --- a/crates/git-server-http/tests/helpers/mod.rs +++ b/crates/git-server-http/tests/helpers/mod.rs @@ -18,7 +18,7 @@ pub struct TestServer { impl TestServer { /// Start a test server serving repositories discovered under `root`. pub async fn start(root: &Path) -> Self { - let store = RepoStore::discover(root.to_path_buf(), 0).expect("discover repos"); + let store = RepoStore::discover(root.to_path_buf(), 0, false).expect("discover repos"); let router = git_server_http::router(store); let listener = TcpListener::bind("127.0.0.1:0") @@ -143,3 +143,55 @@ pub fn create_bare_repo_with_commits(root: &Path, name: &str, commit_count: usiz bare_path } + +/// Create a non-bare git repository (with a working tree) and a given number of commits. +/// +/// Commits land on the `main` branch and each adds a file named `fileN.txt`. +/// Returns the path to the repository. +// Shared test helper: not exercised by every test binary that compiles this module. +#[allow(dead_code)] +pub fn create_working_repo_with_commits(root: &Path, name: &str, commit_count: usize) -> PathBuf { + let repo_path = root.join(name); + + let out = Command::new("git") + .args(["init", "-b", "main", repo_path.to_str().unwrap()]) + .output() + .expect("git init"); + assert!(out.status.success(), "git init failed: {:?}", out); + + for (key, val) in [("user.name", "Test User"), ("user.email", "test@test.com")] { + let out = Command::new("git") + .args(["config", key, val]) + .current_dir(&repo_path) + .output() + .expect("git config"); + assert!(out.status.success(), "git config failed: {:?}", out); + } + + for i in 0..commit_count { + let filename = format!("file{i}.txt"); + let content = format!("content of file {i}\n"); + std::fs::write(repo_path.join(&filename), content).expect("write file"); + + let out = Command::new("git") + .args(["add", &filename]) + .current_dir(&repo_path) + .output() + .expect("git add"); + assert!(out.status.success(), "git add failed: {:?}", out); + + let msg = format!("commit {i}"); + let out = Command::new("git") + .args(["commit", "-m", &msg]) + .current_dir(&repo_path) + .env("GIT_AUTHOR_NAME", "Test User") + .env("GIT_AUTHOR_EMAIL", "test@test.com") + .env("GIT_COMMITTER_NAME", "Test User") + .env("GIT_COMMITTER_EMAIL", "test@test.com") + .output() + .expect("git commit"); + assert!(out.status.success(), "git commit failed: {:?}", out); + } + + repo_path +} diff --git a/crates/git-server-http/tests/integration.rs b/crates/git-server-http/tests/integration.rs index 0fdb261..e362663 100644 --- a/crates/git-server-http/tests/integration.rs +++ b/crates/git-server-http/tests/integration.rs @@ -4,7 +4,7 @@ use std::process::Command; use tempfile::TempDir; -use helpers::{TestServer, create_bare_repo_with_commits}; +use helpers::{TestServer, create_bare_repo_with_commits, create_working_repo_with_commits}; #[tokio::test(flavor = "multi_thread")] async fn clone_bare_repo() { @@ -47,6 +47,47 @@ async fn clone_bare_repo() { server.stop().await; } +#[tokio::test(flavor = "multi_thread")] +async fn clone_non_bare_repo() { + let root = TempDir::new().unwrap(); + create_working_repo_with_commits(root.path(), "myproject", 3); + + let server = TestServer::start(root.path()).await; + let clone_dir = TempDir::new().unwrap(); + let clone_path = clone_dir.path().join("cloned"); + + let url = server.url("myproject"); + let cp = clone_path.clone(); + let out = tokio::task::spawn_blocking(move || { + Command::new("git") + .args(["clone", &url, cp.to_str().unwrap()]) + .output() + .expect("git clone") + }) + .await + .unwrap(); + assert!( + out.status.success(), + "git clone failed: stdout={}, stderr={}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr), + ); + + // Verify we have 3 commits + let out = Command::new("git") + .args(["log", "--oneline"]) + .current_dir(&clone_path) + .output() + .expect("git log"); + assert!(out.status.success(), "git log failed"); + + let log = String::from_utf8_lossy(&out.stdout); + let lines: Vec<&str> = log.trim().lines().collect(); + assert_eq!(lines.len(), 3, "expected 3 commits, got: {log}"); + + server.stop().await; +} + #[tokio::test(flavor = "multi_thread")] async fn fetch_new_commits() { let root = TempDir::new().unwrap(); diff --git a/crates/git-server/src/main.rs b/crates/git-server/src/main.rs index ae59ffc..225b487 100644 --- a/crates/git-server/src/main.rs +++ b/crates/git-server/src/main.rs @@ -12,7 +12,7 @@ use git_server_core::discovery::RepoStore; about = "Standalone smart HTTP Git server" )] struct Cli { - /// Root directory containing bare Git repositories + /// Root directory to scan for Git repositories root: PathBuf, /// Bind address @@ -38,6 +38,10 @@ struct Cli { /// Max directory depth for repo discovery #[arg(long, default_value_t = 3)] max_depth: u32, + + /// Only serve bare repositories (skip repositories that have a working tree) + #[arg(long)] + bare_only: bool, } #[derive(Clone, clap::ValueEnum)] @@ -69,7 +73,7 @@ fn main() -> anyhow::Result<()> { anyhow::bail!("root path '{}' is not a directory", cli.root.display()); } - let store = RepoStore::discover(cli.root.clone(), cli.max_depth)?; + let store = RepoStore::discover(cli.root.clone(), cli.max_depth, cli.bare_only)?; let repos = store.list(); info!(count = repos.len(), "discovered repositories"); for repo in repos {