use std::path::PathBuf;
use std::sync::Arc;
use tokio::net::TcpListener;
use gitrub::http;
use gitrub::ssh;
use gitrub::Config;
const VERSION: &str = env!("CARGO_PKG_VERSION");
fn print_main_help() {
eprintln!(
"\
gitrub {VERSION} — a local git server
USAGE:
gitrub [OPTIONS] Launch TUI (default) and auto-start server
gitrub --notui [OPTIONS] Headless mode (no TUI)
gitrub help <command> Show detailed help
OPTIONS:
--root <DIR> Repository root [default: .]
--host <ADDR> Bind address [default: 0.0.0.0]
--port <PORT> HTTP port [default: 3000]
--ssh-port <PORT> SSH port [default: 2222]
--user <USER> Username for auth
--pass <PASS> Password for auth
--noauth Disable authentication
--hooks-dir <DIR> Hook scripts directory
--no-ssh Disable SSH server
--recursive List repos recursively (nested paths)
--notui Headless mode (no interactive UI)
QUICKSTART:
gitrub --noauth
git clone http://localhost:3000/myproject.git"
);
}
fn print_serve_help() {
eprintln!(
"\
gitrub serve — start the git server
USAGE:
gitrub serve [OPTIONS]
AUTHENTICATION (one required):
--user <USER> Username for HTTP Basic / SSH password auth
--pass <PASS> Password (must be used with --user)
--noauth Disable authentication entirely
NETWORK:
--host <ADDR> Bind address [default: 0.0.0.0]
--port <PORT> HTTP port [default: 3000]
--ssh-port <PORT> SSH port [default: 2222]
--no-ssh Disable the built-in SSH server
STORAGE:
--root <DIR> Root directory for bare repos [default: .]
--recursive List repos recursively (include nested paths)
HOOKS:
--hooks-dir <DIR> Directory of hook scripts to copy into every new repo.
Typical hooks: pre-receive, post-receive, update.
Scripts must be executable.
EXAMPLES:
# Minimal — no auth, default ports
gitrub serve --noauth
# With auth
gitrub serve --user admin --pass secret
# Custom everything
gitrub serve --user deploy --pass s3cret \\
--host 0.0.0.0 --port 8080 --ssh-port 2222 \\
--root /srv/git --hooks-dir /etc/gitrub/hooks
# HTTP only, no SSH
gitrub serve --noauth --no-ssh
CLONE / PUSH / PULL (after the server is running):
# HTTP (no auth)
git clone http://localhost:3000/org/project.git
# HTTP (with auth — credentials in URL)
git clone http://admin:secret@localhost:3000/org/project.git
# SSH
git clone ssh://git@localhost:2222/org/project.git
# Push a new project (repo auto-creates on first push)
cd my-project
git init && git add . && git commit -m 'init'
git remote add origin http://localhost:3000/me/my-project.git
git push -u origin main
# Switch an existing repo from GitHub
git remote set-url origin http://localhost:3000/me/project.git
git push --mirror
SHALLOW & PARTIAL CLONES:
git clone --depth 1 http://localhost:3000/org/project.git
git clone --filter=blob:none http://localhost:3000/org/project.git
ARCHIVE DOWNLOADS (HTTP only):
curl -LO http://localhost:3000/org/project.git/archive/main.tar.gz
curl -LO http://localhost:3000/org/project.git/archive/v1.0.zip
curl -LO http://localhost:3000/org/project.git/archive/main.tar
GIT LFS:
cd my-project
git lfs install
git lfs track '*.bin' '*.zip'
git add .gitattributes
git commit -m 'track large files with LFS'
git push
LFS objects are stored under <root>/<repo>/lfs/objects/.
SERVER-SIDE HOOKS:
Create a directory with executable hook scripts:
mkdir hooks
cat > hooks/post-receive << 'HOOK'
#!/bin/sh
echo \"Push received on $(date)\"
HOOK
chmod +x hooks/post-receive
Then start the server with --hooks-dir:
gitrub serve --noauth --hooks-dir ./hooks
Every new repo gets these hooks copied in. Supported hooks include
pre-receive, update, post-receive, and any other git hook.
PROTOCOL V2:
Protocol v2 is supported automatically. Clients that send the
Git-Protocol header get v2 responses. Force it with:
git -c protocol.version=2 clone http://localhost:3000/org/project.git
SSH DETAILS:
The built-in SSH server generates an Ed25519 host key on first run,
saved to <root>/.host_key. Clients authenticate with the same
--user/--pass credentials. On first connect you will need to accept
the host key:
ssh-keyscan -p 2222 localhost >> ~/.ssh/known_hosts
Then clone/push/pull as usual:
git clone ssh://admin@localhost:2222/org/project.git"
);
}
fn print_tui_help() {
eprintln!(
"\
gitrub tui — interactive terminal UI
USAGE:
gitrub tui [OPTIONS]
OPTIONS:
--root <DIR> Root directory for repos [default: .]
--host <ADDR> Bind address [default: 0.0.0.0]
--port <PORT> HTTP port [default: 3000]
--ssh-port <PORT> SSH port [default: 2222]
--user <USER> Username (pre-fill auth fields)
--pass <PASS> Password (pre-fill auth fields)
--hooks-dir <DIR> Pre-fill hooks directory
--recursive List repos recursively (include nested paths)
All settings can be changed interactively in the TUI.
KEYBINDINGS:
Tab Switch focus between Settings and Repos
↑ / ↓ Navigate settings or repo list
Enter Edit selected setting (or toggle on/off fields)
/ Search / filter repos
s Start or stop the server
r Refresh repo list
PgUp/PgDn Scroll repos by page
Home/End Jump to first/last repo
q Quit
Ctrl-C Force quit
DESCRIPTION:
The TUI lets you browse repos, change server config, and
start/stop the server — all from one screen.
Settings are editable only when the server is stopped.
Press Enter on a field to edit it, or toggle Auth/SSH on/off.
Press 's' to start the server with current settings.
EXAMPLES:
# Launch with defaults
gitrub tui
# Launch pointed at a specific root
gitrub tui --root /srv/git
# Pre-fill auth
gitrub tui --root ./repos --user admin --pass secret"
);
}
fn print_help_for(command: &str) {
match command {
"serve" => print_serve_help(),
"tui" => print_tui_help(),
"help" => {
eprintln!("Usage: gitrub help <command>");
eprintln!();
eprintln!("Available commands: serve, tui");
}
other => {
eprintln!("Unknown command: {}", other);
eprintln!();
print_main_help();
}
}
}
fn parse_serve_args(args: &[String]) -> Config {
let mut root = String::from(".");
let mut host = String::from("0.0.0.0");
let mut port: u16 = 3000;
let mut ssh_port: u16 = 2222;
let mut user: Option<String> = None;
let mut pass: Option<String> = None;
let mut noauth = false;
let mut hooks_dir: Option<PathBuf> = None;
let mut enable_ssh = true;
let mut recursive = false;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--root" => {
i += 1;
root = args.get(i).cloned().unwrap_or_else(|| {
eprintln!("Error: --root requires a value");
std::process::exit(1);
});
}
"--host" => {
i += 1;
host = args.get(i).cloned().unwrap_or_else(|| {
eprintln!("Error: --host requires a value");
std::process::exit(1);
});
}
"--port" => {
i += 1;
port = args
.get(i)
.and_then(|s| s.parse().ok())
.unwrap_or_else(|| {
eprintln!("Error: --port requires a valid number");
std::process::exit(1);
});
}
"--ssh-port" => {
i += 1;
ssh_port = args
.get(i)
.and_then(|s| s.parse().ok())
.unwrap_or_else(|| {
eprintln!("Error: --ssh-port requires a valid number");
std::process::exit(1);
});
}
"--user" => {
i += 1;
user = Some(args.get(i).cloned().unwrap_or_else(|| {
eprintln!("Error: --user requires a value");
std::process::exit(1);
}));
}
"--pass" => {
i += 1;
pass = Some(args.get(i).cloned().unwrap_or_else(|| {
eprintln!("Error: --pass requires a value");
std::process::exit(1);
}));
}
"--noauth" => noauth = true,
"--hooks-dir" => {
i += 1;
hooks_dir = Some(PathBuf::from(args.get(i).cloned().unwrap_or_else(|| {
eprintln!("Error: --hooks-dir requires a value");
std::process::exit(1);
})));
}
"--no-ssh" => enable_ssh = false,
"--recursive" => recursive = true,
"--help" | "-h" => {
print_serve_help();
std::process::exit(0);
}
other => {
eprintln!("Error: unknown option '{}'\n", other);
eprintln!("Run 'gitrub help serve' for usage.");
std::process::exit(1);
}
}
i += 1;
}
if noauth {
user = None;
pass = None;
} else if user.is_none() && pass.is_none() {
eprintln!("Error: must provide --user and --pass, or use --noauth\n");
eprintln!("Run 'gitrub help serve' for usage.");
std::process::exit(1);
} else if user.is_some() != pass.is_some() {
eprintln!("Error: --user and --pass must both be set\n");
eprintln!("Run 'gitrub help serve' for usage.");
std::process::exit(1);
}
if let Some(ref dir) = hooks_dir {
if !dir.is_dir() {
eprintln!("Error: hooks directory does not exist: {}", dir.display());
std::process::exit(1);
}
}
std::fs::create_dir_all(&root).ok();
let root = PathBuf::from(&root)
.canonicalize()
.expect("Cannot resolve root path");
Config {
root,
host,
port,
ssh_port,
user,
pass,
hooks_dir,
enable_ssh,
recursive,
}
}
#[tokio::main]
async fn main() {
let args: Vec<String> = std::env::args().collect();
if let Some(cmd) = args.get(1).map(|s| s.as_str()) {
match cmd {
"--help" | "-h" => {
print_main_help();
std::process::exit(0);
}
"--version" | "-V" => {
println!("gitrub {}", VERSION);
return;
}
"help" => {
let topic = args.get(2).map(|s| s.as_str()).unwrap_or("help");
print_help_for(topic);
return;
}
"serve" => {
let config = Arc::new(parse_serve_args(&args[2..]));
run_server(config).await;
return;
}
"tui" => {
run_unified(&args[2..], false).await;
return;
}
_ => {}
}
}
run_unified(&args[1..], false).await;
}
async fn run_server(config: Arc<Config>) {
let listener = TcpListener::bind((&*config.host, config.port))
.await
.unwrap_or_else(|e| {
eprintln!(
"Error: cannot bind to {}:{} — {}",
config.host, config.port, e
);
std::process::exit(1);
});
println!("gitrub {VERSION} — local git server");
println!();
let bind_all = config.host == "0.0.0.0" || config.host == "::";
if bind_all {
println!(" Listening on:");
for ip in gitrub::local_ips() {
print!(" http://{}:{}", ip, config.port);
if config.enable_ssh {
print!(" ssh://{}:{}", ip, config.ssh_port);
}
println!();
}
} else {
print!(" HTTP: http://{}:{}", config.host, config.port);
if config.enable_ssh {
print!(" SSH: ssh://{}:{}", config.host, config.ssh_port);
}
println!();
}
if config.user.is_some() {
println!(
" Auth: enabled (user: {})",
config.user.as_deref().unwrap()
);
} else {
println!(" Auth: disabled (--noauth)");
}
println!(" Root: {}", config.root.display());
if let Some(ref dir) = config.hooks_dir {
println!(" Hooks: {}", dir.display());
}
println!();
let list_config = config.clone();
tokio::spawn(async move {
http::list_repos(&list_config.root, &list_config.host, list_config.port, list_config.recursive).await;
});
if config.enable_ssh {
let ssh_config = config.clone();
let (_shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
tokio::spawn(async move {
if let Err(e) = ssh::serve(ssh_config, shutdown_rx).await {
eprintln!("SSH server error: {}", e);
}
});
}
http::serve(listener, config).await;
}
async fn run_unified(args: &[String], notui_from_caller: bool) {
let mut root = String::from(".");
let mut host = String::from("0.0.0.0");
let mut port: u16 = 3000;
let mut ssh_port: u16 = 2222;
let mut user: Option<String> = None;
let mut pass: Option<String> = None;
let mut hooks_dir: Option<PathBuf> = None;
let mut enable_ssh = true;
let mut noauth = false;
let mut notui = notui_from_caller;
let mut recursive = false;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--root" => {
i += 1;
root = args.get(i).cloned().unwrap_or_else(|| {
eprintln!("Error: --root requires a value");
std::process::exit(1);
});
}
"--host" => {
i += 1;
host = args.get(i).cloned().unwrap_or_else(|| {
eprintln!("Error: --host requires a value");
std::process::exit(1);
});
}
"--port" => {
i += 1;
port = args
.get(i)
.and_then(|s| s.parse().ok())
.unwrap_or_else(|| {
eprintln!("Error: --port requires a valid number");
std::process::exit(1);
});
}
"--ssh-port" => {
i += 1;
ssh_port = args
.get(i)
.and_then(|s| s.parse().ok())
.unwrap_or_else(|| {
eprintln!("Error: --ssh-port requires a valid number");
std::process::exit(1);
});
}
"--user" => {
i += 1;
user = Some(args.get(i).cloned().unwrap_or_else(|| {
eprintln!("Error: --user requires a value");
std::process::exit(1);
}));
}
"--pass" => {
i += 1;
pass = Some(args.get(i).cloned().unwrap_or_else(|| {
eprintln!("Error: --pass requires a value");
std::process::exit(1);
}));
}
"--hooks-dir" => {
i += 1;
hooks_dir = Some(PathBuf::from(args.get(i).cloned().unwrap_or_else(|| {
eprintln!("Error: --hooks-dir requires a value");
std::process::exit(1);
})));
}
"--noauth" => noauth = true,
"--no-ssh" => enable_ssh = false,
"--recursive" => recursive = true,
"--notui" => notui = true,
"--help" | "-h" => {
print_main_help();
std::process::exit(0);
}
other => {
eprintln!("Error: unknown option '{}'\n", other);
print_main_help();
std::process::exit(1);
}
}
i += 1;
}
if noauth {
user = None;
pass = None;
} else if !notui {
} else if user.is_none() && pass.is_none() {
eprintln!("Error: must provide --user and --pass, or use --noauth\n");
print_main_help();
std::process::exit(1);
} else if user.is_some() != pass.is_some() {
eprintln!("Error: --user and --pass must both be set\n");
print_main_help();
std::process::exit(1);
}
std::fs::create_dir_all(&root).ok();
if notui {
let root = PathBuf::from(&root)
.canonicalize()
.expect("Cannot resolve root path");
if let Some(ref dir) = hooks_dir {
if !dir.is_dir() {
eprintln!("Error: hooks directory does not exist: {}", dir.display());
std::process::exit(1);
}
}
let config = Arc::new(Config {
root,
host,
port,
ssh_port,
user,
pass,
hooks_dir,
enable_ssh,
recursive,
});
run_server(config).await;
} else {
if let Err(e) = gitrub::tui::run(
root, host, port, ssh_port, user, pass, hooks_dir, enable_ssh, noauth, recursive,
)
.await
{
eprintln!("TUI error: {}", e);
std::process::exit(1);
}
}
}