use std::path::PathBuf;
use std::process::ExitCode;
use puregit::Repository;
use puregit::object::ObjectType;
use puregit::oid::{HashAlgo, ObjectId};
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let Some(command) = args.first() else {
eprintln!("{USAGE}");
return ExitCode::from(129);
};
match command.as_str() {
"--version" | "version" => {
println!("puregit {}", env!("CARGO_PKG_VERSION"));
return ExitCode::SUCCESS;
}
"--help" => {
println!("{USAGE}");
return ExitCode::SUCCESS;
}
"help" => return cmd_help(&args[1..]),
_ => {}
}
let result = match command.as_str() {
"init" => cmd_init(&args[1..]),
"hash-object" => cmd_hash_object(&args[1..]),
"cat-file" => cmd_cat_file(&args[1..]),
"rev-parse" => cmd_rev_parse(&args[1..]),
"add" => cmd_add(&args[1..]),
"rm" => cmd_rm(&args[1..]),
"mv" => cmd_mv(&args[1..]),
"write-tree" => cmd_write_tree(&args[1..]),
"read-tree" => cmd_read_tree(&args[1..]),
"mktree" => cmd_mktree(&args[1..]),
"commit-tree" => cmd_commit_tree(&args[1..]),
"commit" => cmd_commit(&args[1..]),
"log" => cmd_log(&args[1..]),
"reflog" => cmd_reflog(&args[1..]),
"show" => cmd_show(&args[1..]),
"status" => cmd_status(&args[1..]),
"branch" => cmd_branch(&args[1..]),
"checkout" => cmd_checkout(&args[1..]),
"reset" => cmd_reset(&args[1..]),
"gc" | "repack" => cmd_gc(&args[1..]),
"fsck" => cmd_fsck(&args[1..]),
"prune" => cmd_prune(&args[1..]),
"commit-graph" => cmd_commit_graph(&args[1..]),
"multi-pack-index" => cmd_multi_pack_index(&args[1..]),
"worktree" => cmd_worktree(&args[1..]),
"merge-base" => cmd_merge_base(&args[1..]),
"merge" => cmd_merge(&args[1..]),
"lfs" => cmd_lfs(&args[1..]),
"tag" => cmd_tag(&args[1..]),
"ls-tree" => cmd_ls_tree(&args[1..]),
"diff-tree" => cmd_diff_tree(&args[1..]),
"diff" => cmd_diff(&args[1..]),
"unpack-objects" => cmd_unpack_objects(&args[1..]),
"verify-pack" => cmd_verify_pack(&args[1..]),
"clone" => cmd_clone(&args[1..]),
"completion" => cmd_completion(&args[1..]),
other => {
eprintln!("git: '{other}' is not a git command. See 'git help'.");
return ExitCode::from(1);
}
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(msg) => {
eprintln!("error: {msg}");
if msg.contains("usage:") {
ExitCode::from(129)
} else {
ExitCode::from(128)
}
}
}
}
const USAGE: &str = "\
usage: git <command> [<args>]
commands:
init [<dir>] create an empty repository
hash-object [-w] <file> compute an object id (and optionally store it)
cat-file -t|-p|-s <oid> show an object's type, contents, or size
cat-file --batch print type/size/contents for oids read on stdin
rev-parse <ref> resolve a ref to an object id
add <file>... stage working-tree files (or deletions)
rm <file>... remove files from the index and work tree
mv <source> <dest> move/rename a tracked file
write-tree write the index out as a tree, print its id
read-tree <tree-ish> load a tree into the index (replacing it)
mktree read `ls-tree` lines from stdin, write a tree
commit-tree <tree> [-p <parent>]... [-m <msg>]
create a commit object, print its id
commit -m <msg> commit the staged index
log show commit history from HEAD
reflog [<ref>] show the update log of a ref (default HEAD)
show [<object>] show a commit (with patch), tag, tree, or blob
status show working-tree status
branch <name> create a branch at HEAD
checkout <branch> switch to a branch (updates the work tree)
reset [--soft|--mixed|--hard] [<commit>]
move HEAD (and index/work tree) to a commit
gc pack loose objects and prune them
fsck verify object connectivity and integrity
prune delete unreachable loose objects
commit-graph write|verify write or verify the commit-graph cache
multi-pack-index write|verify write or verify the multi-pack-index
worktree add <path> [<commit>] | list manage linked worktrees
merge-base <a> <b> print the best common ancestor of two commits
merge <branch> merge a branch into the current branch
lfs track <pattern> track a path pattern with Git LFS
lfs ls-files list LFS-pointer files in the index
lfs pull download LFS objects for the current checkout
tag <name> [<commit>] create a lightweight tag (or list tags)
ls-tree <tree-ish> list the entries of a tree
diff-tree <a> <b> name-status diff between two commits/trees
diff unified diff of the work tree vs the index
unpack-objects <pack> explode a packfile into loose objects
verify-pack <pack> check a packfile against its .idx
clone <url> [<dir>] clone a remote repository (http/https)
completion [bash] print a shell completion script
version print the puregit version";
fn cmd_help(args: &[String]) -> ExitCode {
match args.first().map(|s| s.as_str()) {
Some("--man") => {
print!("{}", manpage());
ExitCode::SUCCESS
}
Some(topic) => {
let mut printed = false;
for line in USAGE.lines() {
if line.split_whitespace().next().is_some_and(|w| w == topic) {
println!("{}", line.trim_start());
printed = true;
}
}
if !printed {
eprintln!("git help: no such command '{topic}'");
return ExitCode::from(129);
}
ExitCode::SUCCESS
}
None => {
println!("{USAGE}");
ExitCode::SUCCESS
}
}
}
fn manpage() -> String {
let version = env!("CARGO_PKG_VERSION");
let mut out = String::new();
out.push_str(&format!(
".TH PUREGIT 1 \"\" \"puregit {version}\" \"puregit Manual\"\n"
));
out.push_str(".SH NAME\n");
out.push_str("puregit \\- pure-Rust git command-line front end\n");
out.push_str(".SH SYNOPSIS\n");
out.push_str(".B puregit\n");
out.push_str(".I command\n");
out.push_str("[\\fIargs\\fR]\n");
out.push_str(".SH DESCRIPTION\n");
out.push_str(
"A minimal git command-line interface over the pure-Rust puregit library. \
It maintains repositories and clones, fetches, and pushes over HTTP and SSH \
without any C toolchain.\n",
);
out.push_str(".SH COMMANDS\n");
for line in USAGE.lines() {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with("usage:") || trimmed == "commands:" {
continue;
}
if !line.starts_with(" ") {
continue;
}
let (synopsis, description) = match trimmed.find(" ") {
Some(i) => (trimmed[..i].trim_end(), trimmed[i..].trim_start()),
None => (trimmed, ""),
};
out.push_str(".TP\n.B ");
out.push_str(&roff_escape(synopsis));
out.push('\n');
if !description.is_empty() {
out.push_str(&roff_escape(description));
out.push('\n');
}
}
out.push_str(".SH EXIT STATUS\n");
out.push_str(".TP\n.B 0\nSuccess.\n");
out.push_str(
".TP\n.B 1\nA controlled negative result \\(em \
\\fBdiff \\-\\-exit\\-code\\fR found changes, or \
\\fBmerge\\-base \\-\\-is\\-ancestor\\fR was false \\(em or an unknown command.\n",
);
out.push_str(".TP\n.B 128\nA fatal error (the operation could not be performed).\n");
out.push_str(".TP\n.B 129\nA usage error (invalid arguments).\n");
out.push_str(".SH SEE ALSO\n");
out.push_str(".BR git (1)\n");
out
}
fn roff_escape(s: &str) -> String {
let mut out = s.replace('\\', "\\\\");
if out.starts_with('.') || out.starts_with('\'') {
out.insert_str(0, "\\&");
}
out
}
const SUBCOMMANDS: &[&str] = &[
"init",
"hash-object",
"cat-file",
"rev-parse",
"add",
"rm",
"mv",
"write-tree",
"read-tree",
"mktree",
"commit-tree",
"commit",
"log",
"reflog",
"show",
"status",
"branch",
"checkout",
"reset",
"gc",
"repack",
"fsck",
"prune",
"merge-base",
"merge",
"lfs",
"tag",
"ls-tree",
"diff-tree",
"diff",
"unpack-objects",
"verify-pack",
"clone",
"completion",
"version",
];
fn cmd_completion(args: &[String]) -> Result<(), String> {
let shell = args.first().map(String::as_str).unwrap_or("bash");
if shell != "bash" {
return Err(format!(
"completion: unsupported shell '{shell}' (try 'bash')"
));
}
let words = SUBCOMMANDS.join(" ");
println!(
"_puregit_git() {{\n \
local cur=\"${{COMP_WORDS[COMP_CWORD]}}\"\n \
if [ \"$COMP_CWORD\" -eq 1 ]; then\n \
COMPREPLY=( $(compgen -W \"{words}\" -- \"$cur\") )\n \
fi\n\
}}\n\
complete -o default -F _puregit_git git"
);
Ok(())
}
fn cmd_init(args: &[String]) -> Result<(), String> {
let dir = args
.first()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
let repo = Repository::init(&dir).map_err(|e| e.to_string())?;
println!(
"Initialized empty Git repository in {}",
repo.git_dir().display()
);
Ok(())
}
fn cmd_hash_object(args: &[String]) -> Result<(), String> {
let mut write = false;
let mut file = None;
for arg in args {
match arg.as_str() {
"-w" => write = true,
other => file = Some(other.to_string()),
}
}
let file = file.ok_or("hash-object: missing <file>")?;
let content = std::fs::read(&file).map_err(|e| format!("reading {file}: {e}"))?;
if write {
let repo = open_here()?;
let id = repo
.write_object(ObjectType::Blob, &content)
.map_err(|e| e.to_string())?;
println!("{id}");
} else {
let id = puregit::hash::hash_object(HashAlgo::Sha1, ObjectType::Blob, &content);
println!("{id}");
}
Ok(())
}
fn cmd_cat_file(args: &[String]) -> Result<(), String> {
if args.first().map(String::as_str) == Some("--batch") {
return cmd_cat_file_batch();
}
if args.len() < 2 {
return Err("cat-file: usage: cat-file -t|-p|-s <oid> | --batch".into());
}
let flag = &args[0];
let repo = open_here()?;
let id = ObjectId::from_hex(repo.algo(), &args[1]).map_err(|e| e.to_string())?;
let (ty, payload) = repo.objects_read(&id).map_err(|e| e.to_string())?;
match flag.as_str() {
"-t" => println!("{ty}"),
"-s" => println!("{}", payload.len()),
"-p" => {
use std::io::Write;
std::io::stdout()
.write_all(&payload)
.map_err(|e| e.to_string())?;
}
other => return Err(format!("cat-file: unknown flag '{other}'")),
}
Ok(())
}
fn cmd_cat_file_batch() -> Result<(), String> {
use std::io::{BufRead, Write};
let repo = open_here()?;
let stdin = std::io::stdin();
let mut out = std::io::stdout();
for line in stdin.lock().lines() {
let line = line.map_err(|e| e.to_string())?;
let rev = line.trim();
if rev.is_empty() {
continue;
}
let resolved = repo
.refs()
.resolve(rev)
.or_else(|_| ObjectId::from_hex(repo.algo(), rev).map_err(|e| e.to_string()))
.ok()
.and_then(|id| repo.objects_read(&id).ok().map(|(ty, p)| (id, ty, p)));
match resolved {
Some((id, ty, payload)) => {
writeln!(out, "{id} {ty} {}", payload.len()).map_err(|e| e.to_string())?;
out.write_all(&payload).map_err(|e| e.to_string())?;
out.write_all(b"\n").map_err(|e| e.to_string())?;
}
None => {
writeln!(out, "{rev} missing").map_err(|e| e.to_string())?;
}
}
}
Ok(())
}
fn cmd_rev_parse(args: &[String]) -> Result<(), String> {
let name = args.first().ok_or("rev-parse: missing <ref>")?;
let repo = open_here()?;
let id = repo.refs().resolve(name).map_err(|e| e.to_string())?;
println!("{id}");
Ok(())
}
fn cmd_add(args: &[String]) -> Result<(), String> {
if args.is_empty() {
return Err("add: nothing specified".into());
}
let repo = open_here()?;
for path in args {
repo.add_path(path).map_err(|e| e.to_string())?;
}
Ok(())
}
fn cmd_rm(args: &[String]) -> Result<(), String> {
if args.is_empty() {
return Err("rm: nothing specified".into());
}
let repo = open_here()?;
for path in args {
repo.remove_path(path).map_err(|e| e.to_string())?;
println!("rm '{path}'");
}
Ok(())
}
fn cmd_mv(args: &[String]) -> Result<(), String> {
if args.len() < 2 {
return Err("mv: usage: mv <source> <dest>".into());
}
let repo = open_here()?;
repo.mv(&args[0], &args[1]).map_err(|e| e.to_string())
}
fn cmd_write_tree(_args: &[String]) -> Result<(), String> {
use puregit::tree_builder::write_tree_from_index;
let repo = open_here()?;
let index = repo.index().map_err(|e| e.to_string())?;
let id = write_tree_from_index(repo.objects(), &index).map_err(|e| e.to_string())?;
println!("{id}");
Ok(())
}
fn cmd_read_tree(args: &[String]) -> Result<(), String> {
use puregit::object::Object;
let rev = args.first().ok_or("read-tree: missing <tree-ish>")?;
let repo = open_here()?;
let id = repo
.refs()
.resolve(rev)
.or_else(|_| ObjectId::from_hex(repo.algo(), rev).map_err(|e| e.to_string()))?;
let tree = match repo.read_object(&id).map_err(|e| e.to_string())? {
Object::Commit(c) => c.tree,
Object::Tree(_) => id,
other => {
return Err(format!(
"read-tree: {} is not a tree or commit",
other.object_type()
));
}
};
repo.read_tree(&tree).map_err(|e| e.to_string())
}
fn cmd_mktree(_args: &[String]) -> Result<(), String> {
use puregit::object::tree::FileMode;
use puregit::object::{Tree, TreeEntry};
use std::io::Read;
let repo = open_here()?;
let mut input = String::new();
std::io::stdin()
.read_to_string(&mut input)
.map_err(|e| format!("mktree: reading stdin: {e}"))?;
let mut entries = Vec::new();
for line in input.lines() {
if line.trim().is_empty() {
continue;
}
let (meta, name) = line
.split_once('\t')
.ok_or_else(|| format!("mktree: no TAB before name in: {line}"))?;
let mut fields = meta.split_whitespace();
let mode = fields
.next()
.ok_or_else(|| format!("mktree: missing mode in: {line}"))?;
let _kind = fields.next(); let sha = fields
.next()
.ok_or_else(|| format!("mktree: missing sha in: {line}"))?;
entries.push(TreeEntry {
mode: FileMode::from_octal(mode.as_bytes()).map_err(|e| e.to_string())?,
name: name.as_bytes().to_vec(),
id: ObjectId::from_hex(repo.algo(), sha).map_err(|e| e.to_string())?,
});
}
let id = repo
.write_object(ObjectType::Tree, &Tree { entries }.serialize())
.map_err(|e| e.to_string())?;
println!("{id}");
Ok(())
}
fn cmd_commit_tree(args: &[String]) -> Result<(), String> {
use puregit::object::{Commit, Object};
use std::io::Read;
let mut tree_arg = None;
let mut parents_hex = Vec::new();
let mut messages = Vec::new();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"-p" => {
let p = args.get(i + 1).ok_or("commit-tree: -p needs a <parent>")?;
parents_hex.push(p.clone());
i += 2;
}
"-m" => {
let m = args.get(i + 1).ok_or("commit-tree: -m needs a <message>")?;
messages.push(m.clone());
i += 2;
}
other => {
if tree_arg.is_some() {
return Err(format!("commit-tree: unexpected argument '{other}'"));
}
tree_arg = Some(other.to_string());
i += 1;
}
}
}
let tree_arg = tree_arg.ok_or("commit-tree: missing <tree>")?;
let repo = open_here()?;
let tree = ObjectId::from_hex(repo.algo(), &tree_arg).map_err(|e| e.to_string())?;
match repo.read_object(&tree).map_err(|e| e.to_string())? {
Object::Tree(_) => {}
other => {
return Err(format!(
"commit-tree: {tree} is a {}, not a tree",
other.object_type()
));
}
}
let mut parents = Vec::new();
for p in &parents_hex {
parents.push(ObjectId::from_hex(repo.algo(), p).map_err(|e| e.to_string())?);
}
let mut message = if messages.is_empty() {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(|e| format!("commit-tree: reading stdin: {e}"))?;
buf
} else {
messages.join("\n\n")
};
if !message.ends_with('\n') {
message.push('\n');
}
let who = signature_now(&repo)?;
let commit = Commit {
tree,
parents,
author: who.clone(),
committer: who,
extra_headers: Vec::new(),
message: message.into_bytes(),
};
let id = repo
.write_object(ObjectType::Commit, &commit.serialize())
.map_err(|e| e.to_string())?;
println!("{id}");
Ok(())
}
fn cmd_commit(args: &[String]) -> Result<(), String> {
let mut message = None;
let mut i = 0;
while i < args.len() {
if args[i] == "-m" {
message = args.get(i + 1).cloned();
i += 2;
} else {
i += 1;
}
}
let message = message.ok_or("commit: -m <message> is required")?;
let repo = open_here()?;
let who = signature_now(&repo)?;
let mut body = message.into_bytes();
body.push(b'\n');
let id = repo
.commit(&body, who.clone(), who)
.map_err(|e| e.to_string())?;
println!("committed {id}");
Ok(())
}
fn cmd_log(_args: &[String]) -> Result<(), String> {
use puregit::walk::RevWalk;
let repo = open_here()?;
let head = match repo.head_id() {
Ok(h) => h,
Err(_) => return Err("log: HEAD does not point at any commit yet".into()),
};
for item in RevWalk::new(repo.objects(), &[head]) {
let (id, commit) = item.map_err(|e| e.to_string())?;
let summary = String::from_utf8_lossy(commit.summary());
let author = String::from_utf8_lossy(&commit.author.name);
println!("commit {id}");
println!("Author: {author}");
println!("\n {summary}\n");
}
Ok(())
}
fn cmd_show(args: &[String]) -> Result<(), String> {
let rev = args.first().map(String::as_str).unwrap_or("HEAD");
let repo = open_here()?;
let id = repo
.refs()
.resolve(rev)
.or_else(|_| ObjectId::from_hex(repo.algo(), rev).map_err(|e| e.to_string()))?;
show_object(&repo, &id)
}
fn show_object(repo: &Repository, id: &ObjectId) -> Result<(), String> {
use puregit::object::Object;
use std::io::Write;
match repo.read_object(id).map_err(|e| e.to_string())? {
Object::Commit(c) => {
println!("commit {id}");
println!(
"Author: {} <{}>",
String::from_utf8_lossy(&c.author.name),
String::from_utf8_lossy(&c.author.email)
);
println!();
for line in String::from_utf8_lossy(&c.message).lines() {
println!(" {line}");
}
println!();
let new_map =
puregit::walk::flatten_tree(repo.objects(), &c.tree).map_err(|e| e.to_string())?;
let mut old_map = std::collections::BTreeMap::new();
if let Some(parent) = c.parents.first()
&& let Object::Commit(pc) = repo.read_object(parent).map_err(|e| e.to_string())?
{
old_map = puregit::walk::flatten_tree(repo.objects(), &pc.tree)
.map_err(|e| e.to_string())?;
}
print_patch(repo, &old_map, &new_map)
}
Object::Tag(t) => {
println!("tag {}", String::from_utf8_lossy(&t.name));
if let Some(tagger) = &t.tagger {
println!(
"Tagger: {} <{}>",
String::from_utf8_lossy(&tagger.name),
String::from_utf8_lossy(&tagger.email)
);
}
println!();
for line in String::from_utf8_lossy(&t.message).lines() {
println!("{line}");
}
println!();
show_object(repo, &t.object)
}
Object::Tree(t) => {
for entry in &t.entries {
let kind = if entry.mode.is_tree() { "tree" } else { "blob" };
println!(
"{:0>6} {} {}\t{}",
entry.mode.as_octal(),
kind,
entry.id,
String::from_utf8_lossy(&entry.name)
);
}
Ok(())
}
Object::Blob(bytes) => std::io::stdout()
.write_all(&bytes)
.map_err(|e| e.to_string()),
}
}
fn print_patch(
repo: &Repository,
old: &std::collections::BTreeMap<Vec<u8>, (puregit::object::FileMode, ObjectId)>,
new: &std::collections::BTreeMap<Vec<u8>, (puregit::object::FileMode, ObjectId)>,
) -> Result<(), String> {
use puregit::object::FileMode;
let mut paths: Vec<&Vec<u8>> = old.keys().chain(new.keys()).collect();
paths.sort();
paths.dedup();
for path in paths {
let o = old.get(path);
let n = new.get(path);
if o.map(|(_, i)| i) == n.map(|(_, i)| i) {
continue; }
let name = String::from_utf8_lossy(path);
let is_gitlink =
|e: Option<&(FileMode, ObjectId)>| matches!(e, Some((FileMode::Gitlink, _)));
if is_gitlink(o) || is_gitlink(n) {
println!("Submodule {name} changed");
continue;
}
let read = |e: Option<&(FileMode, ObjectId)>| -> Result<Vec<u8>, String> {
match e {
Some((_, id)) => repo
.objects_read(id)
.map(|(_, p)| p)
.map_err(|err| err.to_string()),
None => Ok(Vec::new()),
}
};
let old_bytes = read(o)?;
let new_bytes = read(n)?;
println!("diff --git a/{name} b/{name}");
let patch = puregit::diff::unified(
&old_bytes,
&new_bytes,
&format!("a/{name}"),
&format!("b/{name}"),
3,
);
print!("{patch}");
}
Ok(())
}
fn cmd_reflog(args: &[String]) -> Result<(), String> {
if args.first().map(String::as_str) == Some("expire") {
return cmd_reflog_expire(&args[1..]);
}
let refname = args.first().map(String::as_str).unwrap_or("HEAD");
let repo = open_here()?;
let path = repo.git_dir().join("logs").join(refname);
let content = std::fs::read_to_string(&path).unwrap_or_default();
let lines: Vec<&str> = content.lines().collect();
for (i, line) in lines.iter().rev().enumerate() {
let Some((meta, message)) = line.split_once('\t') else {
continue;
};
let new = meta.split_whitespace().nth(1).unwrap_or("");
let short = &new[..new.len().min(8)];
println!("{short} {refname}@{{{i}}}: {message}");
}
Ok(())
}
fn cmd_reflog_expire(args: &[String]) -> Result<(), String> {
use std::time::{SystemTime, UNIX_EPOCH};
let mut seconds: i64 = 90 * 24 * 60 * 60; let mut refname = "HEAD".to_string();
for arg in args {
if let Some(v) = arg.strip_prefix("--expire=") {
seconds = v
.parse()
.map_err(|_| format!("reflog expire: bad --expire value {v:?}"))?;
} else {
refname = arg.clone();
}
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let repo = open_here()?;
let removed = repo
.reflog_expire(&refname, now - seconds)
.map_err(|e| e.to_string())?;
eprintln!(
"reflog expire: dropped {removed} entr{}",
if removed == 1 { "y" } else { "ies" }
);
Ok(())
}
fn signature_now(repo: &Repository) -> Result<puregit::object::Signature, String> {
use std::time::{SystemTime, UNIX_EPOCH};
let cfg = repo.config().map_err(|e| e.to_string())?;
let name = cfg
.get("user", None, "name")
.map(String::from)
.or_else(|| std::env::var("GIT_AUTHOR_NAME").ok())
.unwrap_or_else(|| "puregit".into());
let email = cfg
.get("user", None, "email")
.map(String::from)
.or_else(|| std::env::var("GIT_AUTHOR_EMAIL").ok())
.unwrap_or_else(|| "puregit@localhost".into());
let time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
Ok(puregit::object::Signature {
name: name.into_bytes(),
email: email.into_bytes(),
time,
tz: b"+0000".to_vec(),
})
}
fn cmd_clone(args: &[String]) -> Result<(), String> {
let url = args.first().ok_or("clone: missing <url>")?;
let dir = match args.get(1) {
Some(d) => d.clone(),
None => dir_from_url(url),
};
if url.starts_with("http://") || url.starts_with("https://") {
return clone_http(url, &dir);
}
if url.starts_with("ssh://") || is_scp_like(url) {
return clone_ssh(url, &dir);
}
Err(format!("clone: unsupported URL scheme in '{url}'"))
}
fn is_scp_like(url: &str) -> bool {
match (url.find(':'), url.find('/')) {
(Some(colon), Some(slash)) => colon < slash,
(Some(_), None) => true,
_ => false,
}
}
#[cfg(feature = "ssh")]
fn clone_ssh(url: &str, dir: &str) -> Result<(), String> {
use puregit::oid::HashAlgo;
use puregit::transport::ssh::SshTransport;
let mut transport = SshTransport::from_url(url, HashAlgo::Sha1).map_err(|e| e.to_string())?;
if let Ok(pw) = std::env::var("GIT_SSH_PASSWORD") {
transport = transport.with_password(pw);
} else if let Ok(key) = std::env::var("GIT_SSH_KEY") {
transport = transport.with_key(key, std::env::var("GIT_SSH_KEY_PASSPHRASE").ok());
}
let repo = puregit::client::clone(dir, &mut transport).map_err(|e| e.to_string())?;
println!(
"Cloned into '{}' (HEAD {})",
dir,
repo.head_id()
.map(|h| h.to_hex_short(8))
.unwrap_or_else(|_| "unborn".into())
);
write_origin_config(&repo, url)?;
Ok(())
}
#[cfg(any(feature = "http", feature = "ssh"))]
fn write_origin_config(repo: &Repository, url: &str) -> Result<(), String> {
use puregit::refs::Reference;
let mut cfg = repo.config().map_err(|e| e.to_string())?;
cfg.set("remote", Some("origin"), "url", url);
cfg.set(
"remote",
Some("origin"),
"fetch",
&puregit::remote::Remote::default_fetch("origin").to_string_form(),
);
if let Ok(Reference::Symbolic(head_ref)) = repo.head()
&& let Some(branch) = head_ref.strip_prefix("refs/heads/")
{
cfg.set("branch", Some(branch), "remote", "origin");
cfg.set("branch", Some(branch), "merge", &head_ref);
}
repo.write_config(&cfg).map_err(|e| e.to_string())
}
#[cfg(not(feature = "ssh"))]
fn clone_ssh(_url: &str, _dir: &str) -> Result<(), String> {
Err("clone: this build lacks the `ssh` feature".into())
}
#[cfg(feature = "http")]
fn clone_http(url: &str, dir: &str) -> Result<(), String> {
use puregit::oid::HashAlgo;
use puregit::transport::http::HttpTransport;
let mut transport = HttpTransport::new(url.to_string(), HashAlgo::Sha1);
let repo = puregit::client::clone(dir, &mut transport).map_err(|e| e.to_string())?;
println!(
"Cloned into '{}' (HEAD {})",
dir,
repo.head_id()
.map(|h| h.to_hex_short(8))
.unwrap_or_else(|_| "unborn".into())
);
write_origin_config(&repo, url)?;
match cmd_lfs_pull(&repo) {
Ok(()) => {}
Err(e) => eprintln!("warning: lfs pull skipped: {e}"),
}
Ok(())
}
#[cfg(not(feature = "http"))]
fn clone_http(_url: &str, _dir: &str) -> Result<(), String> {
Err("clone: this build lacks the `http` feature".into())
}
fn dir_from_url(url: &str) -> String {
let trimmed = url.trim_end_matches('/');
let last = trimmed.rsplit('/').next().unwrap_or("repo");
last.strip_suffix(".git").unwrap_or(last).to_string()
}
fn cmd_status(args: &[String]) -> Result<(), String> {
use puregit::status::{Change, status};
let repo = open_here()?;
let st = status(&repo).map_err(|e| e.to_string())?;
if args
.iter()
.any(|a| a == "--porcelain" || a == "-s" || a == "--short")
{
return status_porcelain(&st);
}
if st.is_clean() {
println!("nothing to commit, working tree clean");
return Ok(());
}
let show = |label: &str, items: &[(Vec<u8>, Change)]| {
if !items.is_empty() {
println!("{label}:");
for (path, change) in items {
let tag = match change {
Change::Added => "new file",
Change::Modified => "modified",
Change::Deleted => "deleted",
};
println!("\t{tag}: {}", String::from_utf8_lossy(path));
}
}
};
show("Changes to be committed", &st.staged);
show("Changes not staged for commit", &st.unstaged);
if !st.untracked.is_empty() {
println!("Untracked files:");
for path in &st.untracked {
println!("\t{}", String::from_utf8_lossy(path));
}
}
Ok(())
}
fn status_porcelain(st: &puregit::status::Status) -> Result<(), String> {
use puregit::status::Change;
use std::collections::BTreeMap;
let code = |c: &Change| match c {
Change::Added => 'A',
Change::Modified => 'M',
Change::Deleted => 'D',
};
let mut rows: BTreeMap<&[u8], (char, char)> = BTreeMap::new();
for (path, change) in &st.staged {
rows.entry(path).or_insert((' ', ' ')).0 = code(change);
}
for (path, change) in &st.unstaged {
rows.entry(path).or_insert((' ', ' ')).1 = code(change);
}
for path in &st.untracked {
rows.insert(path, ('?', '?'));
}
for (path, (x, y)) in &rows {
println!("{x}{y} {}", String::from_utf8_lossy(path));
}
Ok(())
}
fn cmd_branch(args: &[String]) -> Result<(), String> {
let repo = open_here()?;
match args.first() {
None => {
let current = repo.head().ok();
for (name, _) in repo.refs().list().map_err(|e| e.to_string())? {
if let Some(short) = name.strip_prefix("refs/heads/") {
let marker = matches!(¤t, Some(puregit::refs::Reference::Symbolic(t)) if t == &name);
println!("{} {short}", if marker { "*" } else { " " });
}
}
Ok(())
}
Some(name) => repo.create_branch(name, None).map_err(|e| e.to_string()),
}
}
fn cmd_checkout(args: &[String]) -> Result<(), String> {
let name = args.first().ok_or("checkout: missing <branch>")?;
let repo = open_here()?;
repo.checkout(name).map_err(|e| e.to_string())?;
println!("Switched to branch '{name}'");
Ok(())
}
fn cmd_reset(args: &[String]) -> Result<(), String> {
use puregit::repository::ResetMode;
let mut mode = ResetMode::Mixed;
let mut target = None;
for arg in args {
match arg.as_str() {
"--soft" => mode = ResetMode::Soft,
"--mixed" => mode = ResetMode::Mixed,
"--hard" => mode = ResetMode::Hard,
other if other.starts_with('-') => {
return Err(format!("reset: unknown option '{other}'"));
}
other => target = Some(other.to_string()),
}
}
let target = target.unwrap_or_else(|| "HEAD".to_string());
let repo = open_here()?;
let commit = repo
.refs()
.resolve(&target)
.or_else(|_| ObjectId::from_hex(repo.algo(), &target).map_err(|e| e.to_string()))?;
repo.reset(mode, &commit).map_err(|e| e.to_string())?;
let label = match mode {
ResetMode::Soft => "soft",
ResetMode::Mixed => "mixed",
ResetMode::Hard => "hard",
};
println!("HEAD is now at {} ({label} reset)", commit.to_hex_short(8));
Ok(())
}
fn cmd_tag(args: &[String]) -> Result<(), String> {
let repo = open_here()?;
match args.first() {
None => {
for (name, _) in repo.refs().list().map_err(|e| e.to_string())? {
if let Some(short) = name.strip_prefix("refs/tags/") {
println!("{short}");
}
}
Ok(())
}
Some(name) => {
let target = match args.get(1) {
Some(rev) => repo
.refs()
.resolve(rev)
.or_else(|_| ObjectId::from_hex(repo.algo(), rev).map_err(|e| e.to_string()))?,
None => repo.head_id().map_err(|e| e.to_string())?,
};
let full = format!("refs/tags/{name}");
if repo.refs().lookup(&full).is_ok() {
return Err(format!("tag '{name}' already exists"));
}
repo.refs()
.update(&full, &puregit::refs::Reference::Direct(target))
.map_err(|e| e.to_string())
}
}
}
fn cmd_ls_tree(args: &[String]) -> Result<(), String> {
use puregit::object::Object;
let rev = args.first().ok_or("ls-tree: missing <tree-ish>")?;
let repo = open_here()?;
let id = repo
.refs()
.resolve(rev)
.or_else(|_| ObjectId::from_hex(repo.algo(), rev).map_err(|e| e.to_string()))?;
let tree = match repo.read_object(&id).map_err(|e| e.to_string())? {
Object::Commit(c) => match repo.read_object(&c.tree).map_err(|e| e.to_string())? {
Object::Tree(t) => t,
_ => return Err("ls-tree: commit tree is not a tree".into()),
},
Object::Tree(t) => t,
other => {
return Err(format!(
"ls-tree: {} is not a tree or commit",
other.object_type()
));
}
};
for entry in &tree.entries {
let kind = if entry.mode == puregit::object::tree::FileMode::Tree {
"tree"
} else {
"blob"
};
println!(
"{:0>6} {} {}\t{}",
entry.mode.as_octal(),
kind,
entry.id,
String::from_utf8_lossy(&entry.name)
);
}
Ok(())
}
fn cmd_diff(args: &[String]) -> Result<(), String> {
use puregit::object::Object;
let quiet = args.iter().any(|a| a == "--quiet");
let exit_code = quiet || args.iter().any(|a| a == "--exit-code");
let repo = open_here()?;
let work = repo
.work_tree()
.ok_or("diff: bare repository has no work tree")?
.to_path_buf();
let index = repo.index().map_err(|e| e.to_string())?;
let mut differed = false;
for entry in &index.entries {
if entry.stage != 0 {
continue;
}
let path = String::from_utf8_lossy(&entry.path).into_owned();
let wt = std::fs::read(work.join(&path)).unwrap_or_default();
let staged = match repo.read_object(&entry.id).map_err(|e| e.to_string())? {
Object::Blob(b) => b,
_ => continue,
};
if wt == staged {
continue;
}
differed = true;
if quiet {
break; }
let d = puregit::diff::unified(&staged, &wt, &format!("a/{path}"), &format!("b/{path}"), 3);
print!("{d}");
}
if exit_code && differed {
use std::io::Write;
let _ = std::io::stdout().flush();
std::process::exit(1);
}
Ok(())
}
fn cmd_diff_tree(args: &[String]) -> Result<(), String> {
use puregit::object::Object;
use puregit::walk::{TreeChange, diff_trees};
if args.len() < 2 {
return Err("diff-tree: usage: diff-tree <a> <b>".into());
}
let repo = open_here()?;
let to_tree = |rev: &str| -> Result<ObjectId, String> {
let id = repo
.refs()
.resolve(rev)
.or_else(|_| ObjectId::from_hex(repo.algo(), rev).map_err(|e| e.to_string()))?;
match repo.read_object(&id).map_err(|e| e.to_string())? {
Object::Commit(c) => Ok(c.tree),
Object::Tree(_) => Ok(id),
other => Err(format!(
"diff-tree: {} is not a tree or commit",
other.object_type()
)),
}
};
let a = to_tree(&args[0])?;
let b = to_tree(&args[1])?;
for entry in diff_trees(repo.objects(), Some(&a), Some(&b)).map_err(|e| e.to_string())? {
let tag = match entry.change {
TreeChange::Added => 'A',
TreeChange::Modified => 'M',
TreeChange::Deleted => 'D',
};
println!("{tag}\t{}", String::from_utf8_lossy(&entry.path));
}
Ok(())
}
fn cmd_lfs(args: &[String]) -> Result<(), String> {
use puregit::lfs::Pointer;
let sub = args.first().map(String::as_str).unwrap_or("");
let repo = open_here()?;
match sub {
"track" => {
let pattern = args.get(1).ok_or("lfs track: missing <pattern>")?;
repo.lfs_track(pattern).map_err(|e| e.to_string())?;
println!("Tracking \"{pattern}\"");
Ok(())
}
"ls-files" => {
use puregit::odb::ObjectDatabase;
let index = repo.index().map_err(|e| e.to_string())?;
for entry in &index.entries {
if entry.stage != 0 {
continue;
}
if let Ok((_, blob)) = repo.objects().read(&entry.id)
&& let Ok(ptr) = Pointer::parse(&blob)
{
println!(
"{} * {}",
&ptr.oid[..10],
String::from_utf8_lossy(&entry.path)
);
}
}
Ok(())
}
"pull" => cmd_lfs_pull(&repo),
other => Err(format!(
"lfs: unknown subcommand '{other}' (try track|ls-files|pull)"
)),
}
}
#[cfg(feature = "http")]
fn cmd_lfs_pull(repo: &Repository) -> Result<(), String> {
use puregit::lfs::batch::Operation;
use puregit::lfs::transfer::LfsClient;
let url = repo
.config()
.map_err(|e| e.to_string())?
.get("remote", Some("origin"), "url")
.map(String::from)
.ok_or("lfs pull: no remote.origin.url configured")?;
let client = LfsClient::new(LfsClient::endpoint_from_remote(&url));
let n = repo
.lfs_smudge_worktree(|p| {
let results = client.batch(Operation::Download, std::slice::from_ref(p))?;
let action = results
.first()
.and_then(|r| r.download.as_ref())
.ok_or_else(|| puregit::Error::Protocol("lfs pull: no download action".into()))?;
client.download(p, action)
})
.map_err(|e| e.to_string())?;
println!("Downloaded {n} LFS object(s).");
Ok(())
}
#[cfg(not(feature = "http"))]
fn cmd_lfs_pull(_repo: &Repository) -> Result<(), String> {
Err("lfs pull: this build lacks the `http` feature".into())
}
fn cmd_merge(args: &[String]) -> Result<(), String> {
use puregit::merge::MergeOutcome;
let branch = args.first().ok_or("merge: missing <branch>")?;
let repo = open_here()?;
let their = repo
.refs()
.resolve(&format!("refs/heads/{branch}"))
.or_else(|_| repo.refs().resolve(branch))
.or_else(|_| ObjectId::from_hex(repo.algo(), branch).map_err(|e| e.to_string()))?;
let who = signature_now(&repo)?;
let msg = format!("Merge branch '{branch}'\n");
match repo
.merge(&their, who.clone(), who, msg.as_bytes())
.map_err(|e| e.to_string())?
{
MergeOutcome::AlreadyUpToDate => println!("Already up to date."),
MergeOutcome::FastForward(id) => println!("Fast-forward to {}", id.to_hex_short(8)),
MergeOutcome::Merged(id) => println!(
"Merge made by the 'three-way' strategy: {}",
id.to_hex_short(8)
),
MergeOutcome::Conflicts(paths) => {
println!("Automatic merge failed; fix conflicts and then commit the result:");
for p in paths {
println!("\tboth modified: {}", String::from_utf8_lossy(&p));
}
return Err("merge conflicts".into());
}
}
Ok(())
}
fn cmd_merge_base(args: &[String]) -> Result<(), String> {
let is_ancestor = args.iter().any(|a| a == "--is-ancestor");
let positional: Vec<&String> = args.iter().filter(|a| !a.starts_with('-')).collect();
if positional.len() < 2 {
return Err("merge-base: usage: merge-base [--is-ancestor] <commit> <commit>".into());
}
let repo = open_here()?;
let resolve = |spec: &String| -> Result<ObjectId, String> {
repo.refs()
.resolve(spec)
.or_else(|_| ObjectId::from_hex(repo.algo(), spec).map_err(|e| e.to_string()))
};
let a = resolve(positional[0])?;
let b = resolve(positional[1])?;
if is_ancestor {
let anc = puregit::walk::is_ancestor(repo.objects(), &a, &b).map_err(|e| e.to_string())?;
std::process::exit(if anc { 0 } else { 1 });
}
match puregit::walk::merge_base(repo.objects(), &a, &b).map_err(|e| e.to_string())? {
Some(base) => {
println!("{base}");
Ok(())
}
None => std::process::exit(1),
}
}
fn cmd_gc(_args: &[String]) -> Result<(), String> {
let mut repo = open_here()?;
let n = repo.repack().map_err(|e| e.to_string())?;
println!("Packed {n} loose object(s).");
Ok(())
}
fn cmd_fsck(_args: &[String]) -> Result<(), String> {
let repo = open_here()?;
let report = puregit::fsck::fsck(&repo).map_err(|e| e.to_string())?;
for p in &report.problems {
match &p.referenced_by {
Some(parent) => println!("{} ({}, referenced by {parent})", p.message, p.id),
None => println!("{} ({})", p.message, p.id),
}
}
for id in &report.dangling {
println!("dangling object {id}");
}
if report.is_ok() {
eprintln!(
"fsck: {} object(s) checked, no problems found",
report.checked
);
Ok(())
} else {
Err(format!(
"{} integrity problem(s) found",
report.problems.len()
))
}
}
fn cmd_commit_graph(args: &[String]) -> Result<(), String> {
let sub = args.first().map(String::as_str).unwrap_or("write");
let repo = open_here()?;
match sub {
"write" => {
let n = repo.write_commit_graph().map_err(|e| e.to_string())?;
eprintln!("commit-graph: wrote {n} commit(s)");
Ok(())
}
"verify" => {
let path = repo.git_dir().join("objects/info/commit-graph");
let data = std::fs::read(&path).map_err(|e| format!("reading commit-graph: {e}"))?;
let g = puregit::commit_graph::CommitGraph::parse(repo.algo(), &data)
.map_err(|e| e.to_string())?;
eprintln!("commit-graph: OK, {} commit(s)", g.len());
Ok(())
}
other => Err(format!("commit-graph: unknown subcommand '{other}'")),
}
}
fn cmd_multi_pack_index(args: &[String]) -> Result<(), String> {
let sub = args.first().map(String::as_str).unwrap_or("write");
let repo = open_here()?;
match sub {
"write" => {
let n = repo.write_multi_pack_index().map_err(|e| e.to_string())?;
eprintln!("multi-pack-index: wrote an index over {n} pack(s)");
Ok(())
}
"verify" => {
let path = repo.git_dir().join("objects/pack/multi-pack-index");
let data =
std::fs::read(&path).map_err(|e| format!("reading multi-pack-index: {e}"))?;
let m = puregit::multi_pack_index::MultiPackIndex::parse(repo.algo(), &data)
.map_err(|e| e.to_string())?;
eprintln!(
"multi-pack-index: OK, {} object(s) over {} pack(s)",
m.len(),
m.pack_names().len()
);
Ok(())
}
other => Err(format!("multi-pack-index: unknown subcommand '{other}'")),
}
}
fn cmd_worktree(args: &[String]) -> Result<(), String> {
let repo = open_here()?;
match args.first().map(String::as_str) {
Some("add") => {
let path = args.get(1).ok_or("worktree add: missing <path>")?;
let commit = match args.get(2) {
Some(rev) => repo
.refs()
.resolve(rev)
.or_else(|_| ObjectId::from_hex(repo.algo(), rev).map_err(|e| e.to_string()))?,
None => repo.head_id().map_err(|e| e.to_string())?,
};
repo.add_worktree(std::path::Path::new(path), &commit)
.map_err(|e| e.to_string())?;
println!("Prepared worktree at {path} ({})", commit.to_hex_short(8));
Ok(())
}
Some("list") | None => {
if let (Some(work), Ok(head)) = (repo.work_tree(), repo.head_id()) {
println!("{} {}", work.display(), head.to_hex_short(8));
}
for (name, id) in repo.worktrees().map_err(|e| e.to_string())? {
println!("worktrees/{name} {}", id.to_hex_short(8));
}
Ok(())
}
Some(other) => Err(format!("worktree: unknown subcommand '{other}'")),
}
}
fn cmd_prune(_args: &[String]) -> Result<(), String> {
let mut repo = open_here()?;
let n = repo.prune().map_err(|e| e.to_string())?;
println!("Pruned {n} unreachable loose object(s).");
Ok(())
}
fn cmd_verify_pack(args: &[String]) -> Result<(), String> {
let pack_path = args.first().ok_or("verify-pack: missing <packfile>")?;
let idx_path = match pack_path.strip_suffix(".pack") {
Some(stem) => format!("{stem}.idx"),
None => format!("{pack_path}.idx"),
};
let pack = std::fs::read(pack_path).map_err(|e| format!("reading {pack_path}: {e}"))?;
let idx = std::fs::read(&idx_path).map_err(|e| format!("reading {idx_path}: {e}"))?;
use puregit::odb::ObjectDatabase;
let repo = open_here()?;
let resolver = |id: &ObjectId| repo.objects().read(id);
let v = puregit::pack::verify_pack(&pack, &idx, repo.algo(), &resolver)
.map_err(|e| e.to_string())?;
for p in &v.problems {
println!("{p}");
}
if v.is_ok() {
eprintln!("verify-pack: {} object(s) ok", v.objects);
Ok(())
} else {
Err(format!("{} problem(s) found", v.problems.len()))
}
}
fn cmd_unpack_objects(args: &[String]) -> Result<(), String> {
let path = args.first().ok_or("unpack-objects: missing <packfile>")?;
let pack = std::fs::read(path).map_err(|e| format!("reading {path}: {e}"))?;
let repo = open_here()?;
let ids = repo.ingest_pack(&pack).map_err(|e| e.to_string())?;
eprintln!("Unpacked {} objects.", ids.len());
Ok(())
}
fn open_here() -> Result<Repository, String> {
Repository::open(".").map_err(|e| e.to_string())
}
trait ObjectsReadExt {
fn objects_read(&self, id: &ObjectId) -> puregit::Result<(ObjectType, Vec<u8>)>;
}
impl ObjectsReadExt for Repository {
fn objects_read(&self, id: &ObjectId) -> puregit::Result<(ObjectType, Vec<u8>)> {
use puregit::odb::ObjectDatabase;
self.objects().read(id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roff_escapes_backslashes_and_leading_control() {
assert_eq!(roff_escape("a\\b"), "a\\\\b");
assert_eq!(roff_escape(".hidden"), "\\&.hidden");
assert_eq!(roff_escape("'quote"), "\\&'quote");
assert_eq!(roff_escape("plain"), "plain");
}
#[test]
fn manpage_has_required_roff_sections() {
let man = manpage();
assert!(man.starts_with(".TH PUREGIT 1 "));
for section in [
".SH NAME",
".SH SYNOPSIS",
".SH DESCRIPTION",
".SH COMMANDS",
".SH EXIT STATUS",
] {
assert!(man.contains(section), "man page missing {section}");
}
assert!(man.contains(".B init"));
assert!(man.contains(".B 128"));
assert!(man.contains(".B 129"));
}
}