use clap::{CommandFactory, Parser, Subcommand};
use std::io::{self, Write};
use std::path::PathBuf;
pub mod formatters;
pub fn safe_print(content: &str) -> Result<(), String> {
match io::stdout().write_all(content.as_bytes()) {
Ok(()) => {
match io::stdout().flush() {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()),
Err(e) => Err(format!("Error flushing stdout: {e}")),
}
}
Err(e) if e.kind() == io::ErrorKind::BrokenPipe => Ok(()),
Err(e) => Err(format!("Error writing to stdout: {e}")),
}
}
pub fn safe_println(content: &str) -> Result<(), String> {
safe_print(&format!("{content}\n"))
}
fn is_likely_hash(input: &str) -> bool {
if input.len() < 4 || input.len() > 40 {
return false;
}
input.chars().all(|c| c.is_ascii_hexdigit())
}
fn is_likely_path(input: &str) -> bool {
input.contains('/') || input.contains('\\') || input.contains('.')
}
#[derive(Parser)]
#[command(name = "git-plumber")]
#[command(about = "Explorer for git internals, the plumbing", long_about = None)]
pub struct Cli {
#[arg(long = "repo", short = 'r', default_value = ".", global = true)]
pub repo_path: PathBuf,
#[arg(long = "version", short = 'v', action = clap::ArgAction::SetTrue)]
pub version: bool,
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand)]
pub enum Commands {
Tui {
#[arg(long = "reduced-motion", short = 'm', action = clap::ArgAction::SetTrue)]
reduced_motion: bool,
},
List {
#[arg(
default_value = "all",
help = "The type of objects to list:\n pack - for pack files only\n loose - for loose objects only\n all - for everything supported\n"
)]
object_type: String,
},
View {
#[arg(
required = true,
help = "Object hash (4-40 characters) or path to file"
)]
target: String,
},
}
pub fn run() -> Result<(), String> {
let cli = Cli::parse();
if cli.version {
safe_print(&crate::version::get_version_info().to_string())?;
return Ok(());
}
let plumber = crate::GitPlumber::new(&cli.repo_path);
match &cli.command {
Some(Commands::Tui { reduced_motion }) => crate::tui::run_tui_with_options(
plumber,
crate::tui::RunOptions {
reduced_motion: *reduced_motion,
},
),
Some(Commands::List { object_type }) => {
match object_type.as_str() {
"pack" => {
match plumber.list_pack_files() {
Ok(pack_files) => {
if pack_files.is_empty() {
safe_println("No pack files found")?;
} else {
safe_println(&format!("Found {} pack files:", pack_files.len()))?;
for (i, file) in pack_files.iter().enumerate() {
safe_println(&format!("{}. {}", i + 1, file.display()))?;
}
}
Ok(())
}
Err(e) => Err(format!("Error listing pack files: {e}")),
}
}
"loose" => {
match plumber.get_loose_object_stats() {
Ok(stats) => {
safe_println("Loose object statistics:")?;
safe_println(&stats.summary())?;
safe_println("")?;
match plumber.list_parsed_loose_objects(stats.total_count) {
Ok(loose_objects) => {
if loose_objects.is_empty() {
safe_println("No loose objects found")?;
} else {
safe_println("Loose objects:")?;
for (i, obj) in loose_objects.iter().enumerate() {
let (short_hash, rest_hash) = obj.object_id.split_at(8);
safe_println(&format!(
"{}. \x1b[1m{}\x1b[22m{} ({}) - {} bytes",
i + 1,
short_hash,
rest_hash,
obj.object_type,
obj.size
))?;
}
}
Ok(())
}
Err(e) => Err(format!("Error listing loose objects: {e}")),
}
}
Err(e) => Err(format!("Error getting loose object stats: {e}")),
}
}
_ => {
let mut has_error = false;
let mut error_messages = Vec::new();
safe_println("Pack files:")?;
match plumber.list_pack_files() {
Ok(pack_files) => {
if pack_files.is_empty() {
safe_println(" No pack files found")?;
} else {
for file in pack_files {
safe_println(&format!(" {}", file.display()))?;
}
}
}
Err(e) => {
has_error = true;
error_messages.push(format!("Error listing pack files: {e}"));
safe_println(&format!(" Error listing pack files: {e}"))?;
}
}
safe_println("")?;
safe_println("Loose objects:")?;
match plumber.get_loose_object_stats() {
Ok(stats) => {
safe_println(&format!(" {}", stats.summary().replace('\n', "\n ")))?;
}
Err(e) => {
has_error = true;
error_messages.push(format!("Error getting loose object stats: {e}"));
safe_println(&format!(" Error getting loose object stats: {e}"))?;
}
}
if has_error {
Err(error_messages.join("; "))
} else {
Ok(())
}
}
}
}
Some(Commands::View { target }) => {
if is_likely_path(target) && !is_likely_hash(target) {
let path = PathBuf::from(target);
if path.exists() {
if path.extension().and_then(|s| s.to_str()) == Some("pack") {
plumber.parse_pack_file_rich(&path)
} else {
plumber.view_file_as_object(&path)
}
} else {
Err(format!("File not found: {}", path.display()))
}
} else if is_likely_hash(target) {
plumber.view_object_by_hash(target)
} else {
let path = PathBuf::from(target);
if path.exists() {
if path.extension().and_then(|s| s.to_str()) == Some("pack") {
plumber.parse_pack_file_rich(&path)
} else {
plumber.view_file_as_object(&path)
}
} else if target.chars().all(|c| c.is_ascii_hexdigit()) {
if target.len() < 4 {
Err(format!(
"Hash too short: '{target}'. Git object hashes must be at least 4 characters long."
))
} else if target.len() > 40 {
Err(format!(
"Hash too long: '{target}'. Git object hashes must be at most 40 characters long."
))
} else {
plumber.view_object_by_hash(target)
}
} else {
Err(format!(
"Invalid target: '{target}' is neither a valid file path nor object hash (hashes must be 4-40 hex characters)"
))
}
}
}
None => {
let mut cmd = Cli::command();
cmd.print_help().map_err(|e| e.to_string())?;
Ok(())
}
}
}