mod action;
mod app;
mod components;
mod config;
mod diagnostic;
mod event;
mod git;
mod repo_id;
mod session;
mod theme;
mod tui;
mod update_checker;
mod watcher;
use clap::{Parser, Subcommand};
use color_eyre::Result;
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(name = "gitpane", about = "Multi-repo Git workspace dashboard", version = gitpane::VERSION)]
struct Cli {
#[arg(long)]
root: Option<PathBuf>,
#[arg(long)]
cwd: bool,
#[arg(long)]
theme: Option<String>,
#[arg(long, default_value_t = 10, hide = true)]
frame_rate: u16,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand, Debug)]
enum Command {
Update,
Themes,
#[command(alias = "diagnostics")]
Diagnostic,
}
fn main() -> Result<()> {
color_eyre::install()?;
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| runtime.block_on(run())));
if result.is_err() {
let waited = std::time::Instant::now();
while app::mutating_git_ops() > 0 && waited.elapsed() < std::time::Duration::from_secs(10) {
std::thread::sleep(std::time::Duration::from_millis(50));
}
}
runtime.shutdown_background();
match result {
Ok(result) => result,
Err(panic) => std::panic::resume_unwind(panic),
}
}
async fn run() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Some(Command::Update) => return self_update(),
Some(Command::Themes) => return list_themes(cli.theme.as_deref()),
Some(Command::Diagnostic) => {
return run_diagnostic(cli.root, cli.cwd, cli.theme.as_deref());
}
None => {}
}
install_tracing()?;
let mut config = config::Config::load()?;
if let Some(root) = cli.root {
config.override_root(root);
}
if cli.cwd {
config.override_root(std::env::current_dir()?);
}
if let Some(theme_name) = cli.theme {
config.runtime_theme_override = Some(theme_name);
config.resolve_theme_with_env(&config::RealEnv);
}
config.ui.frame_rate = cli.frame_rate;
let mut app = app::App::new(config);
app.run().await?;
Ok(())
}
fn install_tracing() -> Result<()> {
let env_filter = std::env::var("GITPANE_LOG")
.or_else(|_| std::env::var("RUST_LOG"))
.ok();
let log_file = std::env::var("GITPANE_LOG_FILE").ok();
if env_filter.is_none() && log_file.is_none() {
return Ok(());
}
let filter = match env_filter {
Some(v) => tracing_subscriber::EnvFilter::new(v),
None => tracing_subscriber::EnvFilter::new("gitpane=info"),
};
if let Some(path) = log_file {
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::sync::Mutex::new(file))
.with_ansi(false)
.init();
} else {
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.init();
}
Ok(())
}
fn list_themes(cli_override: Option<&str>) -> Result<()> {
let env = config::RealEnv;
let mut config = config::Config::load()?;
if let Some(name) = cli_override {
config.runtime_theme_override = Some(name.to_string());
}
let dirs = config.theme_dirs(&env);
let current = config.effective_theme_name();
for name in theme::discover_all_theme_names(&dirs) {
let marker = if name == current { "*" } else { " " };
println!("{marker} {name}");
}
Ok(())
}
fn run_diagnostic(root: Option<PathBuf>, cwd: bool, theme_override: Option<&str>) -> Result<()> {
let env = config::RealEnv;
let mut config = config::Config::load()?;
if let Some(root) = root {
config.override_root(root);
}
if cwd {
config.override_root(std::env::current_dir()?);
}
if let Some(name) = theme_override {
config.runtime_theme_override = Some(name.to_string());
config.resolve_theme_with_env(&env);
}
let repos = git::scanner::discover_repos(&config);
let shadowed = config.shadowed_config_paths(&env);
let report = diagnostic::render(
&config,
&repos,
&shadowed,
diagnostic::RuntimeInfo::current(gitpane::VERSION),
);
print!("{report}");
Ok(())
}
fn self_update() -> Result<()> {
let base = env!("CARGO_PKG_VERSION");
println!("gitpane v{} — checking for updates...", gitpane::VERSION);
if gitpane::VERSION != base {
println!(
"note: this build sets a custom version; update checks use the base version {base}"
);
println!(
"note: `gitpane update` runs `cargo install gitpane` and may replace a package-managed binary"
);
}
let Some(latest) = update_checker::check_latest() else {
println!("Already up to date.");
return Ok(());
};
println!("New version available: v{latest}");
println!("Running: cargo install gitpane --version {latest}");
let status = std::process::Command::new("cargo")
.args(["install", "gitpane", "--version", &latest])
.status();
match status {
Ok(s) if s.success() => println!("Updated to v{latest}."),
Ok(s) => {
eprintln!("cargo install exited with {s}");
eprintln!(
"If v{latest} was tagged in the last few minutes it may still be propagating to crates.io; try again shortly."
);
std::process::exit(1);
}
Err(e) => {
eprintln!("Failed to run cargo: {e}");
eprintln!("Make sure cargo is installed (https://rustup.rs)");
std::process::exit(1);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn cwd_is_optional() {
let cli = Cli::try_parse_from(["gitpane"]).unwrap();
assert!(!cli.cwd);
}
#[test]
fn cwd_flag_scans_the_current_directory() {
let cli = Cli::try_parse_from(["gitpane", "--cwd"]).unwrap();
assert!(cli.cwd);
}
#[test]
fn cwd_does_not_consume_a_subcommand() {
let cli = Cli::try_parse_from(["gitpane", "--cwd", "diagnostic"]).unwrap();
assert!(cli.cwd);
assert!(matches!(cli.command, Some(Command::Diagnostic)));
}
#[test]
fn cwd_coexists_with_root() {
let cli = Cli::try_parse_from(["gitpane", "--root", "~/Code", "--cwd"]).unwrap();
assert_eq!(cli.root, Some(PathBuf::from("~/Code")));
assert!(cli.cwd);
}
}