use clap::{CommandFactory, Parser, Subcommand};
mod cmd;
mod display;
mod driver_registry;
mod fuzzy;
mod ref_utils;
mod remote_proto;
mod style;
#[derive(Parser)]
#[command(
name = "suture",
version,
about = "Universal Semantic Version Control System"
)]
struct Cli {
#[arg(short = 'C', global = true)]
repo_path: Option<String>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
#[command(after_long_help = "\
EXAMPLES:
suture init # Initialize in current directory
suture init my-project # Initialize in a new directory")]
Init {
#[arg(default_value = ".")]
path: String,
},
#[command(after_long_help = "\
EXAMPLES:
suture status # Show working tree status")]
Status,
#[command(after_long_help = "\
EXAMPLES:
suture ignore list # List ignore patterns
suture ignore check foo.o # Check if a path is ignored")]
Ignore {
#[command(subcommand)]
action: IgnoreAction,
},
#[command(after_long_help = "\
EXAMPLES:
suture add file.txt # Stage a specific file
suture add src/ # Stage all files in src/
suture add --all # Stage all modified/deleted files
suture add -p # Interactively choose which files to stage")]
Add {
paths: Vec<String>,
#[arg(short, long)]
all: bool,
#[arg(short = 'p', long)]
patch: bool,
},
#[command(after_long_help = "\
EXAMPLES:
suture rm file.txt # Remove file from tree and staging
suture rm --cached file # Remove from staging only, keep on disk")]
Rm {
paths: Vec<String>,
#[arg(short, long)]
cached: bool,
},
#[command(after_long_help = "\
EXAMPLES:
suture commit \"fix typo\" # Commit staged changes
suture commit -a \"update\" # Auto-stage all and commit
suture commit --all \"WIP\" # Same as above")]
Commit {
message: String,
#[arg(short, long)]
all: bool,
},
#[command(after_long_help = "\
EXAMPLES:
suture branch # List branches
suture branch --list # List branches (explicit)
suture branch feature # Create branch 'feature'
suture branch feature main # Create from specific target
suture branch -d old-branch # Delete a branch
suture branch --protect main # Protect 'main' from force-push
suture branch --unprotect main # Unprotect 'main'")]
Branch {
name: Option<String>,
#[arg(short, long)]
target: Option<String>,
#[arg(short, long)]
delete: bool,
#[arg(short, long)]
list: bool,
#[arg(long)]
protect: bool,
#[arg(long)]
unprotect: bool,
},
#[command(after_long_help = "\
EXAMPLES:
suture log # Show log for HEAD
suture log --oneline # Compact one-line format
suture log --graph # ASCII graph of branch topology
suture log --all # Show commits across all branches
suture log --author alice # Filter by author
suture log --grep \"fix\" # Filter by message pattern
suture log --since \"2 weeks ago\" # Filter by date")]
Log {
branch: Option<String>,
#[arg(short, long)]
graph: bool,
#[arg(long)]
first_parent: bool,
#[arg(long)]
oneline: bool,
#[arg(long)]
author: Option<String>,
#[arg(long)]
grep: Option<String>,
#[arg(long)]
all: bool,
#[arg(long)]
since: Option<String>,
#[arg(long)]
until: Option<String>,
},
#[command(after_long_help = "\
EXAMPLES:
suture checkout main # Switch to 'main' branch
suture checkout -b feature # Create and switch to 'feature'
suture checkout -b feat main # Create 'feat' from 'main'")]
Checkout {
branch: Option<String>,
#[arg(short = 'b', long)]
new_branch: Option<String>,
},
#[command(after_long_help = "\
EXAMPLES:
suture mv old.txt new.txt # Rename a file
suture mv file dir/ # Move file into directory")]
Mv {
source: String,
destination: String,
},
#[command(after_long_help = "\
EXAMPLES:
suture diff # Working tree vs staging area
suture diff --cached # Staging area vs HEAD
suture diff --from main # Compare main branch to working tree
suture diff --from main --to feature # Compare two branches")]
Diff {
#[arg(short, long)]
from: Option<String>,
#[arg(short, long)]
to: Option<String>,
#[arg(long)]
cached: bool,
},
#[command(after_long_help = "\
EXAMPLES:
suture revert abc123 # Revert a commit
suture revert abc123 -m \"revert fix\" # With custom message")]
Revert {
commit: String,
#[arg(short, long)]
message: Option<String>,
},
#[command(after_long_help = "\
EXAMPLES:
suture merge feature # Merge 'feature' into current branch
suture merge --dry-run feature # Preview merge without modifying working tree")]
Merge {
source: String,
#[arg(long)]
dry_run: bool,
},
#[command(after_long_help = "\
EXAMPLES:
suture merge-file base.txt ours.txt theirs.txt
suture merge-file --driver json base.json ours.json theirs.json
suture merge-file --driver yaml -o merged.yaml base.yaml ours.yaml theirs.yaml
suture merge-file --label-ours HEAD --label-theirs feature base.txt ours.txt theirs.txt")]
MergeFile {
base: String,
ours: String,
theirs: String,
#[arg(long)]
label_ours: Option<String>,
#[arg(long)]
label_theirs: Option<String>,
#[arg(long)]
driver: Option<String>,
#[arg(short, long)]
output: Option<String>,
},
#[command(after_long_help = "\
EXAMPLES:
suture cherry-pick abc123 # Apply commit onto current branch")]
CherryPick {
commit: String,
},
#[command(after_long_help = "\
EXAMPLES:
suture rebase main # Rebase current branch onto 'main'
suture rebase -i main # Interactive rebase onto 'main'
suture rebase --abort # Abort an in-progress rebase")]
Rebase {
branch: String,
#[arg(short, long)]
interactive: bool,
#[arg(long, visible_alias = "continue")]
resume: bool,
#[arg(long)]
abort: bool,
},
#[command(after_long_help = "\
EXAMPLES:
suture blame src/main.rs # Show line-by-line attribution")]
Blame {
path: String,
},
#[command(after_long_help = "\
EXAMPLES:
suture tag # List tags
suture tag --list # List tags (explicit)
suture tag v1.0 # Create lightweight tag
suture tag v1.0 -m \"release 1.0\" # Create annotated tag
suture tag -d v0.9 # Delete a tag")]
Tag {
name: Option<String>,
#[arg(short, long)]
target: Option<String>,
#[arg(short, long)]
delete: bool,
#[arg(short, long)]
list: bool,
#[arg(short, long)]
annotate: bool,
#[arg(short, long)]
message: Option<String>,
},
#[command(after_long_help = "\
EXAMPLES:
suture config # List all config
suture config user.name # Get a config value
suture config user.name=Alice # Set a config value
suture config --global user.name=Alice # Set global config")]
Config {
#[arg(long)]
global: bool,
key_value: Vec<String>,
},
Remote {
#[command(subcommand)]
action: RemoteAction,
},
#[command(after_long_help = "\
EXAMPLES:
suture push # Push all branches to origin
suture push --force # Force push (skip fast-forward check)
suture push origin feature # Push only 'feature' branch to 'origin'")]
Push {
#[arg(default_value = "origin")]
remote: String,
#[arg(long)]
force: bool,
branch: Option<String>,
},
#[command(after_long_help = "\
EXAMPLES:
suture pull # Pull and merge from origin
suture pull --rebase # Pull with rebase
suture pull upstream # Pull from a specific remote")]
Pull {
#[arg(default_value = "origin")]
remote: String,
#[arg(long)]
rebase: bool,
},
#[command(after_long_help = "\
EXAMPLES:
suture fetch # Fetch from origin
suture fetch --depth 5 # Shallow fetch last 5 commits")]
Fetch {
#[arg(default_value = "origin")]
remote: String,
#[arg(long, help = "Limit fetch to the last N commits")]
depth: Option<u32>,
},
#[command(after_long_help = "\
EXAMPLES:
suture clone http://localhost:50051/my-repo
suture clone http://localhost:50051/my-repo my-local-dir
suture clone --depth 10 http://localhost:50051/my-repo")]
Clone {
url: String,
dir: Option<String>,
#[arg(short, long)]
depth: Option<u32>,
},
#[command(after_long_help = "\
EXAMPLES:
suture reset HEAD~1 # Reset to parent (mixed mode)
suture reset abc123 --soft # Keep changes staged
suture reset abc123 --hard # Discard all changes")]
Reset {
target: String,
#[arg(short, long, default_value = "mixed")]
mode: String,
},
Key {
#[command(subcommand)]
action: KeyAction,
},
Stash {
#[command(subcommand)]
action: StashAction,
},
#[command(after_long_help = "\
EXAMPLES:
suture completions bash > ~/.bash_completion.d/suture
suture completions zsh > ~/.zfunc/_suture
suture completions fish > ~/.config/fish/completions/suture.fish
suture completions powershell > suture.ps1
suture completions nushell | save -f ~/.cache/suture/completions.nu")]
Completions {
shell: String,
},
#[command(after_long_help = "\
EXAMPLES:
suture show HEAD # Show HEAD commit
suture show abc123 # Show specific commit")]
Show {
commit: String,
},
Reflog,
Drivers,
Shortlog {
branch: Option<String>,
#[arg(short = 'n', long)]
number: Option<usize>,
},
Notes {
#[command(subcommand)]
action: NotesAction,
},
#[command(after_long_help = "\
EXAMPLES:
suture worktree add ../feature # Create worktree at ../feature
suture worktree add hotfix -b fix # Create 'hotfix' on new branch 'fix'
suture worktree list # List all worktrees
suture worktree remove feature # Remove worktree 'feature'")]
Worktree {
#[command(subcommand)]
action: WorktreeAction,
},
Version,
Gc,
Fsck,
Bisect {
#[command(subcommand)]
action: BisectAction,
},
#[command(after_long_help = "\
EXAMPLES:
suture undo # Undo the last commit (soft reset)
suture undo --steps 3 # Undo the last 3 commits")]
Undo {
#[arg(short, long)]
steps: Option<usize>,
},
#[command(after_long_help = "\
EXAMPLES:
suture squash 3 # Squash last 3 commits
suture squash 3 -m \"combined\" # With custom message")]
Squash {
count: usize,
#[arg(short, long)]
message: Option<String>,
},
Tui,
}
#[derive(Subcommand, Debug)]
pub(crate) enum IgnoreAction {
List,
Check {
path: String,
},
}
#[derive(Subcommand, Debug)]
pub(crate) enum KeyAction {
#[command(
after_long_help = "EXAMPLES:\n suture key generate # Generate default key\n suture key generate deploy # Generate named key"
)]
Generate {
#[arg(default_value = "default")]
name: String,
},
#[command(after_long_help = "EXAMPLES:\n suture key list")]
List,
#[command(
after_long_help = "EXAMPLES:\n suture key public # Show default public key\n suture key public deploy # Show named key"
)]
Public {
#[arg(default_value = "default")]
name: String,
},
}
#[derive(Subcommand, Debug)]
pub(crate) enum StashAction {
#[command(
after_long_help = "EXAMPLES:\n suture stash push # Stash current changes\n suture stash push -m \"WIP\" # Stash with message"
)]
Push {
#[arg(short, long)]
message: Option<String>,
},
#[command(after_long_help = "EXAMPLES:\n suture stash pop")]
Pop,
#[command(
after_long_help = "EXAMPLES:\n suture stash apply 0 # Apply the latest stash\n suture stash apply 2 # Apply a specific stash"
)]
Apply { index: usize },
#[command(after_long_help = "EXAMPLES:\n suture stash list")]
List,
#[command(
after_long_help = "EXAMPLES:\n suture stash drop 0 # Drop the latest stash\n suture stash drop 2 # Drop a specific stash"
)]
Drop { index: usize },
}
#[derive(Subcommand, Debug)]
pub(crate) enum RemoteAction {
#[command(after_long_help = "EXAMPLES:\n suture remote add origin http://localhost:50051")]
Add {
name: String,
url: String,
},
#[command(after_long_help = "EXAMPLES:\n suture remote list")]
List,
#[command(after_long_help = "EXAMPLES:\n suture remote remove upstream")]
Remove {
name: String,
},
#[command(
after_long_help = "EXAMPLES:\n suture remote login # Login to origin\n suture remote login upstream # Login to specific remote"
)]
Login {
#[arg(default_value = "origin")]
name: String,
},
#[command(
after_long_help = "EXAMPLES:\n suture remote mirror http://upstream/repo upstream-name"
)]
Mirror {
url: String,
repo: String,
#[arg(long, default_value = None)]
name: Option<String>,
},
}
#[derive(Subcommand, Debug)]
pub(crate) enum NotesAction {
#[command(
after_long_help = "EXAMPLES:\n suture notes add abc123 -m \"reviewed\"\n suture notes add abc123 # Enter note interactively"
)]
Add {
commit: String,
#[arg(short, long)]
message: Option<String>,
},
List {
commit: String,
},
Show {
commit: String,
},
Remove {
commit: String,
index: usize,
},
}
#[derive(Subcommand, Debug)]
pub(crate) enum WorktreeAction {
Add {
path: String,
branch: Option<String>,
#[arg(short, long)]
b: Option<String>,
},
List,
Remove {
name: String,
},
}
#[derive(Subcommand, Debug)]
pub(crate) enum BisectAction {
#[command(
after_long_help = "EXAMPLES:\n suture bisect start abc123 def456\n # abc123 = known good, def456 = known bad"
)]
Start {
good: String,
bad: String,
},
#[command(
after_long_help = "EXAMPLES:\n suture bisect run abc123 def456 -- cargo test\n # exit 0 = good, non-zero = bad"
)]
Run {
good: String,
bad: String,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
cmd: Vec<String>,
},
Reset,
}
#[tokio::main]
async fn main() {
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
let cli = Cli::parse();
if let Some(path) = &cli.repo_path
&& let Err(e) = std::env::set_current_dir(path)
{
eprintln!("error: cannot change to '{}': {e}", path);
std::process::exit(1);
}
let result = match cli.command {
Commands::Init { path } => cmd::init::cmd_init(&path).await,
Commands::Status => cmd::status::cmd_status().await,
Commands::Ignore { action } => {
let args = match action {
IgnoreAction::List => cmd::ignore::IgnoreArgs::List,
IgnoreAction::Check { path } => cmd::ignore::IgnoreArgs::Check { path },
};
cmd::ignore::cmd_ignore(&args).await
}
Commands::Add { paths, all, patch } => cmd::add::cmd_add(&paths, all, patch).await,
Commands::Rm { paths, cached } => cmd::rm::cmd_rm(&paths, cached).await,
Commands::Commit { message, all } => cmd::commit::cmd_commit(&message, all).await,
Commands::Branch {
name,
target,
delete,
list,
protect,
unprotect,
} => {
cmd::branch::cmd_branch(
name.as_deref(),
target.as_deref(),
delete,
list,
protect,
unprotect,
)
.await
}
Commands::Log {
branch,
graph,
first_parent,
oneline,
author,
grep,
all,
since,
until,
} => {
cmd::log::cmd_log(
branch.as_deref(),
graph,
first_parent,
oneline,
author.as_deref(),
grep.as_deref(),
all,
since.as_deref(),
until.as_deref(),
)
.await
}
Commands::Checkout { branch, new_branch } => {
cmd::checkout::cmd_checkout(branch.as_deref(), new_branch.as_deref()).await
}
Commands::Mv {
source,
destination,
} => cmd::mv::cmd_mv(&source, &destination).await,
Commands::Diff { from, to, cached } => {
cmd::diff::cmd_diff(from.as_deref(), to.as_deref(), cached).await
}
Commands::Revert { commit, message } => {
cmd::revert::cmd_revert(&commit, message.as_deref()).await
}
Commands::Merge { source, dry_run } => cmd::merge::cmd_merge(&source, dry_run).await,
Commands::MergeFile {
base,
ours,
theirs,
label_ours,
label_theirs,
driver,
output,
} => {
cmd::merge_file::cmd_merge_file(
&base,
&ours,
&theirs,
label_ours.as_deref(),
label_theirs.as_deref(),
driver.as_deref(),
output.as_deref(),
)
.await
}
Commands::CherryPick { commit } => cmd::cherry_pick::cmd_cherry_pick(&commit).await,
Commands::Rebase {
branch,
interactive,
resume,
abort,
} => cmd::rebase::cmd_rebase(&branch, interactive, resume, abort).await,
Commands::Blame { path } => cmd::blame::cmd_blame(&path).await,
Commands::Tag {
name,
target,
delete,
list,
annotate,
message,
} => {
cmd::tag::cmd_tag(
name.as_deref(),
target.as_deref(),
delete,
list,
annotate,
message.as_deref(),
)
.await
}
Commands::Config { key_value, global } => cmd::config::cmd_config(&key_value, global).await,
Commands::Remote { action } => cmd::remote::cmd_remote(&action).await,
Commands::Push {
remote,
force,
branch,
} => cmd::push::cmd_push(&remote, force, branch.as_deref()).await,
Commands::Pull { remote, rebase } => cmd::pull::cmd_pull(&remote, rebase).await,
Commands::Fetch { remote, depth } => cmd::fetch::cmd_fetch(&remote, depth).await,
Commands::Clone { url, dir, depth } => {
cmd::clone::cmd_clone(&url, dir.as_deref(), depth).await
}
Commands::Reset { target, mode } => cmd::reset::cmd_reset(&target, &mode).await,
Commands::Key { action } => cmd::key::cmd_key(&action).await,
Commands::Stash { action } => cmd::stash::cmd_stash(&action).await,
Commands::Completions { shell } => {
match shell.as_str() {
"bash" => clap_complete::generate(
clap_complete::Shell::Bash,
&mut Cli::command(),
"suture",
&mut std::io::stdout(),
),
"zsh" => clap_complete::generate(
clap_complete::Shell::Zsh,
&mut Cli::command(),
"suture",
&mut std::io::stdout(),
),
"fish" => clap_complete::generate(
clap_complete::Shell::Fish,
&mut Cli::command(),
"suture",
&mut std::io::stdout(),
),
"powershell" | "pwsh" => clap_complete::generate(
clap_complete::Shell::PowerShell,
&mut Cli::command(),
"suture",
&mut std::io::stdout(),
),
"nushell" => clap_complete::generate(
clap_complete_nushell::Nushell,
&mut Cli::command(),
"suture",
&mut std::io::stdout(),
),
_ => {
eprintln!(
"error: unsupported shell '{}' (supported: bash, zsh, fish, powershell, nushell)",
shell
);
std::process::exit(1);
}
}
Ok(())
}
Commands::Show { commit } => cmd::show::cmd_show(&commit).await,
Commands::Reflog => cmd::reflog::cmd_reflog().await,
Commands::Drivers => cmd::drivers::cmd_drivers().await,
Commands::Shortlog { branch, number } => {
cmd::shortlog::cmd_shortlog(branch.as_deref(), number).await
}
Commands::Notes { action } => cmd::notes::cmd_notes(&action).await,
Commands::Worktree { action } => cmd::worktree::cmd_worktree(&action).await,
Commands::Gc => cmd::gc::cmd_gc().await,
Commands::Fsck => cmd::fsck::cmd_fsck().await,
Commands::Bisect { action } => cmd::bisect::cmd_bisect(&action).await,
Commands::Squash { count, message } => {
cmd::squash::cmd_squash(count, message.as_deref()).await
}
Commands::Undo { steps } => cmd::undo::cmd_undo(steps).await,
Commands::Version => cmd::version::cmd_version().await,
Commands::Tui => cmd::tui::cmd_tui().await,
};
if let Err(e) = result {
user_friendly_error(e.as_ref());
std::process::exit(1);
}
}
fn user_friendly_error(err: &dyn std::error::Error) {
let msg = err.to_string();
let clean = clean_error_message(&msg);
eprintln!("error: {clean}");
if let Some(hint) = error_hint(&clean) {
eprintln!("hint: {hint}");
}
let mut source = err.source();
while let Some(s) = source {
let src_clean = clean_error_message(&s.to_string());
if src_clean != clean {
eprintln!(" caused by: {src_clean}");
}
source = s.source();
}
}
fn clean_error_message(msg: &str) -> String {
let mut s = msg.to_string();
s = strip_rust_type_paths(&s);
s = strip_rust_backtrace(&s);
s.trim().to_string()
}
fn strip_rust_type_paths(s: &str) -> String {
let re = regex::Regex::new(r"[a-z_][a-z0-9_]*(?:::[a-z_][a-z0-9_]*)+::[A-Z][a-zA-Z0-9]*").unwrap();
re.replace_all(s, "…").to_string()
}
fn strip_rust_backtrace(s: &str) -> String {
let re = regex::Regex::new(r"\s*at [^\n]+\.(rs|rlib):?\d*").unwrap();
re.replace_all(s, "").to_string()
}
fn error_hint(msg: &str) -> Option<&'static str> {
let lower = msg.to_lowercase();
if lower.contains("no remote") || lower.contains("remote not found") || lower.contains("no remotes configured") {
Some("run `suture remote add <name> <url>` to configure a remote")
} else if lower.contains("not a suture repository") || lower.contains("not a repository") || lower.contains(".suture") {
Some("run `suture init` to create a new repository")
} else if lower.contains("network") || lower.contains("connection refused") || lower.contains("connect error") || lower.contains("could not resolve") || lower.contains("timeout") {
Some("check that the remote URL is correct and the server is reachable")
} else if lower.contains("permission") || lower.contains("denied") || lower.contains("unauthorized") {
Some("run `suture remote login` to authenticate with the remote")
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Cli {
Cli::try_parse_from(args).unwrap_or_else(|e| panic!("failed to parse {:?}: {e}", args))
}
#[test]
fn test_init_default() {
let cli = parse(&["suture", "init"]);
match cli.command {
Commands::Init { path } => assert_eq!(path, "."),
other => panic!("expected Init, got {other:?}"),
}
}
#[test]
fn test_init_with_path() {
let cli = parse(&["suture", "init", "/tmp/myrepo"]);
match cli.command {
Commands::Init { path } => assert_eq!(path, "/tmp/myrepo"),
other => panic!("expected Init, got {other:?}"),
}
}
#[test]
fn test_commit_with_message() {
let cli = parse(&["suture", "commit", "my message"]);
match cli.command {
Commands::Commit { message, all } => {
assert_eq!(message, "my message");
assert!(!all);
}
other => panic!("expected Commit, got {other:?}"),
}
}
#[test]
fn test_commit_with_all_flag() {
let cli = parse(&["suture", "commit", "--all", "msg"]);
match cli.command {
Commands::Commit { message, all } => {
assert_eq!(message, "msg");
assert!(all);
}
other => panic!("expected Commit, got {other:?}"),
}
}
#[test]
fn test_diff_cached_flag() {
let cli = parse(&["suture", "diff", "--cached"]);
match cli.command {
Commands::Diff { cached, from, to } => {
assert!(cached);
assert!(from.is_none());
assert!(to.is_none());
}
other => panic!("expected Diff, got {other:?}"),
}
}
#[test]
fn test_diff_from_to() {
let cli = parse(&["suture", "diff", "--from", "HEAD~1", "--to", "HEAD"]);
match cli.command {
Commands::Diff { cached, from, to } => {
assert!(!cached);
assert_eq!(from.as_deref(), Some("HEAD~1"));
assert_eq!(to.as_deref(), Some("HEAD"));
}
other => panic!("expected Diff, got {other:?}"),
}
}
#[test]
fn test_log_graph() {
let cli = parse(&["suture", "log", "--graph"]);
match cli.command {
Commands::Log { graph, .. } => assert!(graph),
other => panic!("expected Log, got {other:?}"),
}
}
#[test]
fn test_log_oneline() {
let cli = parse(&["suture", "log", "--oneline"]);
match cli.command {
Commands::Log { oneline, .. } => assert!(oneline),
other => panic!("expected Log, got {other:?}"),
}
}
#[test]
fn test_branch_create() {
let cli = parse(&["suture", "branch", "feature"]);
match cli.command {
Commands::Branch { name, .. } => assert_eq!(name.as_deref(), Some("feature")),
other => panic!("expected Branch, got {other:?}"),
}
}
#[test]
fn test_branch_delete() {
let cli = parse(&["suture", "branch", "--delete", "feature"]);
match cli.command {
Commands::Branch { delete, name, .. } => {
assert!(delete);
assert_eq!(name.as_deref(), Some("feature"));
}
other => panic!("expected Branch, got {other:?}"),
}
}
#[test]
fn test_tag_lightweight() {
let cli = parse(&["suture", "tag", "v1.0"]);
match cli.command {
Commands::Tag { name, annotate, .. } => {
assert_eq!(name.as_deref(), Some("v1.0"));
assert!(!annotate);
}
other => panic!("expected Tag, got {other:?}"),
}
}
#[test]
fn test_tag_annotated() {
let cli = parse(&["suture", "tag", "-a", "-m", "release", "v1.0"]);
match cli.command {
Commands::Tag { name, annotate, message, .. } => {
assert_eq!(name.as_deref(), Some("v1.0"));
assert!(annotate);
assert_eq!(message.as_deref(), Some("release"));
}
other => panic!("expected Tag, got {other:?}"),
}
}
#[test]
fn test_tag_delete() {
let cli = parse(&["suture", "tag", "--delete", "v1.0"]);
match cli.command {
Commands::Tag { delete, name, .. } => {
assert!(delete);
assert_eq!(name.as_deref(), Some("v1.0"));
}
other => panic!("expected Tag, got {other:?}"),
}
}
#[test]
fn test_merge_dry_run() {
let cli = parse(&["suture", "merge", "--dry-run", "feature"]);
match cli.command {
Commands::Merge { source, dry_run } => {
assert_eq!(source, "feature");
assert!(dry_run);
}
other => panic!("expected Merge, got {other:?}"),
}
}
#[test]
fn test_merge_file_with_driver() {
let cli = parse(&["suture", "merge-file", "--driver", "json", "base", "ours", "theirs", "-o", "out.json"]);
match cli.command {
Commands::MergeFile {
driver, output, ..
} => {
assert_eq!(driver.as_deref(), Some("json"));
assert_eq!(output.as_deref(), Some("out.json"));
}
other => panic!("expected MergeFile, got {other:?}"),
}
}
#[test]
fn test_merge_file_auto_detect() {
let cli = parse(&["suture", "merge-file", "base.json", "ours.json", "theirs.json"]);
match cli.command {
Commands::MergeFile {
base, ours, theirs, driver, output, ..
} => {
assert_eq!(base, "base.json");
assert_eq!(ours, "ours.json");
assert_eq!(theirs, "theirs.json");
assert!(driver.is_none());
assert!(output.is_none());
}
other => panic!("expected MergeFile, got {other:?}"),
}
}
#[test]
fn test_stash_push() {
let cli = parse(&["suture", "stash", "push", "-m", "work in progress"]);
match cli.command {
Commands::Stash { action } => match action {
StashAction::Push { message } => {
assert_eq!(message.as_deref(), Some("work in progress"));
}
other => panic!("expected StashAction::Push, got {other:?}"),
},
other => panic!("expected Stash, got {other:?}"),
}
}
#[test]
fn test_stash_pop() {
let cli = parse(&["suture", "stash", "pop"]);
match cli.command {
Commands::Stash { action } => match action {
StashAction::Pop => {}
other => panic!("expected StashAction::Pop, got {other:?}"),
},
other => panic!("expected Stash, got {other:?}"),
}
}
#[test]
fn test_remote_add() {
let cli = parse(&["suture", "remote", "add", "origin", "https://example.com"]);
match cli.command {
Commands::Remote { action } => match action {
RemoteAction::Add { name, url } => {
assert_eq!(name, "origin");
assert_eq!(url, "https://example.com");
}
other => panic!("expected RemoteAction::Add, got {other:?}"),
},
other => panic!("expected Remote, got {other:?}"),
}
}
#[test]
fn test_push_force() {
let cli = parse(&["suture", "push", "--force", "origin"]);
match cli.command {
Commands::Push { remote, force, branch } => {
assert_eq!(remote, "origin");
assert!(force);
assert!(branch.is_none());
}
other => panic!("expected Push, got {other:?}"),
}
}
#[test]
fn test_rebase_interactive() {
let cli = parse(&["suture", "rebase", "-i", "main"]);
match cli.command {
Commands::Rebase { branch, interactive, .. } => {
assert_eq!(branch, "main");
assert!(interactive);
}
other => panic!("expected Rebase, got {other:?}"),
}
}
#[test]
fn test_config_set() {
let cli = parse(&["suture", "config", "user.name=Alice"]);
match cli.command {
Commands::Config { key_value, global } => {
assert_eq!(key_value, vec!["user.name=Alice"]);
assert!(!global);
}
other => panic!("expected Config, got {other:?}"),
}
}
#[test]
fn test_notes_add() {
let cli = parse(&["suture", "notes", "add", "HEAD", "-m", "review note"]);
match cli.command {
Commands::Notes { action } => match action {
NotesAction::Add { commit, message } => {
assert_eq!(commit, "HEAD");
assert_eq!(message.as_deref(), Some("review note"));
}
other => panic!("expected NotesAction::Add, got {other:?}"),
},
other => panic!("expected Notes, got {other:?}"),
}
}
#[test]
fn test_worktree_add() {
let cli = parse(&["suture", "worktree", "add", "../wt", "-b", "feature"]);
match cli.command {
Commands::Worktree { action } => match action {
WorktreeAction::Add { path, b, .. } => {
assert_eq!(path, "../wt");
assert_eq!(b.as_deref(), Some("feature"));
}
other => panic!("expected WorktreeAction::Add, got {other:?}"),
},
other => panic!("expected Worktree, got {other:?}"),
}
}
#[test]
fn test_global_repo_path() {
let cli = parse(&["suture", "-C", "/some/path", "status"]);
assert_eq!(cli.repo_path.as_deref(), Some("/some/path"));
}
}