use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use serde::Serialize;
use crate::request_log::{self, WorktreeOutcome};
#[derive(Parser)]
#[command(
long_about = "Worktree operations: thin wrappers over `git worktree` that record \
recovery-relevant metadata (path, branch, commit, uncommitted status) to the omni-dev \
request log. If a worktree is removed by mistake, `omni-dev log --command 'git worktree \
remove'` returns the row needed to recover the branch.\n\nNot to be confused with \
`omni-dev worktrees`, which lists worktrees open across VS Code windows via the daemon."
)]
pub struct WorktreeCommand {
#[command(subcommand)]
pub command: WorktreeSubcommands,
}
#[derive(Subcommand)]
pub enum WorktreeSubcommands {
Add(AddArgs),
Remove(RemoveArgs),
List(ListArgs),
Move(MoveArgs),
Prune(PruneArgs),
Repair(RepairArgs),
}
#[derive(Parser)]
pub struct AddArgs {
pub path: PathBuf,
pub commit_ish: Option<String>,
#[arg(short = 'b', long = "branch", value_name = "BRANCH")]
pub branch: Option<String>,
#[arg(short = 'f', long)]
pub force: bool,
#[arg(long)]
pub detach: bool,
}
#[derive(Parser)]
pub struct RemoveArgs {
pub path: PathBuf,
#[arg(short = 'f', long)]
pub force: bool,
}
#[derive(Parser)]
pub struct ListArgs {
#[arg(long, conflicts_with = "output_json")]
pub porcelain: bool,
#[arg(long)]
pub output_json: bool,
}
#[derive(Parser)]
pub struct MoveArgs {
pub from: PathBuf,
pub to: PathBuf,
#[arg(short = 'f', long)]
pub force: bool,
}
#[derive(Parser)]
pub struct PruneArgs {
#[arg(short = 'n', long)]
pub dry_run: bool,
#[arg(short = 'v', long)]
pub verbose: bool,
#[arg(long, value_name = "TIME")]
pub expire: Option<String>,
}
#[derive(Parser)]
pub struct RepairArgs {
pub paths: Vec<PathBuf>,
}
impl WorktreeCommand {
pub fn execute(self, repo: Option<&Path>) -> Result<()> {
let base = base_dir(repo)?;
match self.command {
WorktreeSubcommands::Add(args) => run_add(&base, &args),
WorktreeSubcommands::Remove(args) => run_remove(&base, &args),
WorktreeSubcommands::List(args) => run_list(&base, &args),
WorktreeSubcommands::Move(args) => run_move(&base, &args),
WorktreeSubcommands::Prune(args) => run_prune(&base, &args),
WorktreeSubcommands::Repair(args) => run_repair(&base, &args),
}
}
}
fn run_add(base: &Path, args: &AddArgs) -> Result<()> {
let argv = add_argv(args);
let mut context = base_context(base);
let abs = absolutize(base, &args.path);
let run = run_git(base, &argv);
echo(&run);
context.insert("path".to_string(), display_canonical(&abs));
if run.exit_code == Some(0) {
insert_snapshot(&mut context, &snapshot(&abs), false);
}
finish("add", argv, run, context)
}
fn run_remove(base: &Path, args: &RemoveArgs) -> Result<()> {
let argv = remove_argv(args);
let mut context = base_context(base);
let abs = absolutize(base, &args.path);
context.insert("path".to_string(), display_canonical(&abs));
context.insert("used_force".to_string(), args.force.to_string());
let snap = snapshot(&abs);
insert_snapshot(&mut context, &snap, true);
let run = run_git(base, &argv);
echo(&run);
finish("remove", argv, run, context)
}
fn run_list(base: &Path, args: &ListArgs) -> Result<()> {
let porcelain = args.porcelain || args.output_json;
let argv = list_argv(porcelain);
let mut context = base_context(base);
let run = run_git(base, &argv);
if args.output_json {
echo_stderr(&run);
if run.exit_code == Some(0) {
let stdout = String::from_utf8_lossy(&run.stdout);
let entries = parse_worktree_porcelain(&stdout);
context.insert("count".to_string(), entries.len().to_string());
let json = serde_json::to_string_pretty(&entries)
.context("Failed to serialize worktree list as JSON")?;
println!("{json}");
}
} else {
echo(&run);
if run.exit_code == Some(0) {
let stdout = String::from_utf8_lossy(&run.stdout);
let count = if porcelain {
parse_worktree_porcelain(&stdout).len()
} else {
stdout.lines().filter(|l| !l.trim().is_empty()).count()
};
context.insert("count".to_string(), count.to_string());
}
}
finish("list", argv, run, context)
}
fn run_move(base: &Path, args: &MoveArgs) -> Result<()> {
let argv = move_argv(args);
let mut context = base_context(base);
let from_abs = absolutize(base, &args.from);
context.insert("from_path".to_string(), display_canonical(&from_abs));
context.insert(
"to_path".to_string(),
absolutize(base, &args.to).display().to_string(),
);
let snap = snapshot(&from_abs);
if let Some(branch) = &snap.branch {
context.insert("branch".to_string(), branch.clone());
}
let run = run_git(base, &argv);
echo(&run);
finish("move", argv, run, context)
}
fn run_prune(base: &Path, args: &PruneArgs) -> Result<()> {
let argv = prune_argv(args);
let mut context = base_context(base);
if args.dry_run {
context.insert("dry_run".to_string(), "true".to_string());
}
let before = list_entries(base);
let run = run_git(base, &argv);
echo(&run);
if let (Some(before), Some(after)) = (before, list_entries(base)) {
let pruned = pruned_entries(&before, &after);
match serde_json::to_string(&pruned) {
Ok(json) => {
context.insert("pruned".to_string(), json);
}
Err(e) => tracing::debug!("Cannot serialize pruned worktree list: {e}"),
}
}
finish("prune", argv, run, context)
}
fn run_repair(base: &Path, args: &RepairArgs) -> Result<()> {
let argv = repair_argv(args);
let mut context = base_context(base);
let run = run_git(base, &argv);
echo(&run);
let combined = format!(
"{}{}",
String::from_utf8_lossy(&run.stdout),
String::from_utf8_lossy(&run.stderr)
);
let repaired = parse_repair_paths(&combined);
if !repaired.is_empty() {
match serde_json::to_string(&repaired) {
Ok(json) => {
context.insert("repaired".to_string(), json);
}
Err(e) => tracing::debug!("Cannot serialize repaired worktree list: {e}"),
}
}
finish("repair", argv, run, context)
}
fn finish(
verb: &str,
argv: Vec<String>,
run: GitRun,
context: BTreeMap<String, String>,
) -> Result<()> {
request_log::record_worktree(WorktreeOutcome {
verb: verb.to_string(),
argv,
exit_code: run.exit_code,
duration: run.duration,
error: run.error.clone(),
context,
});
match (run.exit_code, run.error) {
(Some(0), _) => Ok(()),
(Some(code), _) => anyhow::bail!("git worktree {verb} failed with exit code {code}"),
(None, err) => anyhow::bail!(
"Failed to run git worktree {verb}: {}",
err.unwrap_or_else(|| "unknown spawn error".to_string())
),
}
}
fn add_argv(args: &AddArgs) -> Vec<String> {
let mut argv = vec!["worktree".to_string(), "add".to_string()];
if args.force {
argv.push("--force".to_string());
}
if args.detach {
argv.push("--detach".to_string());
}
if let Some(branch) = &args.branch {
argv.push("-b".to_string());
argv.push(branch.clone());
}
argv.push(args.path.display().to_string());
if let Some(commit_ish) = &args.commit_ish {
argv.push(commit_ish.clone());
}
argv
}
fn remove_argv(args: &RemoveArgs) -> Vec<String> {
let mut argv = vec!["worktree".to_string(), "remove".to_string()];
if args.force {
argv.push("--force".to_string());
}
argv.push(args.path.display().to_string());
argv
}
fn list_argv(porcelain: bool) -> Vec<String> {
if porcelain {
vec![
"-c".to_string(),
"core.quotePath=false".to_string(),
"worktree".to_string(),
"list".to_string(),
"--porcelain".to_string(),
]
} else {
vec!["worktree".to_string(), "list".to_string()]
}
}
fn move_argv(args: &MoveArgs) -> Vec<String> {
let mut argv = vec!["worktree".to_string(), "move".to_string()];
if args.force {
argv.push("--force".to_string());
}
argv.push(args.from.display().to_string());
argv.push(args.to.display().to_string());
argv
}
fn prune_argv(args: &PruneArgs) -> Vec<String> {
let mut argv = vec!["worktree".to_string(), "prune".to_string()];
if args.dry_run {
argv.push("--dry-run".to_string());
}
if args.verbose {
argv.push("--verbose".to_string());
}
if let Some(expire) = &args.expire {
argv.push("--expire".to_string());
argv.push(expire.clone());
}
argv
}
fn repair_argv(args: &RepairArgs) -> Vec<String> {
let mut argv = vec!["worktree".to_string(), "repair".to_string()];
argv.extend(args.paths.iter().map(|p| p.display().to_string()));
argv
}
struct GitRun {
exit_code: Option<i32>,
duration: Duration,
error: Option<String>,
stdout: Vec<u8>,
stderr: Vec<u8>,
}
fn git_command() -> Command {
let mut cmd = Command::new("git");
cmd.env_clear();
cmd.envs(std::env::vars_os());
cmd
}
fn run_git(base: &Path, argv: &[String]) -> GitRun {
let started = Instant::now();
let result = git_command().args(argv).current_dir(base).output();
let duration = started.elapsed();
match result {
Ok(output) => GitRun {
exit_code: output.status.code(),
duration,
error: None,
stdout: output.stdout,
stderr: output.stderr,
},
Err(e) => GitRun {
exit_code: None,
duration,
error: Some(e.to_string()),
stdout: Vec::new(),
stderr: Vec::new(),
},
}
}
fn echo(run: &GitRun) {
let _ = std::io::stdout().write_all(&run.stdout);
echo_stderr(run);
}
fn echo_stderr(run: &GitRun) {
let _ = std::io::stderr().write_all(&run.stderr);
}
fn base_dir(repo: Option<&Path>) -> Result<PathBuf> {
match repo {
Some(p) => Ok(p.to_path_buf()),
None => std::env::current_dir().context("Failed to resolve working directory"),
}
}
fn base_context(base: &Path) -> BTreeMap<String, String> {
let mut context = BTreeMap::new();
if let Some(toplevel) = toplevel(base) {
context.insert("repo".to_string(), toplevel);
}
context
}
fn toplevel(base: &Path) -> Option<String> {
let output = git_command()
.args(["rev-parse", "--show-toplevel"])
.current_dir(base)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8(output.stdout).ok()?;
let trimmed = stdout.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
#[derive(Default)]
struct WtSnapshot {
branch: Option<String>,
detached: bool,
commit: Option<String>,
dirty: Option<bool>,
}
fn snapshot(path: &Path) -> WtSnapshot {
let mut snap = WtSnapshot::default();
let repo = match git2::Repository::open(path) {
Ok(repo) => repo,
Err(e) => {
tracing::debug!("Cannot open {} as a git repository: {e}", path.display());
return snap;
}
};
match repo.head() {
Ok(head) => {
if head.is_branch() {
snap.branch = head.shorthand().ok().map(str::to_string);
} else {
snap.detached = true;
}
snap.commit = head.target().map(|oid| oid.to_string());
}
Err(e) => tracing::debug!("Cannot read HEAD of {}: {e}", path.display()),
}
let mut opts = git2::StatusOptions::new();
opts.include_untracked(true)
.recurse_untracked_dirs(true)
.include_ignored(false)
.exclude_submodules(true);
match repo.statuses(Some(&mut opts)) {
Ok(statuses) => snap.dirty = Some(!statuses.is_empty()),
Err(e) => tracing::debug!("Cannot read status of {}: {e}", path.display()),
}
snap
}
fn insert_snapshot(context: &mut BTreeMap<String, String>, snap: &WtSnapshot, with_dirty: bool) {
if let Some(branch) = &snap.branch {
context.insert("branch".to_string(), branch.clone());
}
if snap.detached {
context.insert("detached".to_string(), "true".to_string());
}
if let Some(commit) = &snap.commit {
context.insert("commit".to_string(), commit.clone());
}
if with_dirty {
if let Some(dirty) = snap.dirty {
context.insert("had_uncommitted".to_string(), dirty.to_string());
}
}
}
fn absolutize(base: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
base.join(path)
}
}
fn display_canonical(path: &Path) -> String {
std::fs::canonicalize(path)
.unwrap_or_else(|_| path.to_path_buf())
.display()
.to_string()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct WorktreeEntry {
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub head: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub detached: bool,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub bare: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub locked: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prunable: Option<String>,
}
impl WorktreeEntry {
fn new(path: String) -> Self {
Self {
path,
head: None,
branch: None,
detached: false,
bare: false,
locked: None,
prunable: None,
}
}
}
fn parse_worktree_porcelain(out: &str) -> Vec<WorktreeEntry> {
let mut entries = Vec::new();
let mut current: Option<WorktreeEntry> = None;
for line in out.lines() {
if let Some(path) = line.strip_prefix("worktree ") {
entries.extend(current.take());
current = Some(WorktreeEntry::new(path.to_string()));
continue;
}
let Some(entry) = current.as_mut() else {
continue;
};
if let Some(head) = line.strip_prefix("HEAD ") {
entry.head = Some(head.to_string());
} else if let Some(branch) = line.strip_prefix("branch ") {
entry.branch = Some(
branch
.strip_prefix("refs/heads/")
.unwrap_or(branch)
.to_string(),
);
} else if line == "detached" {
entry.detached = true;
} else if line == "bare" {
entry.bare = true;
} else if line == "locked" {
entry.locked = Some(String::new());
} else if let Some(reason) = line.strip_prefix("locked ") {
entry.locked = Some(reason.to_string());
} else if let Some(reason) = line.strip_prefix("prunable ") {
entry.prunable = Some(reason.to_string());
}
}
entries.extend(current);
entries
}
fn list_entries(base: &Path) -> Option<Vec<WorktreeEntry>> {
let output = git_command()
.args([
"-c",
"core.quotePath=false",
"worktree",
"list",
"--porcelain",
])
.current_dir(base)
.output()
.ok()?;
if !output.status.success() {
return None;
}
Some(parse_worktree_porcelain(&String::from_utf8_lossy(
&output.stdout,
)))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct PrunedEntry {
path: String,
#[serde(skip_serializing_if = "Option::is_none")]
branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
commit: Option<String>,
}
fn pruned_entries(before: &[WorktreeEntry], after: &[WorktreeEntry]) -> Vec<PrunedEntry> {
before
.iter()
.filter(|b| !after.iter().any(|a| a.path == b.path))
.map(|b| PrunedEntry {
path: b.path.clone(),
branch: b.branch.clone(),
commit: b.head.clone(),
})
.collect()
}
fn parse_repair_paths(out: &str) -> Vec<String> {
out.lines()
.filter_map(|line| line.strip_prefix("repair: "))
.filter_map(|rest| rest.rsplit_once(": ").map(|(_, path)| path.to_string()))
.collect()
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
fn argv(args: &[&str]) -> Vec<String> {
args.iter().copied().map(String::from).collect()
}
#[test]
fn porcelain_parses_main_linked_and_detached_stanzas() {
let out = "\
worktree /repo
HEAD 1111111111111111111111111111111111111111
branch refs/heads/main
worktree /wt/feature
HEAD 2222222222222222222222222222222222222222
branch refs/heads/issue-1392
worktree /wt/detached
HEAD 3333333333333333333333333333333333333333
detached
";
let entries = parse_worktree_porcelain(out);
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].path, "/repo");
assert_eq!(entries[0].branch.as_deref(), Some("main"));
assert_eq!(
entries[0].head.as_deref(),
Some("1111111111111111111111111111111111111111")
);
assert_eq!(entries[1].branch.as_deref(), Some("issue-1392"));
assert!(entries[2].detached);
assert!(entries[2].branch.is_none());
}
#[test]
fn porcelain_parses_bare_locked_prunable_and_ignores_unknown() {
let out = "\
worktree /bare
bare
worktree /locked-no-reason
HEAD 4444444444444444444444444444444444444444
locked
worktree /locked-with-reason
locked worktree is on a portable device
future-attribute some value
worktree /stale
prunable gitdir file points to non-existent location";
let entries = parse_worktree_porcelain(out);
assert_eq!(entries.len(), 4);
assert!(entries[0].bare);
assert_eq!(entries[1].locked.as_deref(), Some(""));
assert_eq!(
entries[2].locked.as_deref(),
Some("worktree is on a portable device")
);
assert_eq!(
entries[3].prunable.as_deref(),
Some("gitdir file points to non-existent location")
);
}
#[test]
fn porcelain_entry_serializes_compactly() {
let entries = parse_worktree_porcelain("worktree /repo\nbranch refs/heads/main\n");
let json = serde_json::to_string(&entries).unwrap();
assert_eq!(json, r#"[{"path":"/repo","branch":"main"}]"#);
}
#[test]
fn porcelain_ignores_attributes_before_the_first_stanza() {
let entries = parse_worktree_porcelain("HEAD 9999\nworktree /a\n");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].path, "/a");
assert!(entries[0].head.is_none());
}
#[test]
fn snapshot_degrades_on_non_repo_unborn_and_detached_trees() {
let tmp = tempfile::tempdir().unwrap();
let snap = snapshot(tmp.path());
assert!(snap.branch.is_none());
assert!(snap.commit.is_none());
assert!(snap.dirty.is_none());
assert!(!snap.detached);
let unborn = tmp.path().join("unborn");
git2::Repository::init(&unborn).unwrap();
let snap = snapshot(&unborn);
assert!(snap.branch.is_none());
assert!(snap.commit.is_none());
assert_eq!(snap.dirty, Some(false));
let detached = tmp.path().join("detached");
let repo = git2::Repository::init(&detached).unwrap();
let sig = git2::Signature::now("Test User", "test@example.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let oid = repo
.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
.unwrap();
repo.set_head_detached(oid).unwrap();
let snap = snapshot(&detached);
assert!(snap.detached);
assert!(snap.branch.is_none());
assert_eq!(snap.commit, Some(oid.to_string()));
}
#[test]
fn insert_snapshot_records_detached_and_gates_dirty() {
let snap = WtSnapshot {
branch: None,
detached: true,
commit: Some("abc".to_string()),
dirty: Some(false),
};
let mut ctx = BTreeMap::new();
insert_snapshot(&mut ctx, &snap, false);
assert_eq!(ctx.get("detached").map(String::as_str), Some("true"));
assert_eq!(ctx.get("commit").map(String::as_str), Some("abc"));
assert!(!ctx.contains_key("branch"));
assert!(!ctx.contains_key("had_uncommitted"));
insert_snapshot(&mut ctx, &snap, true);
assert_eq!(
ctx.get("had_uncommitted").map(String::as_str),
Some("false")
);
}
#[test]
fn absolutize_and_display_canonical_handle_relative_and_missing_paths() {
let base = Path::new("/base");
assert_eq!(absolutize(base, Path::new("/abs")), PathBuf::from("/abs"));
assert_eq!(
absolutize(base, Path::new("rel")),
PathBuf::from("/base/rel")
);
assert_eq!(
display_canonical(Path::new("/no/such/dir/x")),
"/no/such/dir/x"
);
}
#[test]
fn add_argv_covers_flags_positionals_and_order() {
let args = AddArgs {
path: PathBuf::from("../wt"),
commit_ish: Some("origin/main".to_string()),
branch: Some("issue-1".to_string()),
force: true,
detach: false,
};
assert_eq!(
add_argv(&args),
argv(&[
"worktree",
"add",
"--force",
"-b",
"issue-1",
"../wt",
"origin/main"
])
);
let minimal = AddArgs {
path: PathBuf::from("wt"),
commit_ish: None,
branch: None,
force: false,
detach: true,
};
assert_eq!(
add_argv(&minimal),
argv(&["worktree", "add", "--detach", "wt"])
);
}
#[test]
fn remove_move_prune_repair_argv() {
assert_eq!(
remove_argv(&RemoveArgs {
path: PathBuf::from("/wt"),
force: true,
}),
argv(&["worktree", "remove", "--force", "/wt"])
);
assert_eq!(
move_argv(&MoveArgs {
from: PathBuf::from("a"),
to: PathBuf::from("b"),
force: false,
}),
argv(&["worktree", "move", "a", "b"])
);
assert_eq!(
move_argv(&MoveArgs {
from: PathBuf::from("a"),
to: PathBuf::from("b"),
force: true,
}),
argv(&["worktree", "move", "--force", "a", "b"])
);
assert_eq!(
prune_argv(&PruneArgs {
dry_run: true,
verbose: true,
expire: Some("1.day".to_string()),
}),
argv(&[
"worktree",
"prune",
"--dry-run",
"--verbose",
"--expire",
"1.day"
])
);
assert_eq!(
repair_argv(&RepairArgs {
paths: vec![PathBuf::from("a"), PathBuf::from("b")],
}),
argv(&["worktree", "repair", "a", "b"])
);
}
#[test]
fn list_argv_adds_quote_path_only_for_porcelain() {
assert_eq!(list_argv(false), argv(&["worktree", "list"]));
assert_eq!(
list_argv(true),
argv(&[
"-c",
"core.quotePath=false",
"worktree",
"list",
"--porcelain"
])
);
}
#[test]
fn pruned_entries_diffs_by_path_and_keeps_branch_commit() {
let before = parse_worktree_porcelain(
"worktree /repo\nbranch refs/heads/main\n\n\
worktree /stale\nHEAD 5555555555555555555555555555555555555555\n\
branch refs/heads/gone\nprunable gitdir gone\n",
);
let after = parse_worktree_porcelain("worktree /repo\nbranch refs/heads/main\n");
let pruned = pruned_entries(&before, &after);
assert_eq!(pruned.len(), 1);
assert_eq!(pruned[0].path, "/stale");
assert_eq!(pruned[0].branch.as_deref(), Some("gone"));
assert_eq!(
pruned[0].commit.as_deref(),
Some("5555555555555555555555555555555555555555")
);
assert!(pruned_entries(&after, &after).is_empty());
}
#[test]
fn repair_paths_parsed_from_report_lines_best_effort() {
let out = "\
repair: gitdir file points to non-existent location: /wt/one/.git
not a repair line
repair: .git file broken: /wt/two/.git";
assert_eq!(
parse_repair_paths(out),
argv(&["/wt/one/.git", "/wt/two/.git"])
);
assert!(parse_repair_paths("nothing to repair").is_empty());
}
#[derive(Parser)]
struct Wrapper {
#[command(subcommand)]
cmd: WorktreeSubcommands,
}
#[test]
fn clap_parses_every_verb() {
for args in [
vec!["omni-dev", "add", "wt", "-b", "branch", "origin/main"],
vec!["omni-dev", "remove", "--force", "wt"],
vec!["omni-dev", "list", "--porcelain"],
vec!["omni-dev", "list", "--output-json"],
vec!["omni-dev", "move", "a", "b"],
vec!["omni-dev", "prune", "-n", "--expire", "1.day"],
vec!["omni-dev", "repair", "a", "b"],
vec!["omni-dev", "repair"],
] {
Wrapper::try_parse_from(&args)
.unwrap_or_else(|e| panic!("failed to parse {args:?}: {e}"));
}
}
#[test]
fn clap_rejects_porcelain_with_output_json() {
assert!(
Wrapper::try_parse_from(["omni-dev", "list", "--porcelain", "--output-json"]).is_err()
);
}
}