use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::process::Command;
use gitrub::{http, Config};
struct TestServer {
url: String,
port: u16,
_dir: tempfile::TempDir,
}
async fn start_server(user: Option<&str>, pass: Option<&str>) -> TestServer {
start_server_with_hooks(user, pass, None).await
}
async fn start_server_with_hooks(
user: Option<&str>,
pass: Option<&str>,
hooks_dir: Option<PathBuf>,
) -> TestServer {
let dir = tempfile::tempdir().unwrap();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let config = Arc::new(Config {
root: dir.path().to_path_buf(),
host: "127.0.0.1".into(),
port,
ssh_port: 0,
user: user.map(String::from),
pass: pass.map(String::from),
hooks_dir,
enable_ssh: false,
recursive: false,
});
tokio::spawn(http::serve(listener, config));
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
TestServer {
url: format!("http://127.0.0.1:{}", port),
port,
_dir: dir,
}
}
async fn git(dir: &Path, args: &[&str]) -> (bool, String, String) {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_TERMINAL_PROMPT", "0")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.expect("failed to run git");
(
out.status.success(),
String::from_utf8_lossy(&out.stdout).into(),
String::from_utf8_lossy(&out.stderr).into(),
)
}
async fn make_local_repo(dir: &Path, name: &str) -> PathBuf {
let repo = dir.join(name);
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-b", "main"]).await;
git(&repo, &["config", "user.email", "test@test.com"]).await;
git(&repo, &["config", "user.name", "Test"]).await;
std::fs::write(repo.join("README.md"), "hello").unwrap();
git(&repo, &["add", "."]).await;
git(&repo, &["commit", "-m", "initial"]).await;
repo
}
#[test]
fn parse_path_flat() {
let (repo, ep) = gitrub::http::parse_path("/project.git/info/refs").unwrap();
assert_eq!(repo, "project");
assert_eq!(ep, "info/refs");
}
#[test]
fn parse_path_nested() {
let (repo, ep) = gitrub::http::parse_path("/org/project.git/git-upload-pack").unwrap();
assert_eq!(repo, "org/project");
assert_eq!(ep, "git-upload-pack");
}
#[test]
fn parse_path_deep() {
let (repo, ep) = gitrub::http::parse_path("/a/b/c.git/git-receive-pack").unwrap();
assert_eq!(repo, "a/b/c");
assert_eq!(ep, "git-receive-pack");
}
#[test]
fn parse_path_no_git_suffix() {
let (repo, ep) = gitrub::http::parse_path("/project/info/refs").unwrap();
assert_eq!(repo, "project");
assert_eq!(ep, "info/refs");
}
#[test]
fn parse_path_invalid() {
assert!(gitrub::http::parse_path("/").is_none());
assert!(gitrub::http::parse_path("/info/refs").is_none());
assert!(gitrub::http::parse_path("").is_none());
}
#[tokio::test]
async fn push_creates_repo() {
let srv = start_server(None, None).await;
let tmp = tempfile::tempdir().unwrap();
let repo = make_local_repo(tmp.path(), "myapp").await;
let remote = format!("{}/myapp.git", srv.url);
git(&repo, &["remote", "add", "origin", &remote]).await;
let (ok, _, stderr) = git(&repo, &["push", "-u", "origin", "main"]).await;
assert!(ok, "push failed: {}", stderr);
}
#[tokio::test]
async fn push_nested_path() {
let srv = start_server(None, None).await;
let tmp = tempfile::tempdir().unwrap();
let repo = make_local_repo(tmp.path(), "myapp").await;
let remote = format!("{}/org/team/project.git", srv.url);
git(&repo, &["remote", "add", "origin", &remote]).await;
let (ok, _, stderr) = git(&repo, &["push", "-u", "origin", "main"]).await;
assert!(ok, "push nested failed: {}", stderr);
}
#[tokio::test]
async fn push_multiple_commits() {
let srv = start_server(None, None).await;
let tmp = tempfile::tempdir().unwrap();
let repo = make_local_repo(tmp.path(), "app").await;
let remote = format!("{}/app.git", srv.url);
git(&repo, &["remote", "add", "origin", &remote]).await;
let (ok, _, stderr) = git(&repo, &["push", "-u", "origin", "main"]).await;
assert!(ok, "first push failed: {}", stderr);
std::fs::write(repo.join("file2.txt"), "second").unwrap();
git(&repo, &["add", "."]).await;
git(&repo, &["commit", "-m", "second commit"]).await;
let (ok, _, stderr) = git(&repo, &["push"]).await;
assert!(ok, "second push failed: {}", stderr);
}
#[tokio::test]
async fn clone_after_push() {
let srv = start_server(None, None).await;
let tmp = tempfile::tempdir().unwrap();
let repo = make_local_repo(tmp.path(), "src").await;
let remote = format!("{}/myrepo.git", srv.url);
git(&repo, &["remote", "add", "origin", &remote]).await;
git(&repo, &["push", "-u", "origin", "main"]).await;
let clone_dir = tmp.path().join("cloned");
let (ok, _, stderr) = git(tmp.path(), &["clone", &remote, clone_dir.to_str().unwrap()]).await;
assert!(ok, "clone failed: {}", stderr);
let content = std::fs::read_to_string(clone_dir.join("README.md")).unwrap();
assert_eq!(content, "hello");
}
#[tokio::test]
async fn clone_nested_repo() {
let srv = start_server(None, None).await;
let tmp = tempfile::tempdir().unwrap();
let repo = make_local_repo(tmp.path(), "src").await;
let remote = format!("{}/company/project.git", srv.url);
git(&repo, &["remote", "add", "origin", &remote]).await;
git(&repo, &["push", "-u", "origin", "main"]).await;
let clone_dir = tmp.path().join("cloned");
let (ok, _, stderr) = git(tmp.path(), &["clone", &remote, clone_dir.to_str().unwrap()]).await;
assert!(ok, "clone nested failed: {}", stderr);
assert!(clone_dir.join("README.md").exists());
}
#[tokio::test]
async fn fetch_new_commits() {
let srv = start_server(None, None).await;
let tmp = tempfile::tempdir().unwrap();
let repo = make_local_repo(tmp.path(), "src").await;
let remote = format!("{}/repo.git", srv.url);
git(&repo, &["remote", "add", "origin", &remote]).await;
git(&repo, &["push", "-u", "origin", "main"]).await;
let clone_dir = tmp.path().join("cloned");
git(tmp.path(), &["clone", &remote, clone_dir.to_str().unwrap()]).await;
std::fs::write(repo.join("new.txt"), "new file").unwrap();
git(&repo, &["add", "."]).await;
git(&repo, &["commit", "-m", "add new file"]).await;
git(&repo, &["push"]).await;
let (ok, _, stderr) = git(&clone_dir, &["fetch", "origin"]).await;
assert!(ok, "fetch failed: {}", stderr);
let (ok, stdout, _) = git(&clone_dir, &["log", "--oneline", "origin/main"]).await;
assert!(ok);
assert!(stdout.contains("add new file"));
}
#[tokio::test]
async fn pull_updates_working_tree() {
let srv = start_server(None, None).await;
let tmp = tempfile::tempdir().unwrap();
let repo = make_local_repo(tmp.path(), "src").await;
let remote = format!("{}/repo.git", srv.url);
git(&repo, &["remote", "add", "origin", &remote]).await;
git(&repo, &["push", "-u", "origin", "main"]).await;
let clone_dir = tmp.path().join("cloned");
git(tmp.path(), &["clone", &remote, clone_dir.to_str().unwrap()]).await;
std::fs::write(repo.join("update.txt"), "updated").unwrap();
git(&repo, &["add", "."]).await;
git(&repo, &["commit", "-m", "update"]).await;
git(&repo, &["push"]).await;
let (ok, _, stderr) = git(&clone_dir, &["pull"]).await;
assert!(ok, "pull failed: {}", stderr);
let content = std::fs::read_to_string(clone_dir.join("update.txt")).unwrap();
assert_eq!(content, "updated");
}
#[tokio::test]
async fn push_and_clone_branch() {
let srv = start_server(None, None).await;
let tmp = tempfile::tempdir().unwrap();
let repo = make_local_repo(tmp.path(), "src").await;
let remote = format!("{}/repo.git", srv.url);
git(&repo, &["remote", "add", "origin", &remote]).await;
git(&repo, &["push", "-u", "origin", "main"]).await;
git(&repo, &["checkout", "-b", "feature"]).await;
std::fs::write(repo.join("feature.txt"), "feat").unwrap();
git(&repo, &["add", "."]).await;
git(&repo, &["commit", "-m", "feature work"]).await;
let (ok, _, stderr) = git(&repo, &["push", "origin", "feature"]).await;
assert!(ok, "push branch failed: {}", stderr);
let clone_dir = tmp.path().join("cloned");
git(tmp.path(), &["clone", &remote, clone_dir.to_str().unwrap()]).await;
let (ok, stdout, _) = git(&clone_dir, &["branch", "-r"]).await;
assert!(ok);
assert!(stdout.contains("origin/feature"));
assert!(stdout.contains("origin/main"));
git(&clone_dir, &["checkout", "feature"]).await;
let content = std::fs::read_to_string(clone_dir.join("feature.txt")).unwrap();
assert_eq!(content, "feat");
}
#[tokio::test]
async fn push_and_fetch_tags() {
let srv = start_server(None, None).await;
let tmp = tempfile::tempdir().unwrap();
let repo = make_local_repo(tmp.path(), "src").await;
let remote = format!("{}/repo.git", srv.url);
git(&repo, &["remote", "add", "origin", &remote]).await;
git(&repo, &["push", "-u", "origin", "main"]).await;
git(&repo, &["tag", "v1.0.0"]).await;
let (ok, _, stderr) = git(&repo, &["push", "origin", "v1.0.0"]).await;
assert!(ok, "push tag failed: {}", stderr);
let clone_dir = tmp.path().join("cloned");
git(tmp.path(), &["clone", &remote, clone_dir.to_str().unwrap()]).await;
let (ok, stdout, _) = git(&clone_dir, &["tag"]).await;
assert!(ok);
assert!(stdout.contains("v1.0.0"));
}
#[tokio::test]
async fn force_push() {
let srv = start_server(None, None).await;
let tmp = tempfile::tempdir().unwrap();
let repo = make_local_repo(tmp.path(), "src").await;
let remote = format!("{}/repo.git", srv.url);
git(&repo, &["remote", "add", "origin", &remote]).await;
git(&repo, &["push", "-u", "origin", "main"]).await;
std::fs::write(repo.join("README.md"), "rewritten").unwrap();
git(&repo, &["add", "."]).await;
git(&repo, &["commit", "--amend", "-m", "rewritten"]).await;
let (ok, _, stderr) = git(&repo, &["push", "--force"]).await;
assert!(ok, "force push failed: {}", stderr);
let clone_dir = tmp.path().join("cloned");
git(tmp.path(), &["clone", &remote, clone_dir.to_str().unwrap()]).await;
let content = std::fs::read_to_string(clone_dir.join("README.md")).unwrap();
assert_eq!(content, "rewritten");
}
#[tokio::test]
async fn delete_remote_branch() {
let srv = start_server(None, None).await;
let tmp = tempfile::tempdir().unwrap();
let repo = make_local_repo(tmp.path(), "src").await;
let remote = format!("{}/repo.git", srv.url);
git(&repo, &["remote", "add", "origin", &remote]).await;
git(&repo, &["push", "-u", "origin", "main"]).await;
git(&repo, &["checkout", "-b", "temp"]).await;
std::fs::write(repo.join("tmp.txt"), "tmp").unwrap();
git(&repo, &["add", "."]).await;
git(&repo, &["commit", "-m", "temp"]).await;
git(&repo, &["push", "origin", "temp"]).await;
git(&repo, &["checkout", "main"]).await;
let (ok, _, stderr) = git(&repo, &["push", "origin", "--delete", "temp"]).await;
assert!(ok, "delete branch failed: {}", stderr);
let clone_dir = tmp.path().join("cloned");
git(tmp.path(), &["clone", &remote, clone_dir.to_str().unwrap()]).await;
let (_, stdout, _) = git(&clone_dir, &["branch", "-r"]).await;
assert!(!stdout.contains("origin/temp"));
}
#[tokio::test]
async fn auth_rejects_no_credentials() {
let srv = start_server(Some("admin"), Some("secret")).await;
let tmp = tempfile::tempdir().unwrap();
let repo = make_local_repo(tmp.path(), "src").await;
let remote = format!("{}/repo.git", srv.url);
git(&repo, &["remote", "add", "origin", &remote]).await;
let (ok, _, _) = git(&repo, &["push", "-u", "origin", "main"]).await;
assert!(!ok, "push should fail without auth");
}
#[tokio::test]
async fn auth_rejects_wrong_password() {
let srv = start_server(Some("admin"), Some("secret")).await;
let tmp = tempfile::tempdir().unwrap();
let repo = make_local_repo(tmp.path(), "src").await;
let remote = format!("http://admin:wrong@127.0.0.1:{}/repo.git", srv.port);
git(&repo, &["remote", "add", "origin", &remote]).await;
let (ok, _, _) = git(&repo, &["push", "-u", "origin", "main"]).await;
assert!(!ok, "push should fail with wrong password");
}
#[tokio::test]
async fn auth_accepts_correct_credentials() {
let srv = start_server(Some("admin"), Some("secret")).await;
let tmp = tempfile::tempdir().unwrap();
let repo = make_local_repo(tmp.path(), "src").await;
let remote = format!("http://admin:secret@127.0.0.1:{}/repo.git", srv.port);
git(&repo, &["remote", "add", "origin", &remote]).await;
let (ok, _, stderr) = git(&repo, &["push", "-u", "origin", "main"]).await;
assert!(ok, "push with correct auth failed: {}", stderr);
let clone_dir = tmp.path().join("cloned");
let (ok, _, stderr) = git(tmp.path(), &["clone", &remote, clone_dir.to_str().unwrap()]).await;
assert!(ok, "clone with auth failed: {}", stderr);
assert!(clone_dir.join("README.md").exists());
}
#[tokio::test]
async fn auth_noauth_allows_everything() {
let srv = start_server(None, None).await;
let tmp = tempfile::tempdir().unwrap();
let repo = make_local_repo(tmp.path(), "src").await;
let remote = format!("{}/repo.git", srv.url);
git(&repo, &["remote", "add", "origin", &remote]).await;
let (ok, _, stderr) = git(&repo, &["push", "-u", "origin", "main"]).await;
assert!(ok, "noauth push failed: {}", stderr);
}
#[tokio::test]
async fn multiple_repos_isolated() {
let srv = start_server(None, None).await;
let tmp = tempfile::tempdir().unwrap();
let repo_a = make_local_repo(tmp.path(), "src-a").await;
std::fs::write(repo_a.join("id.txt"), "repo-a").unwrap();
git(&repo_a, &["add", "."]).await;
git(&repo_a, &["commit", "-m", "a"]).await;
let remote_a = format!("{}/repo-a.git", srv.url);
git(&repo_a, &["remote", "add", "origin", &remote_a]).await;
git(&repo_a, &["push", "-u", "origin", "main"]).await;
let repo_b = make_local_repo(tmp.path(), "src-b").await;
std::fs::write(repo_b.join("id.txt"), "repo-b").unwrap();
git(&repo_b, &["add", "."]).await;
git(&repo_b, &["commit", "-m", "b"]).await;
let remote_b = format!("{}/repo-b.git", srv.url);
git(&repo_b, &["remote", "add", "origin", &remote_b]).await;
git(&repo_b, &["push", "-u", "origin", "main"]).await;
let clone_a = tmp.path().join("clone-a");
git(tmp.path(), &["clone", &remote_a, clone_a.to_str().unwrap()]).await;
let clone_b = tmp.path().join("clone-b");
git(tmp.path(), &["clone", &remote_b, clone_b.to_str().unwrap()]).await;
assert_eq!(
std::fs::read_to_string(clone_a.join("id.txt")).unwrap(),
"repo-a"
);
assert_eq!(
std::fs::read_to_string(clone_b.join("id.txt")).unwrap(),
"repo-b"
);
}
#[tokio::test]
async fn shallow_clone() {
let srv = start_server(None, None).await;
let tmp = tempfile::tempdir().unwrap();
let repo = make_local_repo(tmp.path(), "src").await;
for i in 1..=5 {
std::fs::write(repo.join(format!("file{}.txt", i)), format!("content {}", i)).unwrap();
git(&repo, &["add", "."]).await;
git(&repo, &["commit", "-m", &format!("commit {}", i)]).await;
}
let remote = format!("{}/repo.git", srv.url);
git(&repo, &["remote", "add", "origin", &remote]).await;
git(&repo, &["push", "-u", "origin", "main"]).await;
let clone_dir = tmp.path().join("shallow");
let (ok, _, stderr) = git(
tmp.path(),
&["clone", "--depth", "1", &remote, clone_dir.to_str().unwrap()],
)
.await;
assert!(ok, "shallow clone failed: {}", stderr);
let (ok, stdout, _) = git(&clone_dir, &["log", "--oneline"]).await;
assert!(ok);
let lines: Vec<&str> = stdout.trim().lines().collect();
assert_eq!(lines.len(), 1, "Expected 1 commit, got: {:?}", lines);
}
#[tokio::test]
async fn partial_clone_blobless() {
let srv = start_server(None, None).await;
let tmp = tempfile::tempdir().unwrap();
let repo = make_local_repo(tmp.path(), "src").await;
let remote = format!("{}/repo.git", srv.url);
git(&repo, &["remote", "add", "origin", &remote]).await;
git(&repo, &["push", "-u", "origin", "main"]).await;
let clone_dir = tmp.path().join("partial");
let (ok, _, stderr) = git(
tmp.path(),
&[
"clone",
"--filter=blob:none",
&remote,
clone_dir.to_str().unwrap(),
],
)
.await;
assert!(ok, "partial clone failed: {}", stderr);
assert!(clone_dir.join("README.md").exists());
}
#[tokio::test]
async fn protocol_v2_clone() {
let srv = start_server(None, None).await;
let tmp = tempfile::tempdir().unwrap();
let repo = make_local_repo(tmp.path(), "src").await;
let remote = format!("{}/repo.git", srv.url);
git(&repo, &["remote", "add", "origin", &remote]).await;
git(&repo, &["push", "-u", "origin", "main"]).await;
let clone_dir = tmp.path().join("v2clone");
let (ok, _, stderr) = git(
tmp.path(),
&[
"-c",
"protocol.version=2",
"clone",
&remote,
clone_dir.to_str().unwrap(),
],
)
.await;
assert!(ok, "protocol v2 clone failed: {}", stderr);
assert!(clone_dir.join("README.md").exists());
}
#[tokio::test]
async fn archive_tar_gz() {
let srv = start_server(None, None).await;
let tmp = tempfile::tempdir().unwrap();
let repo = make_local_repo(tmp.path(), "src").await;
let remote = format!("{}/repo.git", srv.url);
git(&repo, &["remote", "add", "origin", &remote]).await;
git(&repo, &["push", "-u", "origin", "main"]).await;
let archive_url = format!("{}/repo.git/archive/main.tar.gz", srv.url);
let out = Command::new("curl")
.args(["-sf", "-o", "/dev/null", "-w", "%{http_code}", &archive_url])
.output()
.await
.unwrap();
let code = String::from_utf8_lossy(&out.stdout);
assert_eq!(code.trim(), "200", "archive download failed: HTTP {}", code);
}
#[tokio::test]
async fn archive_zip() {
let srv = start_server(None, None).await;
let tmp = tempfile::tempdir().unwrap();
let repo = make_local_repo(tmp.path(), "src").await;
let remote = format!("{}/repo.git", srv.url);
git(&repo, &["remote", "add", "origin", &remote]).await;
git(&repo, &["push", "-u", "origin", "main"]).await;
let archive_url = format!("{}/repo.git/archive/main.zip", srv.url);
let out = Command::new("curl")
.args(["-sf", "-o", "/dev/null", "-w", "%{http_code}", &archive_url])
.output()
.await
.unwrap();
let code = String::from_utf8_lossy(&out.stdout);
assert_eq!(code.trim(), "200", "zip archive failed: HTTP {}", code);
}
#[tokio::test]
async fn server_side_hooks() {
let tmp = tempfile::tempdir().unwrap();
let hooks_dir = tmp.path().join("hooks");
std::fs::create_dir_all(&hooks_dir).unwrap();
let hook_script = hooks_dir.join("post-receive");
std::fs::write(
&hook_script,
"#!/bin/sh\ntouch \"$(git rev-parse --git-dir)/post-receive-ran\"\n",
)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&hook_script, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let srv = start_server_with_hooks(None, None, Some(hooks_dir)).await;
let repo = make_local_repo(tmp.path(), "src").await;
let remote = format!("{}/hooked.git", srv.url);
git(&repo, &["remote", "add", "origin", &remote]).await;
let (ok, _, stderr) = git(&repo, &["push", "-u", "origin", "main"]).await;
assert!(ok, "push failed: {}", stderr);
let marker_dotgit = srv._dir.path().join("hooked/.git/post-receive-ran");
let marker_bare = srv._dir.path().join("hooked/post-receive-ran");
assert!(
marker_dotgit.exists() || marker_bare.exists(),
"post-receive hook did not run"
);
}
#[tokio::test]
async fn lfs_upload_and_download() {
let srv = start_server(None, None).await;
let batch_url = format!("{}/myrepo.git/info/lfs/objects/batch", srv.url);
let tmp = tempfile::tempdir().unwrap();
let repo = make_local_repo(tmp.path(), "src").await;
let remote = format!("{}/myrepo.git", srv.url);
git(&repo, &["remote", "add", "origin", &remote]).await;
git(&repo, &["push", "-u", "origin", "main"]).await;
let content = b"hello lfs world";
let oid = sha256_hex(content);
let size = content.len();
let batch_req = format!(
r#"{{"operation":"upload","objects":[{{"oid":"{}","size":{}}}]}}"#,
oid, size
);
let out = Command::new("curl")
.args([
"-sf",
"-X",
"POST",
"-H",
"Content-Type: application/vnd.git-lfs+json",
"-d",
&batch_req,
&batch_url,
])
.output()
.await
.unwrap();
assert!(out.status.success(), "batch upload request failed");
let resp: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let upload_href = resp["objects"][0]["actions"]["upload"]["href"]
.as_str()
.expect("No upload href in response");
let out = Command::new("curl")
.args([
"-sf",
"-X",
"PUT",
"-H",
"Content-Type: application/octet-stream",
"--data-binary",
&String::from_utf8_lossy(content).to_string(),
upload_href,
])
.output()
.await
.unwrap();
assert!(out.status.success(), "upload failed");
let batch_req = format!(
r#"{{"operation":"download","objects":[{{"oid":"{}","size":{}}}]}}"#,
oid, size
);
let out = Command::new("curl")
.args([
"-sf",
"-X",
"POST",
"-H",
"Content-Type: application/vnd.git-lfs+json",
"-d",
&batch_req,
&batch_url,
])
.output()
.await
.unwrap();
assert!(out.status.success(), "batch download request failed");
let resp: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
let download_href = resp["objects"][0]["actions"]["download"]["href"]
.as_str()
.expect("No download href in response");
let out = Command::new("curl")
.args(["-sf", download_href])
.output()
.await
.unwrap();
assert!(out.status.success(), "download failed");
assert_eq!(&out.stdout, content);
}
fn sha256_hex(data: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(data);
hex::encode(h.finalize())
}