Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -29,7 +29,7 @@ git clone http://127.0.0.1:3000/my-project.git
git-server [OPTIONS] <ROOT>

Arguments:
<ROOT> Root directory containing bare Git repositories
<ROOT> Root directory to scan for Git repositories

Options:
-b, --bind <ADDR> Bind address [default: 127.0.0.1]
Expand All @@ -38,6 +38,7 @@ Options:
--log-format <FORMAT> Log format: text or json [default: text]
-w, --workers <N> Number of Tokio worker threads
--max-depth <N> Max directory depth for repo discovery [default: 3]
--bare-only Only serve bare repositories (skip working trees)
```

## API
Expand Down
2 changes: 1 addition & 1 deletion crates/git-server-bench/benches/concurrent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion crates/git-server-bench/benches/git_clone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion crates/git-server-bench/benches/http_clone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ fn build_upload_pack_request(oid: &str) -> Vec<u8> {
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();
Expand Down
92 changes: 74 additions & 18 deletions crates/git-server-core/src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<Self> {
///
/// 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<Self> {
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));

Expand Down Expand Up @@ -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`.
Expand All @@ -76,6 +86,7 @@ fn walk_dir(
dir: &Path,
depth: u32,
max_depth: u32,
bare_only: bool,
repos: &mut Vec<RepoInfo>,
) -> Result<()> {
let read = match fs::read_dir(dir) {
Expand All @@ -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)
Expand All @@ -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)?;
}
}
}
Expand Down Expand Up @@ -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);
}

Expand All @@ -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");
}
Expand All @@ -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);
}

Expand All @@ -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");
}
Expand All @@ -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");
Expand All @@ -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(_)));
}
Expand All @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion crates/git-server-http/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
54 changes: 53 additions & 1 deletion crates/git-server-http/tests/helpers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
}
Loading
Loading