use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::{Parser, Subcommand};
use std::io::{self, Write};
use zub::ops::{
checkout, commit, diff, fsck, gc, log, ls_tree, ls_tree_recursive, map, union_checkout,
union_trees, CheckoutOptions, ConflictResolution, MapOptions, UnionCheckoutOptions,
UnionOptions,
};
use zub::transport::{pull_local, push_local, PullOptions, PushOptions};
use zub::{read_blob, read_commit, read_tree, Hash, Repo};
#[derive(Parser)]
#[command(name = "zub")]
#[command(about = "git-like object tree - content-addressed filesystem store")]
#[command(version)]
struct Cli {
#[arg(short, long, env = "ZUB_REPO")]
repo: Option<PathBuf>,
#[command(subcommand)]
command: Commands,
}
fn resolve_repo_path(repo_arg: Option<PathBuf>) -> PathBuf {
if let Some(path) = repo_arg {
return path;
}
let zub_path = Path::new(".zub");
if zub_path.is_symlink() {
if let Ok(target) = std::fs::read_link(zub_path) {
return target;
}
}
if zub_path.is_dir() {
return zub_path.to_path_buf();
}
PathBuf::from(".")
}
#[derive(Subcommand)]
enum Commands {
Init {
#[arg(default_value = ".")]
path: PathBuf,
},
Commit {
source: PathBuf,
#[arg(short = 'r', long)]
ref_name: String,
#[arg(short, long)]
message: Option<String>,
#[arg(short, long)]
author: Option<String>,
},
Checkout {
ref_name: String,
destination: PathBuf,
#[arg(long)]
copy: bool,
#[arg(long)]
sparse: bool,
},
Log {
ref_name: String,
#[arg(short = 'n', long)]
max_count: Option<usize>,
},
LsTree {
ref_name: String,
#[arg(short, long)]
path: Option<PathBuf>,
#[arg(short, long)]
recursive: bool,
},
Diff {
ref1: String,
ref2: String,
},
Union {
#[arg(required = true)]
refs: Vec<String>,
#[arg(short, long)]
output: String,
#[arg(long, default_value = "error")]
on_conflict: String,
#[arg(short, long)]
message: Option<String>,
},
UnionCheckout {
#[arg(required = true)]
refs: Vec<String>,
#[arg(short, long)]
destination: PathBuf,
#[arg(long, default_value = "error")]
on_conflict: String,
#[arg(long)]
copy: bool,
},
Fsck,
Gc {
#[arg(long)]
dry_run: bool,
},
Stats,
Du {
pattern: Option<String>,
#[arg(short, long, default_value = "20")]
limit: usize,
},
TruncateHistory {
#[arg(long)]
dry_run: bool,
},
Remap {
#[arg(long)]
force: bool,
#[arg(long)]
dry_run: bool,
},
Push {
destination: PathBuf,
ref_name: String,
#[arg(short, long)]
force: bool,
#[arg(long)]
dry_run: bool,
},
Pull {
source: PathBuf,
ref_name: String,
#[arg(long)]
fetch_only: bool,
#[arg(long)]
dry_run: bool,
},
Refs,
ShowRef {
ref_name: String,
},
DeleteRef {
ref_name: String,
},
DeleteRefs {
pattern: String,
},
CatFile {
object_type: String,
object: String,
},
RevParse {
rev: String,
#[arg(long)]
short: bool,
},
Show {
rev: String,
#[arg(long = "print-metadata-key")]
metadata_key: Option<String>,
},
#[command(name = "zub-remote")]
Remote {
path: PathBuf,
},
}
fn main() -> ExitCode {
let cli = Cli::parse();
if let Err(e) = run(cli) {
eprintln!("error: {}", e);
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}
fn run(cli: Cli) -> zub::Result<()> {
let repo_path = resolve_repo_path(cli.repo);
match cli.command {
Commands::Init { path } => {
Repo::init(&path)?;
println!("initialized zub repository at {}", path.display());
}
Commands::Commit {
source,
ref_name,
message,
author,
} => {
let repo = Repo::open(&repo_path)?;
let hash = commit(
&repo,
&source,
&ref_name,
message.as_deref(),
author.as_deref(),
)?;
println!("{}", hash);
}
Commands::Checkout {
ref_name,
destination,
copy,
sparse,
} => {
let repo = Repo::open(&repo_path)?;
let options = CheckoutOptions {
force: false,
hardlink: !copy,
preserve_sparse: sparse,
};
checkout(&repo, &ref_name, &destination, options)?;
println!("checked out {} to {}", ref_name, destination.display());
}
Commands::Log {
ref_name,
max_count,
} => {
let repo = Repo::open(&repo_path)?;
let entries = log(&repo, &ref_name, max_count)?;
for entry in entries {
println!("{}", entry);
}
}
Commands::LsTree {
ref_name,
path,
recursive,
} => {
let repo = Repo::open(&repo_path)?;
let entries = if recursive {
ls_tree_recursive(&repo, &ref_name)?
} else {
ls_tree(&repo, &ref_name, path.as_deref())?
};
for entry in entries {
println!("{}", entry);
}
}
Commands::Diff { ref1, ref2 } => {
let repo = Repo::open(&repo_path)?;
let changes = diff(&repo, &ref1, &ref2)?;
for change in changes {
let prefix = match change.kind {
zub::ChangeKind::Added => "+",
zub::ChangeKind::Deleted => "-",
zub::ChangeKind::Modified => "M",
zub::ChangeKind::MetadataOnly => "m",
};
println!("{} {}", prefix, change.path);
}
}
Commands::Union {
refs,
output,
on_conflict,
message,
} => {
let repo = Repo::open(&repo_path)?;
let resolution = parse_conflict_resolution(&on_conflict)?;
let ref_strs: Vec<&str> = refs.iter().map(|s| s.as_str()).collect();
let opts = UnionOptions {
message,
author: None,
on_conflict: resolution,
};
let hash = union_trees(&repo, &ref_strs, &output, opts)?;
println!("{}", hash);
}
Commands::UnionCheckout {
refs,
destination,
on_conflict,
copy,
} => {
let repo = Repo::open(&repo_path)?;
let resolution = parse_conflict_resolution(&on_conflict)?;
let ref_strs: Vec<&str> = refs.iter().map(|s| s.as_str()).collect();
let options = UnionCheckoutOptions {
force: false,
on_conflict: resolution,
hardlink: !copy,
};
union_checkout(&repo, &ref_strs, &destination, options)?;
println!(
"checked out union of {} refs to {}",
refs.len(),
destination.display()
);
}
Commands::Fsck => {
let repo = Repo::open(&repo_path)?;
let report = fsck(&repo)?;
println!("objects checked: {}", report.objects_checked);
if !report.corrupt_objects.is_empty() {
println!("\ncorrupt objects:");
for obj in &report.corrupt_objects {
println!(" {} {}: {}", obj.object_type, obj.hash, obj.message);
}
}
if !report.missing_objects.is_empty() {
println!("\nmissing objects:");
for obj in &report.missing_objects {
println!(
" {} {} (referenced by {})",
obj.object_type, obj.hash, obj.referenced_by
);
}
}
if !report.dangling_objects.is_empty() {
println!("\ndangling objects: {}", report.dangling_objects.len());
}
if report.is_ok() {
println!("\nrepository is healthy");
} else {
println!("\nrepository has issues");
return Err(zub::Error::CorruptObjectMessage(
"repository integrity check failed".to_string(),
));
}
}
Commands::Gc { dry_run } => {
let repo = Repo::open(&repo_path)?;
let stats = gc(&repo, dry_run)?;
let action = if dry_run { "would remove" } else { "removed" };
println!(
"{} {} blobs, {} trees, {} commits",
action, stats.blobs_removed, stats.trees_removed, stats.commits_removed
);
println!("freed {} bytes", stats.bytes_freed);
}
Commands::Stats => {
let repo = Repo::open(&repo_path)?;
let s = zub::stats(&repo)?;
println!("refs: {}", s.total_refs);
println!();
println!("objects:");
println!(
" blobs: {:>8} total, {:>8} reachable ({:.1} MB on disk)",
s.total_blobs,
s.reachable_blobs,
s.total_blobs_bytes as f64 / 1_000_000.0
);
println!(
" trees: {:>8} total, {:>8} reachable ({:.1} MB on disk)",
s.total_trees,
s.reachable_trees,
s.total_trees_bytes as f64 / 1_000_000.0
);
println!(
" commits: {:>8} total, {:>8} reachable ({:.1} MB on disk)",
s.total_commits,
s.reachable_commits,
s.total_commits_bytes as f64 / 1_000_000.0
);
println!();
if s.unreachable_blobs_bytes > 0 {
println!(
"unreachable blob data: {:.1} MB (run gc to free)",
s.unreachable_blobs_bytes as f64 / 1_000_000.0
);
}
}
Commands::Du { pattern, limit } => {
let repo = Repo::open(&repo_path)?;
let sizes = zub::du(&repo, pattern.as_deref())?;
for entry in sizes.iter().take(limit) {
let mb = entry.bytes as f64 / 1_000_000.0;
println!("{:>10.1} MB {}", mb, entry.ref_name);
}
if sizes.len() > limit {
println!("... and {} more refs", sizes.len() - limit);
}
}
Commands::TruncateHistory { dry_run } => {
let repo = Repo::open(&repo_path)?;
let stats = zub::truncate_history(&repo, dry_run)?;
let action = if dry_run {
"would truncate"
} else {
"truncated"
};
println!(
"{} {}/{} refs",
action, stats.refs_truncated, stats.refs_processed
);
if !dry_run && stats.refs_truncated > 0 {
println!("run gc to free unreachable objects");
}
}
Commands::Remap { force, dry_run } => {
let mut repo = Repo::open(&repo_path)?;
let options = MapOptions { force, dry_run };
let stats = map(&mut repo, &options)?;
if stats.total == 0 && stats.remapped == 0 {
println!("namespace mappings match, nothing to do");
} else {
let action = if dry_run { "would remap" } else { "remapped" };
println!("{} {} of {} blobs", action, stats.remapped, stats.total);
if stats.skipped_unmapped_source > 0 {
println!(
"skipped {} blobs (uid/gid not in source namespace)",
stats.skipped_unmapped_source
);
}
if stats.skipped_unmapped_target > 0 {
println!(
"skipped {} blobs (uid/gid not mappable to current namespace)",
stats.skipped_unmapped_target
);
}
}
}
Commands::Push {
destination,
ref_name,
force,
dry_run,
} => {
let src = Repo::open(&repo_path)?;
let dst = Repo::open(&destination)?;
let options = PushOptions { force, dry_run };
let result = push_local(&src, &dst, &ref_name, &options)?;
if dry_run {
println!("would push {} to {}", result.hash, destination.display());
println!("would transfer {} objects", result.objects_to_transfer);
} else {
println!("pushed {} to {}", result.hash, destination.display());
println!(
"transferred: {} copied, {} hardlinked, {} skipped, {} bytes",
result.stats.copied,
result.stats.hardlinked,
result.stats.skipped,
result.stats.bytes_transferred
);
}
}
Commands::Pull {
source,
ref_name,
fetch_only,
dry_run,
} => {
let src = Repo::open(&source)?;
let dst = Repo::open(&repo_path)?;
let options = PullOptions {
fetch_only,
dry_run,
};
let result = pull_local(&src, &dst, &ref_name, &options)?;
if dry_run {
println!("would pull {} from {}", result.hash, source.display());
println!("would transfer {} objects", result.objects_to_transfer);
} else {
println!("pulled {} from {}", result.hash, source.display());
println!(
"transferred: {} copied, {} hardlinked, {} skipped, {} bytes",
result.stats.copied,
result.stats.hardlinked,
result.stats.skipped,
result.stats.bytes_transferred
);
}
}
Commands::Refs => {
let repo = Repo::open(&repo_path)?;
let refs = zub::list_refs(&repo)?;
for ref_name in refs {
let hash = zub::read_ref(&repo, &ref_name)?;
println!("{} {}", hash, ref_name);
}
}
Commands::ShowRef { ref_name } => {
let repo = Repo::open(&repo_path)?;
let hash = zub::resolve_ref(&repo, &ref_name)?;
println!("{}", hash);
}
Commands::DeleteRef { ref_name } => {
let repo = Repo::open(&repo_path)?;
zub::delete_ref(&repo, &ref_name)?;
println!("deleted ref {}", ref_name);
}
Commands::DeleteRefs { pattern } => {
let repo = Repo::open(&repo_path)?;
let deleted = zub::delete_refs_matching(&repo, &pattern)?;
if deleted.is_empty() {
println!("no refs matched pattern {}", pattern);
} else {
for r in deleted {
println!("deleted ref {}", r);
}
}
}
Commands::CatFile {
object_type,
object,
} => {
let repo = Repo::open(&repo_path)?;
let hash = Hash::from_hex(&object)?;
match object_type.as_str() {
"blob" => {
let data = read_blob(&repo, &hash)?;
io::stdout().write_all(&data).map_err(|e| zub::Error::Io {
path: "stdout".into(),
source: e,
})?;
}
"tree" => {
let tree = read_tree(&repo, &hash)?;
for entry in tree.entries() {
println!("{} {}", entry.kind.type_name(), entry.name);
}
}
"commit" => {
let commit = read_commit(&repo, &hash)?;
println!("tree {}", commit.tree);
for parent in &commit.parents {
println!("parent {}", parent);
}
println!("author {}", commit.author);
println!("timestamp {}", commit.timestamp);
println!();
println!("{}", commit.message);
}
_ => {
return Err(zub::Error::InvalidObjectType(object_type));
}
}
}
Commands::RevParse { rev, short } => {
let repo = Repo::open(&repo_path)?;
let hash = zub::resolve_ref(&repo, &rev)?;
if short {
println!("{}", &hash.to_hex()[..12]);
} else {
println!("{}", hash);
}
}
Commands::Show { rev, metadata_key } => {
let repo = Repo::open(&repo_path)?;
let hash = zub::resolve_ref(&repo, &rev)?;
let commit = read_commit(&repo, &hash)?;
match metadata_key {
Some(key) => {
match commit.metadata.get(&key) {
Some(value) => println!("{}", value),
None => {
return Err(zub::Error::MetadataKeyNotFound(key));
}
}
}
None => {
println!("commit {}", hash);
println!("tree {}", commit.tree);
for parent in &commit.parents {
println!("parent {}", parent);
}
println!("author {}", commit.author);
println!("timestamp {}", commit.timestamp);
if !commit.metadata.is_empty() {
println!();
println!("metadata:");
for (k, v) in &commit.metadata {
println!(" {}: {}", k, v);
}
}
println!();
println!("{}", commit.message);
}
}
}
Commands::Remote { path } => {
run_remote_helper(&path)?;
}
}
Ok(())
}
fn parse_conflict_resolution(s: &str) -> zub::Result<ConflictResolution> {
match s.to_lowercase().as_str() {
"error" => Ok(ConflictResolution::Error),
"first" => Ok(ConflictResolution::First),
"last" => Ok(ConflictResolution::Last),
_ => Err(zub::Error::InvalidConflictResolution(s.to_string())),
}
}
fn run_remote_helper(repo_path: &Path) -> zub::Result<()> {
let repo = Repo::open(repo_path)?;
zub::transport::serve_remote(&repo)
}