use std::borrow::Cow;
use std::path::PathBuf;
use color_print::{cformat, cwrite};
use shell_escape::escape;
use super::HookType;
use crate::path::format_path_for_display;
use crate::styling::{
ERROR_SYMBOL, HINT_SYMBOL, error_message, format_bash_with_gutter, format_with_gutter,
hint_message, info_message, suggest_command,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefType {
Pr,
Mr,
}
impl RefType {
pub fn symbol(self) -> &'static str {
match self {
Self::Pr => "#",
Self::Mr => "!",
}
}
pub fn name(self) -> &'static str {
match self {
Self::Pr => "PR",
Self::Mr => "MR",
}
}
pub fn name_plural(self) -> &'static str {
match self {
Self::Pr => "PRs",
Self::Mr => "MRs",
}
}
pub fn syntax(self) -> &'static str {
match self {
Self::Pr => "pr:",
Self::Mr => "mr:",
}
}
pub fn display(self, number: u32) -> String {
format!("{} {}{}", self.name(), self.symbol(), number)
}
}
pub trait RefContext {
fn ref_type(&self) -> RefType;
fn number(&self) -> u32;
fn title(&self) -> &str;
fn author(&self) -> &str;
fn state(&self) -> &str;
fn draft(&self) -> bool;
fn url(&self) -> &str;
fn source_ref(&self) -> String;
}
#[derive(Debug, Clone)]
pub struct FailedCommand {
pub command: String,
pub exit_info: String,
}
#[derive(Debug, Clone)]
pub struct SwitchSuggestionCtx {
pub extra_flags: Vec<String>,
pub trailing_args: Vec<String>,
}
impl SwitchSuggestionCtx {
fn apply(&self, cmd: String) -> String {
let mut result = cmd;
for flag in &self.extra_flags {
result.push(' ');
result.push_str(flag);
}
if !self.trailing_args.is_empty() {
result.push_str(" --");
for arg in &self.trailing_args {
result.push(' ');
result.push_str(&escape(Cow::Borrowed(arg.as_str())));
}
}
result
}
}
#[derive(Debug, Clone)]
pub enum GitError {
DetachedHead {
action: Option<String>,
},
UncommittedChanges {
action: Option<String>,
branch: Option<String>,
force_hint: bool,
},
BranchAlreadyExists {
branch: String,
},
BranchNotFound {
branch: String,
show_create_hint: bool,
last_fetch_ago: Option<String>,
},
ReferenceNotFound {
reference: String,
},
NotInWorktree {
action: Option<String>,
},
WorktreeMissing {
branch: String,
},
RemoteOnlyBranch {
branch: String,
remote: String,
},
WorktreePathOccupied {
branch: String,
path: PathBuf,
occupant: Option<String>,
},
WorktreePathExists {
branch: String,
path: PathBuf,
create: bool,
},
WorktreeCreationFailed {
branch: String,
base_branch: Option<String>,
error: String,
command: Option<FailedCommand>,
},
WorktreeRemovalFailed {
branch: String,
path: PathBuf,
error: String,
remaining_entries: Option<Vec<String>>,
},
CannotRemoveMainWorktree,
CannotRemoveDefaultBranch {
branch: String,
},
WorktreeLocked {
branch: String,
path: PathBuf,
reason: Option<String>,
},
ConflictingChanges {
target_branch: String,
files: Vec<String>,
worktree_path: PathBuf,
},
NotFastForward {
target_branch: String,
commits_formatted: String,
in_merge_context: bool,
},
RebaseConflict {
target_branch: String,
git_output: String,
},
NotRebased {
target_branch: String,
},
PushFailed {
target_branch: String,
error: String,
},
NotInteractive,
HookCommandNotFound {
name: String,
available: Vec<String>,
},
ParseError {
message: String,
},
WorktreeIncludeParseError {
error: String,
},
LlmCommandFailed {
command: String,
error: String,
reproduction_command: Option<String>,
},
ProjectConfigNotFound {
config_path: PathBuf,
},
WorktreeNotFound {
branch: String,
},
RefCreateConflict {
ref_type: RefType,
number: u32,
branch: String,
},
RefBaseConflict {
ref_type: RefType,
number: u32,
},
BranchTracksDifferentRef {
branch: String,
ref_type: RefType,
number: u32,
},
NoRemoteForRepo {
owner: String,
repo: String,
suggested_url: String,
},
CliApiError {
ref_type: RefType,
message: String,
stderr: String,
},
Other {
message: String,
},
WithSwitchSuggestion {
source: Box<GitError>,
ctx: SwitchSuggestionCtx,
},
}
impl std::error::Error for GitError {}
impl GitError {
fn fmt_with_ctx(
&self,
f: &mut std::fmt::Formatter<'_>,
ctx: Option<&SwitchSuggestionCtx>,
) -> std::fmt::Result {
match self {
GitError::WithSwitchSuggestion { source, ctx } => source.fmt_with_ctx(f, Some(ctx)),
GitError::DetachedHead { action } => {
let message = match action {
Some(action) => cformat!("Cannot {action}: not on a branch (detached HEAD)"),
None => "Not on a branch (detached HEAD)".to_string(),
};
write!(
f,
"{}\n{}",
error_message(&message),
hint_message(cformat!(
"To switch to a branch, run <underline>git switch <<branch>></>"
))
)
}
GitError::UncommittedChanges {
action,
branch,
force_hint,
} => {
let message = match (action, branch) {
(Some(action), Some(b)) => {
cformat!("Cannot {action}: <bold>{b}</> has uncommitted changes")
}
(Some(action), None) => {
cformat!("Cannot {action}: working tree has uncommitted changes")
}
(None, Some(b)) => {
cformat!("<bold>{b}</> has uncommitted changes")
}
(None, None) => cformat!("Working tree has uncommitted changes"),
};
let hint = if *force_hint {
let args: Vec<&str> = branch.as_deref().into_iter().collect();
let cmd = suggest_command("remove", &args, &["--force"]);
cformat!(
"Commit or stash changes first, or to lose uncommitted changes, run <underline>{cmd}</>"
)
} else {
"Commit or stash changes first".to_string()
};
write!(f, "{}\n{}", error_message(&message), hint_message(hint))
}
GitError::BranchAlreadyExists { branch } => {
let mut switch_cmd = suggest_command("switch", &[branch], &[]);
if let Some(ctx) = ctx {
switch_cmd = ctx.apply(switch_cmd);
}
write!(
f,
"{}\n{}",
error_message(cformat!("Branch <bold>{branch}</> already exists")),
hint_message(cformat!(
"To switch to the existing branch, run without <underline>--create</>: <underline>{switch_cmd}</>"
))
)
}
GitError::BranchNotFound {
branch,
show_create_hint,
last_fetch_ago,
} => {
let list_cmd = suggest_command("list", &[], &["--branches", "--remotes"]);
let hint = if *show_create_hint {
let mut create_cmd = suggest_command("switch", &[branch], &["--create"]);
if let Some(ctx) = ctx {
create_cmd = ctx.apply(create_cmd);
}
let fetch_note = last_fetch_ago.as_ref().map(|ago| cformat!(" ({ago})"));
cformat!(
"To create a new branch, run <underline>{create_cmd}</>; to list branches, run <underline>{list_cmd}</>{note}",
note = fetch_note.as_deref().unwrap_or("")
)
} else {
cformat!("To list branches, run <underline>{list_cmd}</>")
};
write!(
f,
"{}\n{}",
error_message(cformat!("No branch named <bold>{branch}</>")),
hint_message(hint)
)
}
GitError::ReferenceNotFound { reference } => {
write!(
f,
"{}",
error_message(cformat!(
"No branch, tag, or commit named <bold>{reference}</>"
))
)
}
GitError::NotInWorktree { action } => {
let message = match action {
Some(action) => cformat!("Cannot {action}: not in a worktree"),
None => "Not in a worktree".to_string(),
};
write!(
f,
"{}\n{}",
error_message(&message),
hint_message(cformat!(
"Run from inside a worktree, or specify a branch name"
))
)
}
GitError::WorktreeMissing { branch } => {
write!(
f,
"{}\n{}",
error_message(cformat!("Worktree directory missing for <bold>{branch}</>")),
hint_message(cformat!(
"To clean up, run <underline>git worktree prune</>"
))
)
}
GitError::RemoteOnlyBranch { branch, remote } => {
let cmd = suggest_command("switch", &[branch], &[]);
cwrite!(
f,
"{ERROR_SYMBOL} <red>Branch <bold>{branch}</> exists only on remote ({remote}/{branch})</>\n{HINT_SYMBOL} <dim>To create a local worktree, run <underline>{cmd}</></>"
)
}
GitError::WorktreePathOccupied {
branch,
path,
occupant,
} => {
let path_display = format_path_for_display(path);
let reason = if let Some(occupant_branch) = occupant {
cformat!(
"there's a worktree at the expected path <bold>{path_display}</> on branch <bold>{occupant_branch}</>"
)
} else {
cformat!(
"there's a detached worktree at the expected path <bold>{path_display}</>"
)
};
let escaped_path = escape(path.to_string_lossy());
let escaped_branch = escape(Cow::Borrowed(branch.as_str()));
let command = format!("cd {escaped_path} && git switch {escaped_branch}");
write!(
f,
"{}\n{}",
error_message(cformat!("Cannot switch to <bold>{branch}</> — {reason}")),
hint_message(cformat!(
"To switch the worktree at <underline>{path_display}</> to <underline>{branch}</>, run <underline>{command}</>"
))
)
}
GitError::WorktreePathExists {
branch,
path,
create,
} => {
let path_display = format_path_for_display(path);
let flags: &[&str] = if *create {
&["--create", "--clobber"]
} else {
&["--clobber"]
};
let mut switch_cmd = suggest_command("switch", &[branch], flags);
if let Some(ctx) = ctx {
switch_cmd = ctx.apply(switch_cmd);
}
write!(
f,
"{}\n{}",
error_message(cformat!(
"Directory already exists: <bold>{path_display}</>"
)),
hint_message(cformat!(
"To remove manually, run <underline>rm -rf {path_display}</>; to overwrite (with backup), run <underline>{switch_cmd}</>"
))
)
}
GitError::WorktreeCreationFailed {
branch,
base_branch,
error,
command,
} => {
let header = if let Some(base) = base_branch {
error_message(cformat!(
"Failed to create worktree for <bold>{branch}</> from base <bold>{base}</>"
))
} else {
error_message(cformat!("Failed to create worktree for <bold>{branch}</>"))
};
write!(f, "{}", format_error_block(header, error))?;
if let Some(cmd) = command {
write!(
f,
"\n{}\n{}",
hint_message(cformat!("Failed command, <underline>{}</>:", cmd.exit_info)),
format_bash_with_gutter(&cmd.command)
)?;
}
Ok(())
}
GitError::WorktreeRemovalFailed {
branch,
path,
error,
remaining_entries,
} => {
let path_display = format_path_for_display(path);
let header = error_message(cformat!(
"Failed to remove worktree for <bold>{branch}</> @ <bold>{path_display}</>"
));
write!(f, "{}", format_error_block(header, error))?;
if let Some(entries) = remaining_entries {
const MAX_SHOWN: usize = 10;
let listing = if entries.len() > MAX_SHOWN {
let shown = entries[..MAX_SHOWN].join(", ");
let remaining = entries.len() - MAX_SHOWN;
format!("{shown}, and {remaining} more")
} else {
entries.join(", ")
};
write!(
f,
"\n{}",
hint_message(cformat!("Remaining in directory: <underline>{listing}</>"))
)?;
}
if error.contains("not empty") {
write!(
f,
"\n{}",
hint_message(cformat!(
"A background process may be writing files; try <underline>wt remove</> (without --foreground)"
))
)?;
}
Ok(())
}
GitError::CannotRemoveMainWorktree => {
write!(
f,
"{}",
error_message("The main worktree cannot be removed")
)
}
GitError::CannotRemoveDefaultBranch { branch } => {
let cmd = suggest_command("remove", &[branch], &["-D"]);
write!(
f,
"{}",
error_message(cformat!(
"Cannot remove the default branch <bold>{branch}</>"
))
)?;
write!(
f,
"\n{}",
hint_message(cformat!("To force-delete, run <underline>{cmd}</>"))
)
}
GitError::WorktreeLocked {
branch,
path,
reason,
} => {
let reason_text = match reason {
Some(r) if !r.is_empty() => format!(" ({r})"),
_ => String::new(),
};
let path_display = format_path_for_display(path);
write!(
f,
"{}\n{}",
error_message(cformat!(
"Cannot remove <bold>{branch}</>, worktree is locked{reason_text}"
)),
hint_message(cformat!(
"To unlock, run <underline>git worktree unlock {path_display}</>"
))
)
}
GitError::ConflictingChanges {
target_branch,
files,
worktree_path,
} => {
write!(
f,
"{}",
error_message(cformat!(
"Can't push to local <bold>{target_branch}</> branch: conflicting uncommitted changes"
))
)?;
if !files.is_empty() {
let joined_files = files.join("\n");
write!(f, "\n{}", format_with_gutter(&joined_files, None))?;
}
let path_display = format_path_for_display(worktree_path);
write!(
f,
"\n{}",
hint_message(format!(
"Commit or stash these changes in {path_display} first"
))
)
}
GitError::NotFastForward {
target_branch,
commits_formatted,
in_merge_context,
} => {
write!(
f,
"{}",
error_message(cformat!(
"Can't push to local <bold>{target_branch}</> branch: it has newer commits"
))
)?;
if !commits_formatted.is_empty() {
write!(f, "\n{}", format_with_gutter(commits_formatted, None))?;
}
let merge_cmd = suggest_command("merge", &[target_branch], &[]);
if *in_merge_context {
write!(
f,
"\n{}",
hint_message(cformat!(
"To incorporate these changes, run <underline>{merge_cmd}</> again"
))
)
} else {
let rebase_cmd = suggest_command("step", &["rebase", target_branch], &[]);
write!(
f,
"\n{}",
hint_message(cformat!(
"To rebase onto <underline>{target_branch}</>, run <underline>{rebase_cmd}</>"
))
)
}
}
GitError::RebaseConflict {
target_branch,
git_output,
} => {
write!(
f,
"{}",
error_message(cformat!("Rebase onto <bold>{target_branch}</> incomplete"))
)?;
if !git_output.is_empty() {
write!(f, "\n{}", format_with_gutter(git_output, None))
} else {
write!(
f,
"\n{}\n{}",
hint_message(cformat!(
"To continue after resolving conflicts, run <underline>git rebase --continue</>"
)),
hint_message(cformat!("To abort, run <underline>git rebase --abort</>"))
)
}
}
GitError::NotRebased { target_branch } => {
let rebase_cmd = suggest_command("step", &["rebase", target_branch], &[]);
write!(
f,
"{}\n{}",
error_message(cformat!("Branch not rebased onto <bold>{target_branch}</>")),
hint_message(cformat!(
"To rebase first, run <underline>{rebase_cmd}</>; or remove <underline>--no-rebase</>"
))
)
}
GitError::PushFailed {
target_branch,
error,
} => {
let header = error_message(cformat!(
"Can't push to local <bold>{target_branch}</> branch"
));
write!(f, "{}", format_error_block(header, error))
}
GitError::NotInteractive => {
let approvals_cmd = suggest_command("hook", &["approvals", "add"], &[]);
write!(
f,
"{}\n{}",
error_message("Cannot prompt for approval in non-interactive environment"),
hint_message(cformat!(
"To skip prompts in CI/CD, add <underline>--yes</>; to pre-approve commands, run <underline>{approvals_cmd}</>"
))
)
}
GitError::HookCommandNotFound { name, available } => {
if available.is_empty() {
write!(
f,
"{}",
error_message(cformat!(
"No command named <bold>{name}</> (hook has no named commands)"
))
)
} else {
let available_str = available
.iter()
.map(|s| cformat!("<bold>{s}</>"))
.collect::<Vec<_>>()
.join(", ");
write!(
f,
"{}",
error_message(cformat!(
"No command named <bold>{name}</> (available: {available_str})"
))
)
}
}
GitError::LlmCommandFailed {
command,
error,
reproduction_command,
} => {
let error_header = error_message("Commit generation command failed");
let error_block = format_error_block(error_header, error);
let display_command = reproduction_command.as_ref().unwrap_or(command);
let command_gutter = format_with_gutter(display_command, None);
write!(
f,
"{}\n{}\n{}",
error_block,
info_message("Ran command:"),
command_gutter
)
}
GitError::ProjectConfigNotFound { config_path } => {
let path_display = format_path_for_display(config_path);
write!(
f,
"{}\n{}",
error_message("No project configuration found"),
hint_message(cformat!(
"Create a config file at: <underline>{path_display}</>"
))
)
}
GitError::ParseError { message } => {
write!(f, "{}", error_message(message))
}
GitError::WorktreeIncludeParseError { error } => {
let header = error_message(cformat!("Error parsing <bold>.worktreeinclude</>"));
write!(f, "{}", format_error_block(header, error))
}
GitError::WorktreeNotFound { branch } => {
let switch_cmd = suggest_command("switch", &[branch], &[]);
write!(
f,
"{}\n{}",
error_message(cformat!("Branch <bold>{branch}</> has no worktree")),
hint_message(cformat!(
"To create a worktree, run <underline>{switch_cmd}</>"
))
)
}
GitError::RefCreateConflict {
ref_type,
number,
branch,
} => {
let name = ref_type.name();
let syntax = ref_type.syntax();
write!(
f,
"{}\n{}",
error_message(cformat!(
"Cannot create branch for <bold>{syntax}{number}</> — {name} already has branch <bold>{branch}</>"
)),
hint_message(cformat!(
"To switch to it: <underline>wt switch {syntax}{number}</>"
))
)
}
GitError::RefBaseConflict { ref_type, number } => {
let syntax = ref_type.syntax();
let name_plural = ref_type.name_plural();
write!(
f,
"{}\n{}",
error_message(cformat!(
"Cannot use <bold>--base</> with <bold>{syntax}{number}</>"
)),
hint_message(cformat!(
"{name_plural} already have a base; remove <underline>--base</>"
))
)
}
GitError::BranchTracksDifferentRef {
branch,
ref_type,
number,
} => {
let escaped = escape(Cow::Borrowed(branch.as_str()));
let old_name = format!("{branch}-old");
let escaped_old = escape(Cow::Borrowed(&old_name));
let name = ref_type.name();
let symbol = ref_type.symbol();
write!(
f,
"{}\n{}",
error_message(cformat!(
"Branch <bold>{branch}</> exists but doesn't track {name} {symbol}{number}"
)),
hint_message(cformat!(
"To free the name, run <underline>git branch -m -- {escaped} {escaped_old}</>"
))
)
}
GitError::NoRemoteForRepo {
owner,
repo,
suggested_url,
} => {
write!(
f,
"{}\n{}",
error_message(cformat!("No remote found for <bold>{owner}/{repo}</>")),
hint_message(cformat!(
"Add the remote: <underline>git remote add upstream {suggested_url}</>"
))
)
}
GitError::CliApiError {
message, stderr, ..
} => {
write!(f, "{}", format_error_block(error_message(message), stderr))
}
GitError::Other { message } => {
write!(f, "{}", error_message(message))
}
}
}
}
impl std::fmt::Display for GitError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.fmt_with_ctx(f, None)
}
}
#[derive(Debug)]
pub enum WorktrunkError {
ChildProcessExited { code: i32, message: String },
HookCommandFailed {
hook_type: HookType,
command_name: Option<String>,
error: String,
exit_code: Option<i32>,
},
CommandNotApproved,
AlreadyDisplayed { exit_code: i32 },
}
impl std::fmt::Display for WorktrunkError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WorktrunkError::ChildProcessExited { message, .. } => {
write!(f, "{}", error_message(message))
}
WorktrunkError::HookCommandFailed {
hook_type,
command_name,
error,
..
} => {
if let Some(name) = command_name {
write!(
f,
"{}",
error_message(cformat!(
"{hook_type} command failed: <bold>{name}</>: {error}"
))
)
} else {
write!(
f,
"{}",
error_message(format!("{hook_type} command failed: {error}"))
)
}
}
WorktrunkError::CommandNotApproved => {
Ok(()) }
WorktrunkError::AlreadyDisplayed { .. } => {
Ok(()) }
}
}
}
impl std::error::Error for WorktrunkError {}
pub fn exit_code(err: &anyhow::Error) -> Option<i32> {
if let Some(wrapper) = err.downcast_ref::<HookErrorWithHint>() {
return exit_code(&wrapper.inner);
}
err.downcast_ref::<WorktrunkError>().and_then(|e| match e {
WorktrunkError::ChildProcessExited { code, .. } => Some(*code),
WorktrunkError::HookCommandFailed { exit_code, .. } => *exit_code,
WorktrunkError::CommandNotApproved => None,
WorktrunkError::AlreadyDisplayed { exit_code } => Some(*exit_code),
})
}
pub fn add_hook_skip_hint(err: anyhow::Error) -> anyhow::Error {
let hook_type = err
.downcast_ref::<WorktrunkError>()
.and_then(|wt_err| match wt_err {
WorktrunkError::HookCommandFailed { hook_type, .. } => Some(*hook_type),
_ => None,
});
match hook_type {
Some(hook_type) => HookErrorWithHint {
inner: err,
hook_type,
}
.into(),
None => err,
}
}
#[derive(Debug)]
pub struct HookErrorWithHint {
inner: anyhow::Error,
hook_type: HookType,
}
impl std::fmt::Display for HookErrorWithHint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.inner)?;
write!(
f,
"\n{}",
hint_message(cformat!(
"To skip {} hooks, re-run with <underline>--no-hooks</>",
self.hook_type
))
)
}
}
impl std::error::Error for HookErrorWithHint {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.inner.source()
}
}
fn format_error_block(header: impl Into<String>, error: &str) -> String {
let header = header.into();
let trimmed = error.trim();
if trimmed.is_empty() {
header
} else {
format!("{header}\n{}", format_with_gutter(trimmed, None))
}
}
#[cfg(test)]
mod tests {
use super::*;
use insta::assert_snapshot;
#[test]
fn snapshot_into_preserves_type_for_display() {
let err: anyhow::Error = GitError::BranchAlreadyExists {
branch: "main".into(),
}
.into();
let downcast = err.downcast_ref::<GitError>().expect("Should downcast");
assert_snapshot!(downcast.to_string(), @"
[31m✗[39m [31mBranch [1mmain[22m already exists[39m
[2m↳[22m [2mTo switch to the existing branch, run without [4m--create[24m: [4mwt switch main[24m[22m
");
}
#[test]
fn test_pattern_matching_with_into() {
let err: anyhow::Error = GitError::BranchAlreadyExists {
branch: "main".into(),
}
.into();
if let Some(GitError::BranchAlreadyExists { branch }) = err.downcast_ref::<GitError>() {
assert_eq!(branch, "main");
} else {
panic!("Failed to downcast and pattern match");
}
}
#[test]
fn snapshot_worktree_error_with_path_and_create() {
let err = GitError::WorktreePathExists {
branch: "feature".to_string(),
path: PathBuf::from("/some/path"),
create: true,
};
assert_snapshot!(err.to_string(), @"
[31m✗[39m [31mDirectory already exists: [1m/some/path[22m[39m
[2m↳[22m [2mTo remove manually, run [4mrm -rf /some/path[24m; to overwrite (with backup), run [4mwt switch --create --clobber feature[24m[22m
");
}
#[test]
fn test_exit_code() {
let err: anyhow::Error = WorktrunkError::ChildProcessExited {
code: 42,
message: "test".into(),
}
.into();
assert_eq!(exit_code(&err), Some(42));
let err: anyhow::Error = WorktrunkError::HookCommandFailed {
hook_type: HookType::PreMerge,
command_name: Some("test".into()),
error: "failed".into(),
exit_code: Some(1),
}
.into();
assert_eq!(exit_code(&err), Some(1));
let err: anyhow::Error = WorktrunkError::HookCommandFailed {
hook_type: HookType::PreMerge,
command_name: None,
error: "failed".into(),
exit_code: None,
}
.into();
assert_eq!(exit_code(&err), None);
assert_eq!(exit_code(&WorktrunkError::CommandNotApproved.into()), None);
assert_eq!(
exit_code(&WorktrunkError::AlreadyDisplayed { exit_code: 5 }.into()),
Some(5)
);
assert_eq!(
exit_code(&GitError::DetachedHead { action: None }.into()),
None
);
let inner: anyhow::Error = WorktrunkError::HookCommandFailed {
hook_type: HookType::PreCommit,
command_name: Some("lint".into()),
error: "failed".into(),
exit_code: Some(7),
}
.into();
assert_eq!(exit_code(&add_hook_skip_hint(inner)), Some(7));
}
#[test]
fn snapshot_add_hook_skip_hint() {
let inner: anyhow::Error = WorktrunkError::HookCommandFailed {
hook_type: HookType::PreMerge,
command_name: Some("test".into()),
error: "failed".into(),
exit_code: Some(1),
}
.into();
assert_snapshot!(add_hook_skip_hint(inner).to_string(), @"
[31m✗[39m [31mpre-merge command failed: [1mtest[22m: failed[39m
[2m↳[22m [2mTo skip pre-merge hooks, re-run with [4m--no-hooks[24m[22m
");
let inner: anyhow::Error = WorktrunkError::HookCommandFailed {
hook_type: HookType::PreCommit,
command_name: Some("build".into()),
error: "Build failed".into(),
exit_code: Some(1),
}
.into();
assert_snapshot!(add_hook_skip_hint(inner).to_string(), @"
[31m✗[39m [31mpre-commit command failed: [1mbuild[22m: Build failed[39m
[2m↳[22m [2mTo skip pre-commit hooks, re-run with [4m--no-hooks[24m[22m
");
let err: anyhow::Error = WorktrunkError::ChildProcessExited {
code: 1,
message: "test".into(),
}
.into();
assert!(!add_hook_skip_hint(err).to_string().contains("--no-hooks"));
let err: anyhow::Error = GitError::DetachedHead { action: None }.into();
assert!(!add_hook_skip_hint(err).to_string().contains("--no-hooks"));
let err: anyhow::Error = GitError::Other {
message: "some error".into(),
}
.into();
assert!(!add_hook_skip_hint(err).to_string().contains("--no-hooks"));
}
#[test]
fn test_format_error_block() {
let header = "Error occurred".to_string();
assert_snapshot!(format_error_block(header.clone(), " some error text "), @"
Error occurred
[107m [0m some error text
");
assert_eq!(format_error_block(header.clone(), ""), header);
assert_eq!(format_error_block(header.clone(), " \n\t "), header);
}
#[test]
fn snapshot_worktrunk_error_display() {
let err = WorktrunkError::ChildProcessExited {
code: 1,
message: "Command failed".into(),
};
assert_snapshot!(err.to_string(), @"[31m✗[39m [31mCommand failed[39m");
let err = WorktrunkError::HookCommandFailed {
hook_type: HookType::PreMerge,
command_name: Some("lint".into()),
error: "lint failed".into(),
exit_code: Some(1),
};
assert_snapshot!(err.to_string(), @"[31m✗[39m [31mpre-merge command failed: [1mlint[22m: lint failed[39m");
let err = WorktrunkError::HookCommandFailed {
hook_type: HookType::PreStart,
command_name: None,
error: "setup failed".into(),
exit_code: None,
};
assert_snapshot!(err.to_string(), @"[31m✗[39m [31mpre-start command failed: setup failed[39m");
assert_eq!(format!("{}", WorktrunkError::CommandNotApproved), "");
assert_eq!(
format!("{}", WorktrunkError::AlreadyDisplayed { exit_code: 1 }),
""
);
}
#[test]
fn snapshot_not_in_worktree() {
let err = GitError::NotInWorktree {
action: Some("resolve @".into()),
};
assert_snapshot!(err.to_string(), @"
[31m✗[39m [31mCannot resolve @: not in a worktree[39m
[2m↳[22m [2mRun from inside a worktree, or specify a branch name[22m
");
let err = GitError::NotInWorktree { action: None };
assert_snapshot!(err.to_string(), @"
[31m✗[39m [31mNot in a worktree[39m
[2m↳[22m [2mRun from inside a worktree, or specify a branch name[22m
");
}
#[test]
fn snapshot_worktree_path_occupied() {
let err = GitError::WorktreePathOccupied {
branch: "feature".into(),
path: PathBuf::from("/tmp/repo"),
occupant: Some("main".into()),
};
assert_snapshot!(err.to_string(), @"
[31m✗[39m [31mCannot switch to [1mfeature[22m — there's a worktree at the expected path [1m/tmp/repo[22m on branch [1mmain[22m[39m
[2m↳[22m [2mTo switch the worktree at [4m/tmp/repo[24m to [4mfeature[24m, run [4mcd /tmp/repo && git switch feature[24m[22m
");
let err = GitError::WorktreePathOccupied {
branch: "feature".into(),
path: PathBuf::from("/tmp/repo"),
occupant: None,
};
assert_snapshot!(err.to_string(), @"
[31m✗[39m [31mCannot switch to [1mfeature[22m — there's a detached worktree at the expected path [1m/tmp/repo[22m[39m
[2m↳[22m [2mTo switch the worktree at [4m/tmp/repo[24m to [4mfeature[24m, run [4mcd /tmp/repo && git switch feature[24m[22m
");
}
#[test]
fn snapshot_worktree_path_occupied_special_chars() {
let err = GitError::WorktreePathOccupied {
branch: "feature/my branch".into(),
path: PathBuf::from("/tmp/my repo"),
occupant: Some("main".into()),
};
let output = err.to_string();
assert!(
output.contains("cd '/tmp/my repo' && git switch 'feature/my branch'"),
"expected shell-escaped command in hint, got: {output}"
);
}
#[test]
fn snapshot_worktree_creation_failed() {
let err = GitError::WorktreeCreationFailed {
branch: "feature".into(),
base_branch: Some("main".into()),
error: "git error".into(),
command: None,
};
assert_snapshot!(err.to_string(), @"
[31m✗[39m [31mFailed to create worktree for [1mfeature[22m from base [1mmain[22m[39m
[107m [0m git error
");
let err = GitError::WorktreeCreationFailed {
branch: "feature".into(),
base_branch: None,
error: "git error".into(),
command: None,
};
assert_snapshot!(err.to_string(), @"
[31m✗[39m [31mFailed to create worktree for [1mfeature[22m[39m
[107m [0m git error
");
let err = GitError::WorktreeCreationFailed {
branch: "feature".into(),
base_branch: Some("main".into()),
error: "fatal: ref exists".into(),
command: Some(FailedCommand {
command: "git worktree add /path -b feature main".into(),
exit_info: "exit code 128".into(),
}),
};
assert_snapshot!(err.to_string(), @"
[31m✗[39m [31mFailed to create worktree for [1mfeature[22m from base [1mmain[22m[39m
[107m [0m fatal: ref exists
[2m↳[22m [2mFailed command, [4mexit code 128[24m:[22m
[107m [0m [2m[0m[2m[34mgit[0m[2m worktree add /path [0m[2m[36m-b[0m[2m feature main[0m
");
}
#[test]
fn snapshot_worktree_locked() {
let err = GitError::WorktreeLocked {
branch: "feature".into(),
path: PathBuf::from("/tmp/repo.feature"),
reason: Some("Testing lock".into()),
};
assert_snapshot!(err.to_string(), @"
[31m✗[39m [31mCannot remove [1mfeature[22m, worktree is locked (Testing lock)[39m
[2m↳[22m [2mTo unlock, run [4mgit worktree unlock /tmp/repo.feature[24m[22m
");
let err = GitError::WorktreeLocked {
branch: "feature".into(),
path: PathBuf::from("/tmp/repo.feature"),
reason: Some("".into()),
};
let display = err.to_string();
assert_snapshot!(display, @"
[31m✗[39m [31mCannot remove [1mfeature[22m, worktree is locked[39m
[2m↳[22m [2mTo unlock, run [4mgit worktree unlock /tmp/repo.feature[24m[22m
");
assert!(
!display.contains("locked ("),
"should not show parentheses without reason"
);
}
#[test]
fn snapshot_not_rebased() {
let err = GitError::NotRebased {
target_branch: "main".into(),
};
assert_snapshot!(err.to_string(), @"
[31m✗[39m [31mBranch not rebased onto [1mmain[22m[39m
[2m↳[22m [2mTo rebase first, run [4mwt step rebase main[24m; or remove [4m--no-rebase[24m[22m
");
}
#[test]
fn snapshot_hook_command_not_found() {
let err = GitError::HookCommandNotFound {
name: "unknown".into(),
available: vec!["lint".into(), "test".into()],
};
assert_snapshot!(err.to_string(), @"[31m✗[39m [31mNo command named [1munknown[22m (available: [1mlint[22m, [1mtest[22m)[39m");
let err = GitError::HookCommandNotFound {
name: "unknown".into(),
available: vec![],
};
assert_snapshot!(err.to_string(), @"[31m✗[39m [31mNo command named [1munknown[22m (hook has no named commands)[39m");
}
#[test]
fn snapshot_llm_command_failed() {
let err = GitError::LlmCommandFailed {
command: "llm".into(),
error: "connection failed".into(),
reproduction_command: Some("wt step commit --show-prompt | llm".into()),
};
assert_snapshot!(err.to_string(), @"
[31m✗[39m [31mCommit generation command failed[39m
[107m [0m connection failed
[2m○[22m Ran command:
[107m [0m wt step commit --show-prompt | llm
");
let err = GitError::LlmCommandFailed {
command: "llm --model gpt-4".into(),
error: "timeout".into(),
reproduction_command: None,
};
assert_snapshot!(err.to_string(), @"
[31m✗[39m [31mCommit generation command failed[39m
[107m [0m timeout
[2m○[22m Ran command:
[107m [0m llm --model gpt-4
");
}
#[test]
fn snapshot_uncommitted_changes() {
let err = GitError::UncommittedChanges {
action: Some("push".into()),
branch: None,
force_hint: false,
};
let display = err.to_string();
assert_snapshot!(display, @"
[31m✗[39m [31mCannot push: working tree has uncommitted changes[39m
[2m↳[22m [2mCommit or stash changes first[22m
");
assert!(!display.contains("--force"));
let err = GitError::UncommittedChanges {
action: None,
branch: Some("feature".into()),
force_hint: false,
};
assert_snapshot!(err.to_string(), @"
[31m✗[39m [31m[1mfeature[22m has uncommitted changes[39m
[2m↳[22m [2mCommit or stash changes first[22m
");
let err = GitError::UncommittedChanges {
action: None,
branch: None,
force_hint: false,
};
assert_snapshot!(err.to_string(), @"
[31m✗[39m [31mWorking tree has uncommitted changes[39m
[2m↳[22m [2mCommit or stash changes first[22m
");
let err = GitError::UncommittedChanges {
action: Some("remove worktree".into()),
branch: Some("feature".into()),
force_hint: true,
};
assert_snapshot!(err.to_string(), @"
[31m✗[39m [31mCannot remove worktree: [1mfeature[22m has uncommitted changes[39m
[2m↳[22m [2mCommit or stash changes first, or to lose uncommitted changes, run [4mwt remove --force feature[24m[22m
");
}
#[test]
fn snapshot_not_fast_forward() {
let err = GitError::NotFastForward {
target_branch: "main".into(),
commits_formatted: "".into(),
in_merge_context: false,
};
assert_snapshot!(err.to_string(), @"
[31m✗[39m [31mCan't push to local [1mmain[22m branch: it has newer commits[39m
[2m↳[22m [2mTo rebase onto [4mmain[24m, run [4mwt step rebase main[24m[22m
");
let err = GitError::NotFastForward {
target_branch: "develop".into(),
commits_formatted: "abc123 Some commit".into(),
in_merge_context: false,
};
assert_snapshot!(err.to_string(), @"
[31m✗[39m [31mCan't push to local [1mdevelop[22m branch: it has newer commits[39m
[107m [0m abc123 Some commit
[2m↳[22m [2mTo rebase onto [4mdevelop[24m, run [4mwt step rebase develop[24m[22m
");
let err = GitError::NotFastForward {
target_branch: "main".into(),
commits_formatted: "def456 Another commit".into(),
in_merge_context: true,
};
assert_snapshot!(err.to_string(), @"
[31m✗[39m [31mCan't push to local [1mmain[22m branch: it has newer commits[39m
[107m [0m def456 Another commit
[2m↳[22m [2mTo incorporate these changes, run [4mwt merge main[24m again[22m
");
}
#[test]
fn snapshot_conflicting_changes_empty_files() {
let err = GitError::ConflictingChanges {
target_branch: "main".into(),
files: vec![],
worktree_path: PathBuf::from("/tmp/repo"),
};
assert_snapshot!(err.to_string(), @"
[31m✗[39m [31mCan't push to local [1mmain[22m branch: conflicting uncommitted changes[39m
[2m↳[22m [2mCommit or stash these changes in /tmp/repo first[22m
");
}
#[test]
fn snapshot_cli_api_error() {
let err = GitError::CliApiError {
ref_type: RefType::Pr,
message: "gh api failed for PR #42".into(),
stderr: "error: unexpected response\ncode: 500".into(),
};
assert_snapshot!(err.to_string(), @"
[31m✗[39m [31mgh api failed for PR #42[39m
[107m [0m error: unexpected response
[107m [0m code: 500
");
}
#[test]
fn snapshot_no_remote_for_repo() {
let err = GitError::NoRemoteForRepo {
owner: "upstream-owner".into(),
repo: "upstream-repo".into(),
suggested_url: "https://github.com/upstream-owner/upstream-repo.git".into(),
};
assert_snapshot!(err.to_string(), @"
[31m✗[39m [31mNo remote found for [1mupstream-owner/upstream-repo[22m[39m
[2m↳[22m [2mAdd the remote: [4mgit remote add upstream https://github.com/upstream-owner/upstream-repo.git[24m[22m
");
}
#[test]
fn snapshot_rebase_conflict_empty_output() {
let err = GitError::RebaseConflict {
target_branch: "main".into(),
git_output: "".into(),
};
assert_snapshot!(err.to_string(), @"
[31m✗[39m [31mRebase onto [1mmain[22m incomplete[39m
[2m↳[22m [2mTo continue after resolving conflicts, run [4mgit rebase --continue[24m[22m
[2m↳[22m [2mTo abort, run [4mgit rebase --abort[24m[22m
");
}
#[test]
fn snapshot_with_switch_suggestion_branch_already_exists() {
let err = GitError::WithSwitchSuggestion {
source: Box::new(GitError::BranchAlreadyExists {
branch: "emails".into(),
}),
ctx: SwitchSuggestionCtx {
extra_flags: vec!["--execute=claude".into()],
trailing_args: vec!["Check my emails".into()],
},
};
assert_snapshot!(err.to_string(), @"
[31m✗[39m [31mBranch [1memails[22m already exists[39m
[2m↳[22m [2mTo switch to the existing branch, run without [4m--create[24m: [4mwt switch emails --execute=claude -- 'Check my emails'[24m[22m
");
}
#[test]
fn snapshot_with_switch_suggestion_worktree_path_exists() {
let err = GitError::WithSwitchSuggestion {
source: Box::new(GitError::WorktreePathExists {
branch: "emails".into(),
path: PathBuf::from("/tmp/repo.emails"),
create: true,
}),
ctx: SwitchSuggestionCtx {
extra_flags: vec!["--execute=claude".into()],
trailing_args: vec!["Check my emails".into()],
},
};
assert_snapshot!(err.to_string(), @"
[31m✗[39m [31mDirectory already exists: [1m/tmp/repo.emails[22m[39m
[2m↳[22m [2mTo remove manually, run [4mrm -rf /tmp/repo.emails[24m; to overwrite (with backup), run [4mwt switch --create --clobber emails --execute=claude -- 'Check my emails'[24m[22m
");
}
#[test]
fn snapshot_with_switch_suggestion_no_trailing_args() {
let err = GitError::WithSwitchSuggestion {
source: Box::new(GitError::BranchAlreadyExists {
branch: "emails".into(),
}),
ctx: SwitchSuggestionCtx {
extra_flags: vec!["--execute=claude".into()],
trailing_args: vec![],
},
};
assert_snapshot!(err.to_string(), @"
[31m✗[39m [31mBranch [1memails[22m already exists[39m
[2m↳[22m [2mTo switch to the existing branch, run without [4m--create[24m: [4mwt switch emails --execute=claude[24m[22m
");
}
#[test]
fn snapshot_with_switch_suggestion_branch_not_found() {
let err = GitError::WithSwitchSuggestion {
source: Box::new(GitError::BranchNotFound {
branch: "emails".into(),
show_create_hint: true,
last_fetch_ago: None,
}),
ctx: SwitchSuggestionCtx {
extra_flags: vec!["--execute=claude".into()],
trailing_args: vec!["Check my emails".into()],
},
};
assert_snapshot!(err.to_string(), @"
[31m✗[39m [31mNo branch named [1memails[22m[39m
[2m↳[22m [2mTo create a new branch, run [4mwt switch --create emails --execute=claude -- 'Check my emails'[24m; to list branches, run [4mwt list --branches --remotes[24m[22m
");
}
#[test]
fn test_with_switch_suggestion_unwrapped_errors_unaffected() {
let inner = GitError::DetachedHead {
action: Some("merge".into()),
};
let wrapped = GitError::WithSwitchSuggestion {
source: Box::new(inner.clone()),
ctx: SwitchSuggestionCtx {
extra_flags: vec!["--execute=claude".into()],
trailing_args: vec!["Check my emails".into()],
},
};
assert_eq!(inner.to_string(), wrapped.to_string());
}
}