use anyhow::{anyhow, Result};
use clap::Parser;
use colored::Colorize;
use git2::Oid;
use gitsw::git::GitRepo;
use gitsw::hooks;
use gitsw::prompt::{self, StashAction, UnstashAction};
use gitsw::state::StateManager;
#[derive(Parser, Debug)]
#[command(name = "gitsw")]
#[command(
author,
version,
about = "Contextual Git branch switcher with automatic stash management"
)]
struct Args {
#[arg(value_name = "BRANCH")]
branch: Option<String>,
#[arg(short, long)]
list: bool,
#[arg(short, long)]
recent: bool,
#[arg(short, long)]
status: bool,
#[arg(short, long, value_name = "BRANCH")]
delete: Option<String>,
#[arg(short = 't', long, value_name = "REMOTE/BRANCH")]
track: Option<String>,
#[arg(long)]
no_stash: bool,
#[arg(long)]
no_install: bool,
#[arg(short = 'c', long)]
create: bool,
#[arg(short = 'p', long)]
pull: bool,
}
fn main() {
if let Err(e) = run() {
eprintln!("{}: {}", "error".red().bold(), e);
std::process::exit(1);
}
}
fn run() -> Result<()> {
let args = Args::parse();
if args.status {
return show_status();
}
if args.list {
return list_stashes();
}
if args.recent {
return show_recent();
}
if let Some(branch) = args.delete {
return delete_branch(&branch);
}
if let Some(remote_branch) = args.track {
return track_remote(&remote_branch, !args.no_stash, !args.no_install, args.pull);
}
let target_branch = match args.branch {
Some(b) => b,
None => prompt::select_branch()?,
};
switch_branch(
&target_branch,
!args.no_stash,
!args.no_install,
args.create,
args.pull,
)
}
fn show_status() -> Result<()> {
let mut repo = GitRepo::open()?;
let state = StateManager::load(repo.git_dir())?;
let workdir = repo.workdir()?.to_path_buf();
let current_branch = repo.get_current_branch()?;
println!("{} {}", "Branch:".bold(), current_branch.green());
if repo.has_uncommitted_changes()? {
let summary = repo.get_changes_summary()?;
println!("{} {}", "Changes:".bold(), summary.yellow());
} else {
println!("{} {}", "Changes:".bold(), "clean".dimmed());
}
if let Some(branch_state) = state.get_branch(¤t_branch) {
if branch_state.stash_id.is_some() {
let stashes = repo.list_stashes()?;
let stash_exists = branch_state.stash_id.as_ref().is_some_and(|id| {
Oid::from_str(id)
.map(|oid| stashes.iter().any(|s| s.oid == oid))
.unwrap_or(false)
});
if stash_exists {
println!("{} {}", "Stash:".bold(), "present".yellow());
}
}
}
if let Some((pm, _)) = hooks::get_lock_file_hash(&workdir)? {
println!("{} {}", "Package manager:".bold(), pm.name());
}
if let Some(remote) = repo.get_tracking_remote(¤t_branch)? {
println!("{} {}", "Tracking:".bold(), remote);
}
Ok(())
}
fn list_stashes() -> Result<()> {
let mut repo = GitRepo::open()?;
let state = StateManager::load(repo.git_dir())?;
let current_branch = repo.get_current_branch()?;
let stashes = repo.list_stashes()?;
let branches_with_stashes = state.branches_with_stashes();
if branches_with_stashes.is_empty() {
println!("{}", "No branches with git-switch stashes found.".dimmed());
return Ok(());
}
println!("{}", "Branches with stashed changes:".bold());
println!();
for (branch_name, branch_state) in branches_with_stashes {
let is_current = branch_name == current_branch;
let marker = if is_current { "* " } else { " " };
let branch_display = if is_current {
branch_name.green().bold()
} else {
branch_name.normal()
};
let stash_exists = branch_state.stash_id.as_ref().is_some_and(|id| {
Oid::from_str(id)
.map(|oid| stashes.iter().any(|s| s.oid == oid))
.unwrap_or(false)
});
let stash_status = if stash_exists {
"stash present".yellow()
} else {
"stash missing".red()
};
let time_display = format_time_ago(branch_state.last_visited);
println!(
"{}{} ({}, last visited {})",
marker,
branch_display,
stash_status,
time_display.dimmed()
);
}
Ok(())
}
fn show_recent() -> Result<()> {
let repo = GitRepo::open()?;
let state = StateManager::load(repo.git_dir())?;
let current_branch = repo.get_current_branch()?;
let mut recent = state.recent_branches(10);
if recent.is_empty() {
println!("{}", "No recent branches tracked yet.".dimmed());
return Ok(());
}
println!("{}", "Recent branches:".bold());
println!();
recent.sort_by(|a, b| b.1.last_visited.cmp(&a.1.last_visited));
for (i, (branch_name, branch_state)) in recent.iter().enumerate() {
let is_current = *branch_name == current_branch;
let marker = if is_current { "* " } else { " " };
let branch_display = if is_current {
branch_name.green().bold()
} else {
branch_name.normal()
};
let time_display = format_time_ago(branch_state.last_visited);
let stash_indicator = if branch_state.stash_id.is_some() {
" [stash]".yellow()
} else {
"".normal()
};
println!(
"{}{} {}{} ({})",
marker,
format!("[{}]", i + 1).dimmed(),
branch_display,
stash_indicator,
time_display.dimmed()
);
}
Ok(())
}
fn delete_branch(branch_name: &str) -> Result<()> {
let mut repo = GitRepo::open()?;
let mut state = StateManager::load(repo.git_dir())?;
let current_branch = repo.get_current_branch()?;
if branch_name == current_branch {
return Err(anyhow!("Cannot delete the current branch"));
}
if !repo.branch_exists(branch_name) {
return Err(anyhow!("Branch '{}' not found", branch_name));
}
if let Some(branch_state) = state.get_branch(branch_name) {
if let Some(stash_id) = &branch_state.stash_id {
if let Ok(stash_oid) = Oid::from_str(stash_id) {
println!(
"{} Branch has a stash. It will be dropped.",
"warning:".yellow().bold()
);
if prompt::confirm_delete(branch_name)? {
let _ = repo.stash_drop(stash_oid);
} else {
println!("{} Delete aborted.", "info:".blue().bold());
return Ok(());
}
}
}
} else if !prompt::confirm_delete(branch_name)? {
println!("{} Delete aborted.", "info:".blue().bold());
return Ok(());
}
repo.delete_branch(branch_name)?;
state.remove_branch(branch_name);
state.save()?;
println!(
"{} Deleted branch '{}'",
"done:".green().bold(),
branch_name
);
Ok(())
}
fn track_remote(
remote_branch: &str,
auto_stash: bool,
auto_install: bool,
pull: bool,
) -> Result<()> {
let repo = GitRepo::open()?;
let parts: Vec<&str> = remote_branch.splitn(2, '/').collect();
if parts.len() != 2 {
return Err(anyhow!("Invalid format. Use: origin/branch-name"));
}
let remote = parts[0];
let branch = parts[1];
println!("{} Fetching from '{}'...", "info:".blue().bold(), remote);
repo.fetch(remote)?;
if repo.branch_exists(branch) {
println!(
"{} Local branch '{}' already exists, switching to it",
"info:".blue().bold(),
branch
);
return switch_branch(branch, auto_stash, auto_install, false, pull);
}
println!(
"{} Creating branch '{}' tracking '{}'...",
"info:".blue().bold(),
branch.green(),
remote_branch
);
repo.create_tracking_branch(branch, remote_branch)?;
switch_branch(branch, auto_stash, auto_install, false, pull)
}
fn switch_branch(
target_branch: &str,
auto_stash: bool,
auto_install: bool,
create: bool,
pull: bool,
) -> Result<()> {
let mut repo = GitRepo::open()?;
let workdir = repo.workdir()?.to_path_buf();
let branch_exists = repo.branch_exists(target_branch);
if !branch_exists && !create {
return Err(anyhow!(
"Branch '{}' not found. Use -c to create it.",
target_branch
));
}
let current_branch = repo.get_current_branch()?;
if current_branch == target_branch {
println!(
"{} Already on '{}'",
"info:".blue().bold(),
target_branch.green()
);
if pull {
do_pull(&mut repo)?;
}
return Ok(());
}
let mut state = StateManager::load(repo.git_dir())?;
state.touch_branch(¤t_branch);
state.save()?;
if auto_stash && repo.has_uncommitted_changes()? {
let changes_summary = repo.get_changes_summary()?;
match prompt::prompt_stash_conflict(&changes_summary)? {
StashAction::Stash => {
let message = format!("git-switch: {}", current_branch);
println!(
"{} Stashing changes on '{}'...",
"info:".blue().bold(),
current_branch.yellow()
);
let stash_oid = repo.stash_save(&message)?;
state.set_stash(¤t_branch, Some(stash_oid.to_string()));
if let Some((_, hash)) = hooks::get_lock_file_hash(&workdir)? {
state.set_lock_hash(¤t_branch, Some(hash));
}
state.save()?;
println!("{} Changes stashed.", "done:".green().bold());
}
StashAction::Discard => {
if !prompt::confirm_discard()? {
println!("{} Switch aborted.", "info:".blue().bold());
return Ok(());
}
println!("{} Discarding changes...", "warning:".yellow().bold());
repo.discard_changes()?;
println!("{} Changes discarded.", "done:".green().bold());
}
StashAction::Abort => {
println!("{} Switch aborted.", "info:".blue().bold());
return Ok(());
}
}
} else if !auto_stash && repo.has_uncommitted_changes()? {
println!(
"{} Skipping stash (--no-stash), uncommitted changes may prevent switch",
"warning:".yellow().bold()
);
}
if !branch_exists && create {
println!(
"{} Creating branch '{}'...",
"info:".blue().bold(),
target_branch.green()
);
repo.create_branch(target_branch)?;
}
println!(
"{} Switching to '{}'...",
"info:".blue().bold(),
target_branch.green()
);
repo.switch_branch(target_branch)?;
println!(
"{} Switched to '{}'",
"done:".green().bold(),
target_branch.green()
);
if let Some(branch_state) = state.get_branch(target_branch).cloned() {
if let Some(stash_id) = &branch_state.stash_id {
if let Ok(stash_oid) = Oid::from_str(stash_id) {
println!(
"{} Found stashed changes for this branch",
"info:".blue().bold()
);
match repo.stash_apply(stash_oid) {
Ok(()) => {
println!("{} Restored stashed changes.", "done:".green().bold());
if let Err(e) = repo.stash_drop(stash_oid) {
eprintln!("{} Failed to drop stash: {}", "warning:".yellow().bold(), e);
}
state.clear_stash(target_branch);
state.save()?;
}
Err(_) => match prompt::prompt_unstash_conflict()? {
UnstashAction::Apply => {
println!(
"{} Please resolve conflicts manually. Stash preserved.",
"warning:".yellow().bold()
);
}
UnstashAction::Skip => {
println!(
"{} Skipping stash restoration. Stash preserved for later.",
"info:".blue().bold()
);
}
UnstashAction::Abort => {
println!(
"{} Switching back to '{}'...",
"info:".blue().bold(),
current_branch
);
repo.switch_branch(¤t_branch)?;
return Ok(());
}
},
}
}
}
}
if pull {
do_pull(&mut repo)?;
}
if auto_install {
handle_package_install(&workdir, &mut state, target_branch)?;
}
state.touch_branch(target_branch);
state.save()?;
Ok(())
}
fn do_pull(repo: &mut GitRepo) -> Result<()> {
println!("{} Pulling latest changes...", "info:".blue().bold());
match repo.pull() {
Ok(()) => {
println!("{} Pull completed.", "done:".green().bold());
}
Err(e) => {
eprintln!("{} Pull failed: {}", "warning:".yellow().bold(), e);
}
}
Ok(())
}
fn handle_package_install(
workdir: &std::path::Path,
state: &mut StateManager,
branch: &str,
) -> Result<()> {
let lock_info = hooks::get_lock_file_hash(workdir)?;
if let Some((pm, current_hash)) = lock_info {
let stored_hash = state
.get_branch(branch)
.and_then(|s| s.lock_file_hash.as_ref());
let should_install = match stored_hash {
Some(hash) => hash != ¤t_hash,
None => false,
};
if should_install && prompt::prompt_install(pm.name())? {
println!("{} Running {} install...", "info:".blue().bold(), pm.name());
if hooks::run_install(pm, workdir)? {
println!("{} Install completed.", "done:".green().bold());
} else {
eprintln!(
"{} Install failed. Please run manually.",
"error:".red().bold()
);
}
}
state.set_lock_hash(branch, Some(current_hash));
state.save()?;
}
Ok(())
}
fn format_time_ago(time: chrono::DateTime<chrono::Utc>) -> String {
let duration = chrono::Utc::now().signed_duration_since(time);
let minutes = duration.num_minutes();
let hours = duration.num_hours();
let days = duration.num_days();
if minutes < 1 {
"just now".to_string()
} else if minutes < 60 {
format!("{} min ago", minutes)
} else if hours < 24 {
format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" })
} else if days == 1 {
"yesterday".to_string()
} else {
format!("{} days ago", days)
}
}