use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::{generate, Shell};
use colored::Colorize;
use std::env;
use std::fs;
use std::io::IsTerminal;
use std::process::exit;
use std::time::Instant;
use wsb::app::AppContext;
use wsb::fleet::config::GitModules;
use wsb::fleet::error::SuperGitError;
use wsb::fleet::git::{SubmoduleOps, SubmoduleTree};
use wsb::logging::{
log_operation_complete, log_operation_error, log_operation_start, log_version_info,
setup_logging,
};
use wsb::prelude::*;
use wsb::state::discard::owns_templates;
use wsb::annotations::Ref;
use wsb::roots;
use wsb::settings::{ProjectSettings, RepoSettings, VersionSettings};
use wsb::state::{
detect_project_files, is_git_repository_in, update_version_file_in_repo, RunCache, VersionInfo,
WstemplateEngine,
};
#[derive(Parser, Debug)]
#[command(name = "wsb")]
#[command(version = env!("CARGO_PKG_VERSION"))]
#[command(about = "Worktree - All-in-one development tool suite")]
#[command(
after_help = "When no subcommand is provided, `wsb` defaults to `serve`.\nRun `wsb completions` to install shell completions."
)]
struct Args {
#[arg(long, global = true)]
jsonl: bool,
#[arg(long, global = true)]
no_daemon: bool,
#[arg(long, global = true)]
ignore_worktree_lock: bool,
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand, Debug)]
enum Commands {
Refactor {
#[command(flatten)]
args: wsb::refactor::Args,
},
Status {
#[arg(short, long)]
verbose: bool,
},
Discard {
#[arg(long)]
untracked: bool,
#[arg(long)]
yes: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
Checkout {
branch: String,
#[arg(short, long)]
create: bool,
#[arg(long)]
ignore_missing: bool,
#[arg(short, long)]
verbose: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
Pull {
#[arg(short, long)]
rebase: bool,
#[arg(long)]
autostash: bool,
#[arg(long, conflicts_with = "autostash")]
overwrite_local: bool,
#[arg(short, long)]
verbose: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
Push {
#[arg(short, long)]
force_with_lease: bool,
#[arg(long)]
force_all: bool,
#[arg(short, long)]
verbose: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
Create {
branch: String,
#[arg(short, long)]
from: Option<String>,
#[arg(short, long)]
push: bool,
#[arg(long)]
force_all: bool,
#[arg(short, long)]
verbose: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
Commit {
#[arg(short, long)]
message: String,
#[arg(short, long)]
push: bool,
#[arg(short = 'a', long)]
stage_all: bool,
#[arg(short = 'x', long = "exclude", value_name = "ALIAS")]
exclude: Vec<String>,
#[arg(short = 'i', long = "include", value_name = "ALIAS")]
include: Vec<String>,
#[arg(long)]
force_all: bool,
#[arg(short, long)]
verbose: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
Sync {
#[arg(short, long)]
init: bool,
#[arg(short, long)]
recursive: bool,
#[arg(short, long)]
verbose: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
Delete {
branch: String,
#[arg(short, long)]
remote: bool,
#[arg(short, long)]
force: bool,
#[arg(short, long)]
verbose: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
Fetch {
#[arg(short, long)]
all: bool,
#[arg(long)]
force_all: bool,
#[arg(short, long)]
verbose: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
Branch {
#[arg(short, long)]
all: bool,
#[arg(short, long)]
verbose: bool,
},
SubmoduleInit {
#[arg(short, long)]
recursive: bool,
#[arg(short, long)]
verbose: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
Diff {
#[arg(long, conflicts_with = "since")]
base: Option<String>,
#[arg(long, conflicts_with = "base")]
since: Option<String>,
#[arg(short, long, default_value = "diffs")]
output: String,
#[arg(short, long)]
staged: bool,
#[arg(long)]
clean: bool,
#[arg(short, long)]
verbose: bool,
},
Stage {
#[arg(short, long)]
verbose: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
MergeBack {
#[arg(short, long, default_value = "Pre-merge commit")]
message: String,
#[arg(short, long)]
verbose: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
Completions {
#[arg(long)]
shell: Option<Shell>,
},
Local {
#[command(subcommand)]
action: LocalAction,
},
Checkpoint {
#[arg(short, long)]
message: String,
#[arg(long)]
no_push: bool,
#[arg(short = 'n', long)]
dry_run: bool,
},
Update {
#[arg(long)]
no_git: bool,
#[arg(long)]
git_add: bool,
#[arg(long)]
version_suffix: Option<String>,
#[arg(long, conflicts_with = "version_suffix")]
tag: Option<String>,
#[arg(long, short)]
quiet: bool,
#[arg(long)]
recursive: bool,
#[arg(long)]
verify: bool,
#[arg(long = "discard")]
discard: bool,
#[arg(long = "dry-run", requires = "discard")]
dry_run: bool,
},
Scrap {
paths: Vec<std::path::PathBuf>,
#[command(subcommand)]
command: Option<ScrapCommands>,
},
Unscrap {
name: Option<String>,
#[arg(short, long)]
force: bool,
#[arg(short = 't', long)]
to: Option<std::path::PathBuf>,
},
Ldiff {
#[arg(default_value = "░")]
substitute_char: String,
},
Compact {
input: Option<String>,
#[arg(short, long)]
output: Option<String>,
#[arg(long)]
token_mode: bool,
},
AuditPath,
Ignore {
line: String,
},
LastHash {
file: String,
},
Code {
#[command(subcommand)]
action: Option<CodeAction>,
},
#[command(disable_help_flag = true)]
Peer {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
#[command(disable_help_flag = true)]
Plugin {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
#[command(disable_help_flag = true)]
Worktree {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
#[command(disable_help_flag = true)]
Upgrade {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
#[command(disable_help_flag = true)]
Github {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
Test {
#[arg(long)]
dry_run: bool,
#[arg(long)]
install: bool,
#[arg(last = true)]
args: Vec<String>,
},
Serve {
#[arg(short, long)]
port: Option<u16>,
#[arg(long)]
listen: Option<std::net::IpAddr>,
#[arg(long)]
debug: bool,
#[arg(short, long)]
worktree: Option<String>,
#[arg(long)]
parent_pid: Option<u32>,
},
Daemon {
#[command(subcommand)]
action: DaemonAction,
},
Init {
#[arg(long)]
name: Option<String>,
},
Check {
#[arg(long)]
no_network: bool,
#[arg(long, short)]
quiet: bool,
},
Version {
#[command(subcommand)]
action: VersionAction,
},
Wstemplate {
#[command(subcommand)]
action: WstemplateAction,
},
Each {
#[arg(short, long)]
file: Option<std::path::PathBuf>,
#[arg(long, short)]
quiet: bool,
},
Catalog {
#[command(subcommand)]
action: CatalogAction,
},
Note {
#[command(subcommand)]
action: NoteAction,
},
Link {
#[command(subcommand)]
action: LinkAction,
},
Migrate,
Machine {
#[command(subcommand)]
action: MachineAction,
},
}
#[derive(clap::Args, Debug, Default)]
struct AboutArgs {
#[arg(long = "on", value_name = "REF")]
refs: Vec<String>,
#[arg(long = "on-file", value_name = "PATH")]
files: Vec<PathBuf>,
#[arg(long = "on-worktree")]
worktree: bool,
#[arg(long = "on-project")]
project: bool,
}
impl AboutArgs {
fn targets(self) -> wsb::cli::annotations::Targets {
wsb::cli::annotations::Targets {
refs: self.refs,
files: self.files,
worktree: self.worktree,
project: self.project,
}
}
}
#[derive(Subcommand, Debug)]
enum NoteAction {
Add {
#[arg(long)]
title: String,
#[arg(long, conflicts_with = "body_file")]
body: Option<String>,
#[arg(long)]
body_file: Option<PathBuf>,
#[command(flatten)]
about: AboutArgs,
},
List {
#[arg(long = "on", value_name = "REF")]
on: Option<String>,
},
Show { id: String },
Edit {
id: String,
#[arg(long)]
title: Option<String>,
#[arg(long, conflicts_with = "body_file")]
body: Option<String>,
#[arg(long)]
body_file: Option<PathBuf>,
},
Rm { id: String },
}
#[derive(Subcommand, Debug)]
enum LinkAction {
Add {
from: String,
to: String,
#[arg(long)]
label: Option<String>,
},
List { at: Option<String> },
Rm {
from: String,
to: String,
#[arg(long)]
label: Option<String>,
},
}
#[derive(Subcommand, Debug)]
enum MachineAction {
Name { name: Option<String> },
}
#[derive(Subcommand, Debug)]
enum DaemonAction {
Status,
Stop,
Restart {
#[arg(short, long)]
port: Option<u16>,
},
Log {
#[arg(short = 'n', long, default_value = "40")]
lines: usize,
},
Install {
#[arg(short, long)]
port: Option<u16>,
#[arg(long)]
peer_port: Option<u16>,
},
Uninstall,
Enable,
Disable,
}
#[derive(Subcommand, Debug)]
enum CatalogAction {
List {
#[arg(long)]
repo: Option<String>,
},
Scan {
#[arg(long, short)]
quiet: bool,
},
Report,
Parity {
family: String,
#[arg(long, short)]
output: Option<PathBuf>,
#[arg(long)]
git_add: bool,
},
Free {
#[arg(long, default_value_t = 1)]
from: i64,
#[arg(long, default_value_t = 5)]
count: usize,
},
Export {
#[arg(short, long)]
output: Option<std::path::PathBuf>,
#[arg(long)]
repo: Option<String>,
#[arg(long)]
git_add: bool,
},
}
#[derive(Subcommand, Debug)]
enum LocalAction {
On {
#[arg(long, short)]
quiet: bool,
#[arg(long)]
git_add: bool,
#[arg(long)]
verify: bool,
},
Off {
#[arg(short = 'n', long)]
dry_run: bool,
},
Status {
#[arg(long)]
files: bool,
},
}
#[derive(Subcommand, Debug)]
enum CodeAction {
Tree {
#[arg(short, long, default_value = "3")]
depth: usize,
#[arg(long)]
hidden: bool,
#[arg(long)]
sizes: bool,
#[arg(long)]
extensions: Option<String>,
#[arg(long)]
no_ignore: bool,
},
Index {
#[arg(long)]
repo: Option<PathBuf>,
},
Search {
pattern: String,
#[arg(long, conflicts_with_all = ["symbols", "structural"])]
regex: bool,
#[arg(long, conflicts_with = "structural")]
symbols: bool,
#[arg(long)]
structural: bool,
#[arg(long)]
include: Vec<String>,
#[arg(long)]
exclude: Vec<String>,
#[arg(long)]
repo: Option<PathBuf>,
},
Symbols {
pattern: String,
#[arg(long)]
kind: Option<String>,
#[arg(long)]
repo: Option<PathBuf>,
},
Refs {
target: String,
#[arg(long)]
repo: Option<PathBuf>,
},
Callers {
target: String,
#[arg(long, default_value_t = 1)]
depth: u32,
#[arg(long)]
repo: Option<PathBuf>,
},
Callees {
target: String,
#[arg(long, default_value_t = 1)]
depth: u32,
#[arg(long)]
repo: Option<PathBuf>,
},
Diagnostics {
#[arg(long)]
file: Option<String>,
#[arg(long)]
repo: Option<PathBuf>,
},
}
#[derive(Subcommand, Debug)]
enum VersionAction {
Show {
#[arg(long)]
verbose: bool,
#[arg(short, long, default_value = "human")]
format: String,
},
Major {
version: u32,
},
Tag {
#[arg(long, default_value = "v")]
prefix: String,
#[arg(short, long)]
message: Option<String>,
},
Info {
#[arg(long)]
include_history: bool,
},
}
#[derive(Subcommand, Debug)]
enum WstemplateAction {
Alias {
alias: String,
},
Root {
path: PathBuf,
},
Show,
List,
Render,
Vars {
alias: Option<String>,
#[arg(long)]
remote: bool,
},
}
#[derive(Subcommand, Debug)]
enum ScrapCommands {
#[command(alias = "ls")]
List {
#[arg(short, long, default_value = "date")]
sort: String,
},
Clean {
#[arg(short, long, default_value = "30")]
days: u64,
#[arg(short = 'n', long)]
dry_run: bool,
},
Purge {
#[arg(short, long)]
force: bool,
},
Find {
pattern: String,
#[arg(short, long)]
content: bool,
},
Archive {
#[arg(short, long)]
output: Option<std::path::PathBuf>,
#[arg(short, long)]
remove: bool,
},
}
const PRE_COMMIT_HOOK: &str = "#!/bin/sh\n\"${WSB_SELF:-wsb}\" update --quiet --git-add\n";
const PRE_COMMIT_MARK: &str = "update --quiet --git-add";
fn pre_commit_command() -> &'static str {
PRE_COMMIT_HOOK
.lines()
.last()
.expect("the hook has a command")
}
fn is_wsb_hook(content: &str) -> bool {
let mut commands = content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'));
commands.next().is_some_and(is_wsb_update) && commands.next().is_none()
}
fn is_wsb_update(command: &str) -> bool {
let mut words = command.split_whitespace();
let Some(program) = words.next() else {
return false;
};
let program = program.trim_matches('"');
let names_wsb = program == "${WSB_SELF:-wsb}"
|| std::path::Path::new(program).file_name().is_some_and(|name| name == "wsb");
names_wsb && words.next() == Some("update") && words.any(|word| word == "--git-add")
}
fn pre_commit_hook_problem(content: &str) -> Option<String> {
let own = is_wsb_hook(content);
let current = content.contains(PRE_COMMIT_MARK) && content.contains(wsb::git_backend::SELF_ENV);
if own && !current {
Some("is an older version of wsb's own hook — run 'wsb init' to reinstall it".to_string())
} else if !content.contains(PRE_COMMIT_MARK) {
Some(format!(
"does not invoke '{PRE_COMMIT_MARK}' — it is a hook of your own; add this line to \
it: {}",
pre_commit_command()
))
} else if !content.contains(wsb::git_backend::SELF_ENV) {
Some(if own {
"runs whichever wsb is on PATH rather than the one driving the commit — run \
'wsb init' to reinstall it"
.to_string()
} else {
format!(
"runs whichever wsb is on PATH rather than the one driving the commit — change \
its wsb line to: {}",
pre_commit_command()
)
})
} else {
None
}
}
const PROGRAM_STACK_BYTES: usize = 8 * 1024 * 1024;
fn main() {
let program = std::thread::Builder::new()
.name("main".to_string())
.stack_size(PROGRAM_STACK_BYTES)
.spawn(program);
match program {
Ok(running) => {
if running.join().is_err() {
exit(101);
}
}
Err(e) => {
wsb::output::failed(
&format!("could not start wsb on its own {PROGRAM_STACK_BYTES}-byte stack: {e}"),
"startup",
);
exit(wsb::output::finish(1));
}
}
}
fn program() {
if let Ok(own) = std::env::current_exe() {
wsb::git_backend::announce_running_wsb(own);
}
let mut argv: Vec<String> = std::env::args().collect();
let jsonl = wsb::output::take_jsonl_flag(&mut argv);
if take_no_daemon_flag(&mut argv) {
std::env::set_var(wsb::daemon::NO_DAEMON_ENV, "1");
}
if take_flag(&mut argv, "--ignore-workspace-lock") {
std::env::set_var(wsb::worktree_lock::IGNORE_ENV, "1");
}
if jsonl {
wsb::output::jsonl_mode();
std::panic::set_hook(Box::new(|info| {
let where_at = info
.location()
.map(|l| format!(" at {}:{}:{}", l.file(), l.line(), l.column()))
.unwrap_or_default();
wsb::output::failed(&format!("panicked{where_at}: {}", panic_message(info)), "panic");
}));
}
if let Some(root) = wsb::logging::detect_worktree_root() {
if let Some(path) = wsb::output::attach_transcript(&root, &argv) {
wsb::output::log_file(&path.display().to_string());
}
}
wsb::output::started(&argv);
let debug_mode = argv
.iter()
.any(|arg| arg == "--debug" || arg == "-v" || arg == "--verbose");
let _log_guard = match setup_logging(debug_mode) {
Ok(guard) => guard,
Err(e) => {
wsb::output::failed(&format!("Failed to initialize logging: {e}"), "logging");
exit(wsb::output::finish(1));
}
};
log_version_info(env!("CARGO_PKG_VERSION"), option_env!("GIT_HASH"));
if let Err(e) = run(&argv) {
error!(error = %e, "Application error");
wsb::output::failed(&format!("{}: {:#}", "Error".red(), e), "command");
exit(wsb::output::finish(1));
}
info!("Worktree tool completed successfully");
let _ = wsb::output::finish(0);
}
fn parse_arguments(argv: &[String]) -> Args {
match Args::try_parse_from(argv) {
Ok(args) => args,
Err(error) => {
let text = error.render().to_string();
if matches!(
error.kind(),
clap::error::ErrorKind::DisplayHelp
| clap::error::ErrorKind::DisplayVersion
| clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
) {
wsb::outln!("{text}");
exit(wsb::output::finish(0));
}
wsb::output::failed(&text, "arguments");
exit(wsb::output::finish(2));
}
}
}
fn writes_the_tree(command: &Commands) -> Option<String> {
let named = match command {
Commands::Local { .. } => "local",
Commands::Checkpoint { .. } => "checkpoint",
Commands::Update { .. } => "update",
Commands::Commit { .. } => "commit",
Commands::Wstemplate { .. } => "wstemplate",
Commands::Discard { .. } => "discard",
Commands::Scrap { .. } => "scrap",
_ => return None,
};
Some(named.to_string())
}
fn take_flag(argv: &mut Vec<String>, flag: &str) -> bool {
let before = argv.len();
argv.retain(|arg| arg != flag);
before != argv.len()
}
fn take_no_daemon_flag(argv: &mut Vec<String>) -> bool {
let before = argv.len();
argv.retain(|arg| arg != "--no-daemon");
before != argv.len()
}
fn panic_message(info: &std::panic::PanicHookInfo<'_>) -> String {
let payload = info.payload();
if let Some(text) = payload.downcast_ref::<&str>() {
return (*text).to_string();
}
if let Some(text) = payload.downcast_ref::<String>() {
return text.clone();
}
"a panic with no message".to_string()
}
fn run(argv: &[String]) -> Result<()> {
let start_time = Instant::now();
let mut args = parse_arguments(argv);
if args.jsonl {
bail!("--jsonl reached the argument parser: the scan in main must run first");
}
if args.no_daemon {
bail!("--no-daemon reached the argument parser: the scan in main must run first");
}
if args.ignore_worktree_lock {
bail!("--ignore-workspace-lock reached the argument parser: the scan in main must run first");
}
let no_daemon = wsb::daemon::disabled();
let command = args.command.take().unwrap_or(Commands::Serve {
port: None,
listen: None,
debug: false,
worktree: None,
parent_pid: None,
});
let cwd = std::env::current_dir().context("Failed to get current directory")?;
let mut _writing = None;
if let Some(operation) = writes_the_tree(&command) {
if let Ok(root) = roots::require_repo_root(&cwd) {
let root = roots::find_super_root(&root).unwrap_or(root);
_writing = wsb::worktree_lock::begin_writing(&root, &operation).map_err(|e| anyhow::anyhow!(e))?;
}
}
if let Commands::Init { name } = command {
setup_new_project(name, &cwd)?;
return init_nested_repos(&cwd);
}
if let Commands::Check { no_network, quiet } = command {
return run_check_command(&cwd, no_network, quiet);
}
if let Commands::Migrate = command {
return run_migrate_command(&cwd);
}
if let Commands::Note { action } = command {
return run_note_command(action, &cwd);
}
if let Commands::Link { action } = command {
return run_link_command(action, &cwd);
}
if let Commands::Machine { action: MachineAction::Name { name } } = command {
exit(wsb::output::finish(i32::from(wsb::cli::machine::name(name.as_deref()))));
}
if let Commands::Serve {
port,
listen,
debug,
worktree: serve_worktree,
parent_pid,
} = command
{
let worktree_root = validate_server_worktree(serve_worktree.map(PathBuf::from))?;
return run_serve_command(&worktree_root, port, listen, debug, parent_pid, no_daemon);
}
if let Commands::Daemon { action } = command {
return run_daemon_command(action);
}
if let Commands::Completions { shell } = command {
return install_shell_completions(shell);
}
if let Commands::Github { args } = command {
let code = wsb_github::cli::run_through(args, |stream, line| match stream {
wsb_github::output::Stream::Out => wsb::outln!("{line}"),
wsb_github::output::Stream::Err => wsb::errln!("{line}"),
});
exit(wsb::output::finish(i32::from(code)));
}
if let Commands::Worktree { args } = command {
let argv: Vec<String> = ["wsb".to_string(), "worktree".to_string()].into_iter().chain(args).collect();
exit(wsb::output::finish(i32::from(wsb::cli::worktree::dispatch(&argv))));
}
if let Commands::Upgrade { args } = command {
exit(wsb::output::finish(i32::from(wsb::cli::upgrade::dispatch(&args))));
}
attach_to_the_daemon(&cwd, no_daemon)?;
if let Commands::Peer { args } = command {
let argv: Vec<String> = ["wsb".to_string(), "peer".to_string()].into_iter().chain(args).collect();
exit(wsb::output::finish(i32::from(wsb::cli::peer::dispatch(&argv))));
}
if let Commands::Plugin { args } = command {
let argv: Vec<String> = ["wsb".to_string(), "plugin".to_string()].into_iter().chain(args).collect();
exit(wsb::output::finish(i32::from(wsb::cli::plugin::dispatch(&argv))));
}
let project_root = roots::require_repo_root(&cwd)?;
let ctx = AppContext::bootstrap(&project_root)?;
match command {
Commands::Refactor { args } => {
log_operation_start("refactor", &format!("root: {:?}", args.root_dir));
match wsb::run_refactor(args) {
Ok(()) => log_operation_complete("refactor", start_time.elapsed()),
Err(error) => {
log_operation_error("refactor", &error);
wsb::output::failed(&format!("{}: {:#}", "Error".red(), error), "refactor");
exit(wsb::output::finish(1));
}
}
}
Commands::Discard {
untracked,
yes,
dry_run,
} => {
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::Discard { untracked, yes },
false,
dry_run,
)?;
}
Commands::Status { verbose } => {
run_fleet_or_repo_command(wsb::fleet::cli::Commands::Status, verbose, false)?;
}
Commands::Checkout {
branch,
create,
ignore_missing,
verbose,
dry_run,
} => {
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::Checkout {
branch,
create,
ignore_missing,
},
verbose,
dry_run,
)?;
}
Commands::Pull {
rebase,
autostash,
overwrite_local,
verbose,
dry_run,
} => {
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::Pull {
rebase,
autostash,
overwrite_local,
},
verbose,
dry_run,
)?;
}
Commands::Push {
force_with_lease,
force_all,
verbose,
dry_run,
} => {
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::Push {
force_with_lease,
force_all,
},
verbose,
dry_run,
)?;
}
Commands::Create {
branch,
from,
push,
force_all,
verbose,
dry_run,
} => {
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::Create {
branch,
from,
push,
force_all,
},
verbose,
dry_run,
)?;
}
Commands::Commit {
message,
push,
stage_all,
exclude,
include,
force_all,
verbose,
dry_run,
} => {
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::Commit {
message,
push,
stage_all,
exclude,
include,
force_all,
},
verbose,
dry_run,
)?;
}
Commands::Sync {
init,
recursive,
verbose,
dry_run,
} => {
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::Sync { init, recursive },
verbose,
dry_run,
)?;
}
Commands::Delete {
branch,
remote,
force,
verbose,
dry_run,
} => {
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::Delete {
branch,
remote,
force,
},
verbose,
dry_run,
)?;
}
Commands::Fetch {
all,
force_all,
verbose,
dry_run,
} => {
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::Fetch { all, force_all },
verbose,
dry_run,
)?;
}
Commands::Branch { all, verbose } => {
run_fleet_or_repo_command(wsb::fleet::cli::Commands::Branch { all }, verbose, false)?;
}
Commands::SubmoduleInit {
recursive,
verbose,
dry_run,
} => {
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::Init { recursive },
verbose,
dry_run,
)?;
}
Commands::Diff {
base,
since,
output,
staged,
clean,
verbose,
} => {
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::Diff {
base,
since,
output,
staged,
clean,
},
verbose,
false,
)?;
}
Commands::Stage { verbose, dry_run } => {
run_fleet_or_repo_command(wsb::fleet::cli::Commands::Stage, verbose, dry_run)?;
}
Commands::MergeBack {
message,
verbose,
dry_run,
} => {
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::MergeBack { message },
verbose,
dry_run,
)?;
}
Commands::Completions { .. } => {
unreachable!("Completions is handled before the worktree guard")
}
Commands::Local { action } => {
run_local_command(action, &ctx.repo_root, start_time)?;
}
Commands::Checkpoint {
message,
no_push,
dry_run,
} => {
log_operation_start(
"checkpoint",
&format!("no_push: {no_push}, dry_run: {dry_run}"),
);
run_checkpoint_command(&message, no_push, dry_run, &ctx.repo_root)?;
log_operation_complete("checkpoint", start_time.elapsed());
}
Commands::Update {
no_git,
git_add,
version_suffix,
tag,
quiet,
recursive,
verify,
discard,
dry_run: discard_dry_run,
} => {
if discard {
log_operation_start(
"update discard",
&format!("dry_run: {discard_dry_run}, recursive: {recursive}"),
);
if recursive {
discard_recursive(&ctx.repo_root, discard_dry_run)?;
} else {
run_update_discard(&ctx.repo_root, discard_dry_run, &RunCache::new())?;
}
log_operation_complete("update discard", start_time.elapsed());
return Ok(());
}
refuse_update_in_local_mode(&ctx.repo_root)?;
log_operation_start(
"update",
&format!(
"no_git: {}, git_add: {}, version_suffix: {:?}, tag: {:?}, recursive: {}",
no_git, git_add, version_suffix, tag, recursive
),
);
if recursive {
update_recursive(
no_git,
git_add,
version_suffix.as_deref(),
tag.as_deref(),
&ctx.repo_root,
quiet,
verify,
)?;
} else {
update_state(
no_git,
git_add,
version_suffix.as_deref(),
tag.as_deref(),
&ctx.repo_root,
quiet,
&RunCache::new(),
verify,
)?;
}
log_operation_complete("update", start_time.elapsed());
}
Commands::Scrap { paths, command } => {
run_scrap_command(paths, command)?;
}
Commands::Unscrap { name, force, to } => {
run_unscrap_command(name, force, to)?;
}
Commands::Ldiff { substitute_char } => {
run_ldiff_command(substitute_char)?;
}
Commands::Compact {
input,
output,
token_mode,
} => {
run_compact_command(input, output, token_mode)?;
}
Commands::AuditPath => {
run_audit_path_command()?;
}
Commands::Ignore { line } => {
run_ignore_command(&line)?;
}
Commands::LastHash { file } => {
run_last_hash_command(&file)?;
}
Commands::Code { action } => {
let action = action.unwrap_or(CodeAction::Tree {
depth: 3,
hidden: false,
sizes: false,
extensions: None,
no_ignore: false,
});
handle_code_command(action, &ctx.repo_root)?;
}
Commands::Test {
dry_run,
install,
args,
} => {
handle_test_command(dry_run, install, args, &ctx.repo_root)?;
}
Commands::Github { .. } => unreachable!("Github is handled before this match"),
Commands::Note { .. } => unreachable!("Note is handled before this match"),
Commands::Link { .. } => unreachable!("Link is handled before this match"),
Commands::Migrate => unreachable!("Migrate is handled before this match"),
Commands::Machine { .. } => unreachable!("Machine is handled before this match"),
Commands::Peer { .. } => unreachable!("Peer is handled before this match"),
Commands::Plugin { .. } => unreachable!("Plugin is handled before this match"),
Commands::Worktree { .. } => unreachable!("Worktree is handled before this match"),
Commands::Upgrade { .. } => unreachable!("Upgrade is handled before this match"),
Commands::Serve { .. } => unreachable!("Serve is handled before this match"),
Commands::Daemon { .. } => {
unreachable!("Daemon is handled before this match, so it never starts one")
}
Commands::Version { action } => {
run_version_command(action, &ctx)?;
}
Commands::Wstemplate { action } => {
handle_wstemplate_command(action, &ctx.repo_root)?;
}
Commands::Each { file, quiet } => {
run_each_command(&ctx.repo_root, file, quiet)?;
}
Commands::Catalog { action } => run_catalog_command(action, &ctx)?,
Commands::Init { .. } | Commands::Check { .. } => unreachable!(),
}
Ok(())
}
fn run_fleet_or_repo_command(
command: wsb::fleet::cli::Commands,
verbose: bool,
dry_run: bool,
) -> Result<()> {
use wsb::fleet::output::Formatter;
let cwd = std::env::current_dir().context("Failed to get current directory")?;
let tree = wsb::fleet::git::SubmoduleTree::discover_allow_empty(&cwd)
.map_err(|error| anyhow::anyhow!("{}", error))?;
for pattern in tree.unmatched_ignore_patterns() {
Formatter::print_warning(&format!(
"ignored_submodules pattern '{}' matched no submodule",
pattern
));
}
if tree.is_empty() {
if !tree.ignored().is_empty() {
Formatter::print_info(&format!(
"All {} submodule(s) are ignored; operating on this repository only",
tree.ignored().len()
));
}
execute_repo_git_command(command, verbose, dry_run)?;
} else {
wsb::fleet::execute(command, verbose, dry_run)?;
}
Ok(())
}
fn execute_repo_git_command(
command: wsb::fleet::cli::Commands,
_verbose: bool,
dry_run: bool,
) -> Result<()> {
use wsb::fleet::cli::Commands;
use wsb::fleet::git::{GitOps, SubmoduleStatus};
use wsb::fleet::output::Formatter;
let cwd = std::env::current_dir().context("Failed to get current directory")?;
let repo_root = wsb::git_backend::repo_root(&cwd)?;
let repo = wsb::git_backend::open_repo(&repo_root)?;
match command {
Commands::Status => {
let is_detached =
GitOps::is_detached_head(&repo).map_err(|e| anyhow::anyhow!("{}", e))?;
let current_branch = if is_detached {
None
} else {
Some(GitOps::current_branch(&repo).map_err(|e| anyhow::anyhow!("{}", e))?)
};
let rendered = wsb::state::discard::rendered_as_written(
&wsb::roots::find_super_root(&repo_root).unwrap_or_else(|| repo_root.clone()),
&wsb::state::RunCache::new(),
)?;
let (changed, moved_pointers) = GitOps::uncommitted_files_and_pointers(&repo)
.map_err(|e| anyhow::anyhow!("{}", e))?;
let (uncommitted_files, rendered_files) =
wsb::fleet::git::split_rendered(&repo_root, changed, &rendered);
let is_clean = uncommitted_files.is_empty();
let (ahead, behind) = if is_detached {
(0, 0)
} else {
GitOps::get_ahead_behind(&repo).unwrap_or((0, 0))
};
let repo_name = repo_root
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("[REPOSITORY]")
.to_string();
Formatter::print_status(&[SubmoduleStatus {
name: repo_name,
path: ".".to_string(),
current_branch,
is_clean,
is_detached,
ahead,
behind,
uncommitted_files,
rendered_files,
moved_pointers,
exists: true,
depth: 0,
}]);
}
Commands::Discard { untracked, yes } => {
wsb::fleet::commands::discard::execute(
&repo_root,
wsb::fleet::commands::discard::Options {
untracked,
assume_yes: yes,
dry_run,
},
)
.map_err(|e| anyhow::anyhow!("{}", e))?;
}
Commands::Checkout {
branch,
create,
ignore_missing,
} => {
let local_branch_exists = wsb::git_backend::branch_exists(
&repo,
&branch,
wsb::git_backend::BranchKind::Local,
)?;
let remote_branch_exists = if !local_branch_exists && !create {
wsb::git_backend::branch_exists(
&repo,
&format!("origin/{branch}"),
wsb::git_backend::BranchKind::Remote,
)?
} else {
false
};
let remote_branch_advertised =
if !local_branch_exists && !remote_branch_exists && !create {
wsb::git_backend::remote_branch_head(&repo, "origin", &branch)?.is_some()
} else {
false
};
let branch_available =
local_branch_exists || remote_branch_exists || remote_branch_advertised;
if !branch_available && !create && ignore_missing {
Formatter::print_info(&format!(
"Branch '{}' does not exist; skipping checkout",
branch
));
return Ok(());
}
if !branch_available && !create {
anyhow::bail!(
"Branch '{}' does not exist. Use --create to create it.",
branch
);
}
if dry_run {
Formatter::print_warning("DRY RUN: No changes will be made");
if local_branch_exists {
Formatter::print_info(&format!("Would checkout '{}'", branch));
} else if remote_branch_exists {
Formatter::print_info(&format!("Would checkout '{}' from remote", branch));
} else if remote_branch_advertised {
Formatter::print_info(&format!(
"Would fetch and checkout '{}' from remote",
branch
));
} else if create {
Formatter::print_info(&format!("Would create and checkout '{}'", branch));
} else {
Formatter::print_info(&format!("Would checkout '{}'", branch));
}
return Ok(());
}
if create && !local_branch_exists {
wsb::git_backend::create_branch(&repo, &branch, None)?;
} else {
if remote_branch_advertised {
wsb::git_backend::fetch_remote_branch(&repo, "origin", &branch)?;
}
wsb::git_backend::checkout_branch(&repo, &branch)?;
}
Formatter::print_success(&format!("Checked out '{}'", branch));
}
Commands::Pull {
rebase,
autostash,
overwrite_local,
} => {
let options = wsb::git_backend::PullOptions {
rebase,
autostash,
overwrite_local,
};
if dry_run {
Formatter::print_warning("DRY RUN: No changes will be made");
let state = wsb::git_backend::remote_state(&repo, Some("origin"))?;
Formatter::print_info(&format!("Branch is {}", state.describe()));
return Ok(());
}
let label = wsb::git_backend::current_branch(&repo)?;
match wsb::fleet::commands::pull::pull_one(&repo, &label, options) {
Ok(wsb::fleet::commands::pull::Verdict::Changed(message)) => {
Formatter::print_success(&message);
}
Ok(wsb::fleet::commands::pull::Verdict::Unchanged(message)) => {
Formatter::print_info(&message);
}
Ok(wsb::fleet::commands::pull::Verdict::Blocked(message))
| Err(message) => return Err(anyhow::anyhow!(message)),
}
}
Commands::Push {
force_with_lease,
force_all,
} => {
if dry_run {
Formatter::print_warning("DRY RUN: No changes will be made");
let state = wsb::git_backend::remote_state(&repo, Some("origin"))?;
if force_all || state.has_outgoing() {
Formatter::print_info("Would push current branch");
} else {
Formatter::print_info(&format!("Branch is {}; would skip push", state.describe()));
}
return Ok(());
}
let state = wsb::git_backend::remote_state(&repo, Some("origin"))?;
if let wsb::git_backend::RemoteState::Diverged { incoming, outgoing } = state {
return Err(anyhow::anyhow!(
"the branch has diverged: {outgoing} local commit(s) and {incoming} \
incoming — pull (or `wsb pull --rebase`) before pushing"
));
}
if !force_all && !state.has_outgoing() {
Formatter::print_info(&format!("Branch is {}; skipping push", state.describe()));
return Ok(());
}
match wsb::git_backend::push_current_branch(
&repo,
Some("origin"),
true,
force_with_lease,
)? {
wsb::git_backend::PushOutcome::Updated => {
Formatter::print_success("Pushed current branch");
}
wsb::git_backend::PushOutcome::UpToDate => {
Formatter::print_info("Current branch is already up to date");
}
}
}
Commands::Create {
branch,
from,
push,
force_all,
} => {
if dry_run {
Formatter::print_warning("DRY RUN: No changes will be made");
if force_all || !wsb::git_backend::is_clean(&repo)? {
Formatter::print_info(&format!(
"Would create branch '{}'{}",
branch,
from.as_deref()
.map(|base| format!(" from '{}'", base))
.unwrap_or_default()
));
} else {
Formatter::print_info("No worktree changes; would skip branch creation");
}
return Ok(());
}
if !force_all && wsb::git_backend::is_clean(&repo)? {
Formatter::print_info("No worktree changes; skipping branch creation");
return Ok(());
}
wsb::git_backend::create_branch(&repo, &branch, from.as_deref())?;
Formatter::print_success(&format!("Created branch '{}'", branch));
if push {
match wsb::git_backend::push_current_branch(&repo, Some("origin"), true, false)? {
wsb::git_backend::PushOutcome::Updated => {
Formatter::print_success("Pushed new branch");
}
wsb::git_backend::PushOutcome::UpToDate => {
Formatter::print_info("New branch is already up to date");
}
}
}
}
Commands::Commit {
message,
push,
stage_all,
force_all,
..
} => {
if dry_run {
Formatter::print_warning("DRY RUN: No changes will be made");
if force_all || !wsb::git_backend::is_clean(&repo)? {
if stage_all {
Formatter::print_info("Would stage all changes (git add -A)");
}
Formatter::print_info(&format!(
"Would commit changes with message '{}'",
message
));
} else {
Formatter::print_info("No worktree changes; would skip commit");
}
return Ok(());
}
if !force_all && wsb::git_backend::is_clean(&repo)? {
Formatter::print_info("No worktree changes; skipping commit");
return Ok(());
}
if stage_all {
wsb::git_backend::stage_all(&repo)?;
}
match wsb::git_backend::commit_all(&repo, &message)? {
Some(sha) => Formatter::print_success(&format!("Committed changes: {}", sha)),
None => Formatter::print_info("Nothing to commit"),
}
if push {
match wsb::git_backend::push_current_branch(&repo, Some("origin"), true, false)? {
wsb::git_backend::PushOutcome::Updated => {
Formatter::print_success("Pushed commit")
}
wsb::git_backend::PushOutcome::UpToDate => {
Formatter::print_info("Current branch is already up to date")
}
}
}
}
Commands::Sync { init, recursive } => {
if init || recursive {
anyhow::bail!(
"'wsb sync --init/--recursive' requires a fleet root with a .gitmodules file"
);
}
if dry_run {
Formatter::print_warning("DRY RUN: No changes will be made");
Formatter::print_info("Would fetch and pull current branch");
return Ok(());
}
let label = wsb::git_backend::current_branch(&repo)?;
match wsb::fleet::commands::pull::pull_one(
&repo,
&label,
wsb::git_backend::PullOptions::default(),
) {
Ok(wsb::fleet::commands::pull::Verdict::Changed(message)) => {
Formatter::print_success(&message);
}
Ok(wsb::fleet::commands::pull::Verdict::Unchanged(message)) => {
Formatter::print_info(&message);
}
Ok(wsb::fleet::commands::pull::Verdict::Blocked(message))
| Err(message) => return Err(anyhow::anyhow!(message)),
}
}
Commands::Delete {
branch,
remote,
force,
} => {
if dry_run {
Formatter::print_warning("DRY RUN: No changes will be made");
Formatter::print_info(&format!("Would delete branch '{}'", branch));
if remote {
Formatter::print_info(&format!("Would delete remote branch '{}'", branch));
}
return Ok(());
}
wsb::git_backend::delete_local_branch(&repo, &branch, force)?;
Formatter::print_success(&format!("Deleted branch '{}'", branch));
if remote {
wsb::git_backend::delete_remote_branch(&repo, Some("origin"), &branch)?;
Formatter::print_success(&format!("Deleted remote branch '{}'", branch));
}
}
Commands::Fetch { all, force_all } => {
if dry_run {
Formatter::print_warning("DRY RUN: No changes will be made");
if force_all || wsb::git_backend::has_fetchable_updates(&repo, all)? {
Formatter::print_info("Would fetch remote updates");
} else {
Formatter::print_info("No fetchable remote updates; would skip fetch");
}
return Ok(());
}
if !force_all && !wsb::git_backend::has_fetchable_updates(&repo, all)? {
Formatter::print_info("No fetchable remote updates; skipping fetch");
return Ok(());
}
if all {
wsb::git_backend::fetch_all(&repo)?;
} else {
wsb::git_backend::fetch(&repo, Some("origin"))?;
}
Formatter::print_success("Fetched remote updates");
}
Commands::Branch { all } => {
let current = wsb::git_backend::current_branch(&repo).ok();
let branches = if all {
let mut local = wsb::git_backend::local_branches(&repo)?;
local.extend(wsb::git_backend::remote_branches(&repo)?);
local
} else {
wsb::git_backend::local_branches(&repo)?
};
Formatter::print_branches("[REPOSITORY]", &branches, current.as_deref());
}
Commands::Init { recursive: _ } => {
anyhow::bail!("'wsb submodule-init' requires a fleet root with a .gitmodules file");
}
Commands::Diff {
base,
since,
output,
staged,
clean,
} => {
let outdir = std::path::Path::new(&output);
if clean && outdir.exists() {
std::fs::remove_dir_all(outdir).map_err(|e| {
anyhow::anyhow!("Failed to remove '{}': {}", outdir.display(), e)
})?;
}
let files = wsb::fleet::commands::diff::generate_per_file_diffs(
&repo_root,
base.as_deref(),
since.as_deref(),
outdir,
staged,
)
.map_err(|e| anyhow::anyhow!("{}", e))?;
if files.is_empty() {
Formatter::print_info("No changes to diff");
} else {
Formatter::print_success(&format!(
"{} diff file(s) written to '{}'",
files.len(),
output
));
for file in &files {
wsb::outln!(" {}.diff", file);
}
}
}
Commands::Stage => {
if dry_run {
Formatter::print_warning("DRY RUN: No changes will be made");
Formatter::print_info("Would stage all changes");
return Ok(());
}
wsb::git_backend::stage_all(&repo)?;
Formatter::print_success("Staged all changes");
}
Commands::MergeBack { message } => {
if dry_run {
Formatter::print_warning("DRY RUN: No changes will be made");
Formatter::print_info("Would merge current branch back into the default branch");
return Ok(());
}
let current_branch = wsb::git_backend::current_branch(&repo)?;
let default_branch = wsb::git_backend::get_default_branch(&repo)?;
if current_branch == default_branch {
anyhow::bail!("already on '{}'", default_branch);
}
if !wsb::git_backend::is_clean(&repo)? {
wsb::git_backend::stage_all(&repo)?;
}
let _ = wsb::git_backend::commit_all(&repo, &message)?;
let _ = wsb::git_backend::push_current_branch(&repo, Some("origin"), true, false)?;
wsb::git_backend::checkout_branch(&repo, &default_branch)?;
wsb::git_backend::merge_ff(&repo, ¤t_branch)?;
let _ = wsb::git_backend::push_current_branch(&repo, Some("origin"), false, false)?;
Formatter::print_success("Merge-back complete");
}
Commands::TestCatalog {
output,
git_add,
quiet,
} => {
wsb::fleet::commands::catalogue::execute(
&repo_root,
output.as_deref(),
git_add,
quiet,
)
.map_err(|e| anyhow::anyhow!("{}", e))?;
}
}
Ok(())
}
fn make_spinner(quiet: bool, msg: &'static str) -> indicatif::ProgressBar {
if quiet || wsb::output::is_jsonl() {
indicatif::ProgressBar::hidden()
} else {
let pb = indicatif::ProgressBar::new_spinner();
pb.set_style(
indicatif::ProgressStyle::with_template("{spinner:.cyan} {msg}")
.unwrap()
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
);
pb.set_message(msg);
pb.enable_steady_tick(std::time::Duration::from_millis(80));
pb
}
}
fn make_progress_bar(quiet: bool, total: u64) -> indicatif::ProgressBar {
if quiet || wsb::output::is_jsonl() {
indicatif::ProgressBar::hidden()
} else {
let pb = indicatif::ProgressBar::new(total);
pb.set_style(
indicatif::ProgressStyle::with_template("{spinner:.cyan} [{pos}/{len}] {wide_msg}")
.unwrap()
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
);
pb.enable_steady_tick(std::time::Duration::from_millis(80));
pb
}
}
fn local_rendering(project_root: &Path) -> Result<wsb::state::stamp_config::LocalRendering> {
wsb::state::stamp_config::StampConfig::local(&worktree_root_of(project_root))
}
fn declared_local_rendering(
project_root: &Path,
) -> Result<Option<wsb::state::stamp_config::LocalRendering>> {
Ok(wsb::state::stamp_config::StampConfig::load(&worktree_root_of(project_root))?
.and_then(|config| config.local))
}
fn worktree_root_of(project_root: &Path) -> PathBuf {
wsb::roots::find_super_root(project_root).unwrap_or_else(|| project_root.to_path_buf())
}
fn run_declared(argv: &[String], root: &Path, what: &str) -> Result<()> {
let (program, arguments) = argv
.split_first()
.ok_or_else(|| anyhow::anyhow!("the declared {what} command is empty"))?;
let status = wsb::git_backend::program_without_repo_scope(program)
.args(arguments)
.current_dir(root)
.status()
.with_context(|| format!("cannot run the declared {what} command `{}`", argv.join(" ")))?;
if !status.success() {
bail!("the declared {what} command `{}` failed ({status})", argv.join(" "));
}
Ok(())
}
fn run_local_command(action: LocalAction, project_root: &Path, start_time: Instant) -> Result<()> {
let local = local_rendering(project_root)?;
std::env::set_var(&local.variable, "1");
match action {
LocalAction::On {
quiet,
git_add,
verify,
} => {
log_operation_start("local on", &format!("git_add: {git_add}, verify: {verify}"));
render_local(project_root, &local, git_add, quiet, verify)?;
if !quiet {
wsb::outln!(
"Manifests point at the working copies here. \
Run 'wsb local off' before committing."
);
}
log_operation_complete("local on", start_time.elapsed());
}
LocalAction::Off { dry_run } => {
log_operation_start("local off", &format!("dry_run: {dry_run}"));
discard_recursive(project_root, dry_run)?;
ensure_not_local(project_root, &local.variable, dry_run)?;
if !dry_run {
release_what_the_local_render_named(project_root, &local)?;
}
log_operation_complete("local off", start_time.elapsed());
}
LocalAction::Status { files } => {
log_operation_start("local status", &format!("files: {files}"));
report_local_status(project_root, &local.variable, files)?;
log_operation_complete("local status", start_time.elapsed());
}
}
Ok(())
}
fn render_local(
project_root: &Path,
local: &wsb::state::stamp_config::LocalRendering,
git_add: bool,
quiet: bool,
verify: bool,
) -> Result<()> {
update_recursive(false, git_add, None, None, project_root, quiet, verify)?;
if let Err(built) = build_what_the_local_render_names(project_root, local) {
return match discard_recursive(project_root, false) {
Ok(()) => Err(anyhow::anyhow!("{built:#}\n The render was taken back: the tree is not local.")),
Err(discard) => Err(anyhow::anyhow!(
"{built:#}\n and taking the render back failed: {discard:#}\n Finish it with: wsb local off"
)),
};
}
Ok(())
}
fn build_what_the_local_render_names(
project_root: &Path,
local: &wsb::state::stamp_config::LocalRendering,
) -> Result<()> {
let root = worktree_root_of(project_root);
if !wsb::state::local_render::local_render_reads_build_root(&root, &local.variable)
.map_err(|e| anyhow::anyhow!("{e}"))?
{
return Ok(());
}
match &local.prepare {
Some(argv) => run_declared(argv, &root, "local prepare"),
None => Ok(()),
}
}
fn release_what_the_local_render_named(
project_root: &Path,
local: &wsb::state::stamp_config::LocalRendering,
) -> Result<()> {
let root = worktree_root_of(project_root);
if !wsb::state::local_render::local_render_reads_build_root(&root, &local.variable)
.map_err(|e| anyhow::anyhow!("{e}"))?
{
return Ok(());
}
match &local.release {
Some(argv) => run_declared(argv, &root, "local release"),
None => Ok(()),
}
}
enum LocalState {
Local,
Committed,
Indifferent,
Edited,
Absent,
}
fn report_local_status(project_root: &Path, variable: &str, list_files: bool) -> Result<()> {
use wsb::fleet::git::tree::SubmoduleTree;
let cache = RunCache::new();
let tree = SubmoduleTree::discover_allow_empty(project_root)
.with_context(|| format!("discovering submodules under {}", project_root.display()))?;
let mut repositories: Vec<PathBuf> = tree
.bottom_up()
.iter()
.map(|node| node.abs_path.clone())
.collect();
repositories.push(project_root.to_path_buf());
let boundaries: Vec<PathBuf> = tree
.top_down()
.iter()
.map(|node| node.abs_path.clone())
.collect();
let mut order: Vec<PathBuf> = Vec::new();
let mut landing: HashMap<PathBuf, String> = HashMap::new();
for repository in &repositories {
if !owns_templates(repository, &boundaries, &cache)? {
continue;
}
let planned = wsb::state::discard::planned_outputs_with_cache(repository, &cache)
.with_context(|| format!("reading {}", repository.display()))?;
for (path, rendered) in planned {
if landing.insert(path.clone(), rendered).is_none() {
order.push(path);
}
}
}
let (mut local, mut committed, mut edited, mut absent, mut indifferent) =
(0usize, 0usize, 0usize, 0usize, 0usize);
let mut unsettled: Vec<String> = Vec::new();
for path in &order {
let rendered = &landing[path];
let shown = path
.strip_prefix(project_root)
.unwrap_or(path.as_path())
.display()
.to_string();
let worktree = match std::fs::read_to_string(path) {
Ok(text) => Some(text),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => {
wsb::errln!(" unreadable {shown} — {error}");
continue;
}
};
let head = wsb::state::discard::head_text(path);
let indistinguishable = head.as_deref() == Some(rendered.as_str());
let state = match worktree {
None => LocalState::Absent,
Some(_) if indistinguishable => LocalState::Indifferent,
Some(worktree) if &worktree == rendered => LocalState::Local,
Some(worktree) if head.as_deref() == Some(worktree.as_str()) => LocalState::Committed,
Some(_) => LocalState::Edited,
};
let label = match state {
LocalState::Local => {
local += 1;
"local"
}
LocalState::Committed => {
committed += 1;
"as committed"
}
LocalState::Indifferent => {
indifferent += 1;
"same either way"
}
LocalState::Absent => {
absent += 1;
unsettled.push(format!(" not written yet {shown}"));
"not written yet"
}
LocalState::Edited => {
edited += 1;
unsettled.push(format!(" neither {shown}"));
"neither"
}
};
if list_files {
wsb::outln!(" {:<16} {shown}", label);
}
}
let total = local + committed + edited + absent + indifferent;
if total == 0 {
wsb::outln!("No repository under here has anything a render writes.");
return Ok(());
}
let deciding = local + committed + edited + absent;
if deciding == 0 {
wsb::outln!(
"Nothing here tells the two states apart: all {total} generated file(s) render the \
same with and without {variable}."
);
return Ok(());
}
wsb::outln!(
"{local} of {deciding} file(s) that tell the two states apart carry the local render; \
{committed} stand as committed."
);
if indifferent > 0 {
wsb::outln!(" ({indifferent} more render the same either way.)");
}
for line in &unsettled {
wsb::outln!("{line}");
}
if edited + absent == 0 {
if local == 0 {
wsb::outln!(
"This tree is as committed. 'wsb local on' points it at the working copies."
);
} else if committed == 0 {
wsb::outln!("This tree is local. 'wsb local off' takes that back.");
}
}
Ok(())
}
fn refuse_update_in_local_mode(project_root: &Path) -> Result<()> {
let Some(local) = declared_local_rendering(project_root)? else {
return Ok(());
};
if std::env::var_os(&local.variable).is_some_and(|v| !v.is_empty()) {
return Ok(());
}
let found = wsb::state::local_render::scan_worktree(project_root, &local.variable)
.map_err(|e| anyhow::anyhow!("{e}"))?;
if found.is_empty() {
return Ok(());
}
Err(anyhow::anyhow!(
"this worktree is in local mode: {} file(s) are rendered against the working copies \
(first: {}). A plain update would render what it reaches against published tags and \
leave the rest local.\n \
Refresh the local render: wsb local on\n \
Leave local mode: wsb local off",
found.len(),
found[0].message()
))
}
fn ensure_not_local(project_root: &Path, variable: &str, dry_run: bool) -> Result<()> {
let root = wsb::roots::find_super_root(project_root)
.unwrap_or_else(|| project_root.to_path_buf());
let found =
wsb::state::local_render::scan_worktree(project_root, variable).map_err(|e| anyhow::anyhow!("{e}"))?;
if found.is_empty() {
return Ok(());
}
wsb::outln!(
"── {} file(s) are still local after the take-back ──",
found.len()
);
use wsb::state::local_render::Persistence;
let why: Vec<Persistence> = found
.iter()
.map(|one| wsb::state::local_render::persistence(&root, one, variable))
.collect();
for (one, state) in found.iter().zip(&why).take(5) {
wsb::outln!(" {} ({})", one.message(), state.describe());
}
if found.len() > 5 {
wsb::outln!(" … and {} more", found.len() - 5);
}
let committed = why.iter().filter(|s| **s == Persistence::Committed).count();
if committed > 0 {
wsb::outln!(
" {committed} of them are committed that way, so restoring what is committed \
restores it and every checkpoint re-pushes it. Those repositories need the \
fresh render COMMITTED, not just written."
);
}
wsb::outln!(" Rendering fresh against published tags instead.");
if dry_run {
wsb::outln!(" would re-render the fleet with {variable} unset");
return Ok(());
}
std::env::remove_var(variable);
update_recursive(false, false, None, None, &root, true, false)?;
let left = wsb::state::local_render::scan_worktree(project_root, variable)
.map_err(|e| anyhow::anyhow!("{e}"))?;
if !left.is_empty() {
bail!(
"{} file(s) still carry the local render after a fresh render:\n{}\n \
Something outside the templates is writing them, and committing this tree \
would publish a dependency that resolves on this machine only.",
left.len(),
left.iter()
.take(10)
.map(|one| format!(" {}", one.message()))
.collect::<Vec<_>>()
.join("\n")
);
}
wsb::outln!(" the tree is no longer local");
Ok(())
}
fn run_checkpoint_command(
message: &str,
no_push: bool,
dry_run: bool,
project_root: &Path,
) -> Result<()> {
let local = declared_local_rendering(project_root)?;
if let Some(local) = &local {
std::env::set_var(&local.variable, "1");
wsb::outln!("── taking the local render back ──");
discard_recursive(project_root, dry_run)?;
ensure_not_local(project_root, &local.variable, dry_run)?;
std::env::remove_var(&local.variable);
}
wsb::outln!("── bringing main in ──");
wsb::fleet::commands::pull::bring_main_in(project_root, dry_run).map_err(|e| anyhow::anyhow!(e))?;
wsb::outln!("── committing ──");
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::Commit {
message: message.to_string(),
push: false,
stage_all: true,
exclude: Vec::new(),
include: Vec::new(),
force_all: false,
},
false,
dry_run,
)?;
let pushed = if no_push {
wsb::outln!("── not pushing (--no-push) ──");
Ok(())
} else {
wsb::outln!("── pushing ──");
run_fleet_or_repo_command(
wsb::fleet::cli::Commands::Push {
force_with_lease: false,
force_all: false,
},
false,
dry_run,
)
};
if let Some(local) = &local {
wsb::outln!("── going local again ──");
std::env::set_var(&local.variable, "1");
if dry_run {
wsb::outln!(" would render the fleet with {} set", local.variable);
} else {
render_local(project_root, local, false, true, false)?;
}
wsb::outln!("Manifests point at the working copies here.");
}
pushed.map_err(|e| {
anyhow::anyhow!("{e:#}\n The commits are made; push again with: wsb push")
})
}
fn update_recursive(
no_git: bool,
git_add: bool,
version_suffix: Option<&str>,
tag: Option<&str>,
project_root: &Path,
quiet: bool,
verify: bool,
) -> Result<()> {
use wsb::fleet::git::tree::SubmoduleTree;
let cache = RunCache::new();
let tree = SubmoduleTree::discover_allow_empty(project_root)
.with_context(|| format!("discovering submodules under {}", project_root.display()))?;
let mut repositories: Vec<PathBuf> =
tree.bottom_up().iter().map(|node| node.abs_path.clone()).collect();
repositories.push(project_root.to_path_buf());
let boundaries: Vec<PathBuf> = tree
.top_down()
.iter()
.map(|node| node.abs_path.clone())
.collect();
let mut updated = 0usize;
let mut skipped: Vec<String> = Vec::new();
for repository in &repositories {
let label = match repository.strip_prefix(project_root) {
Ok(relative) if relative.as_os_str().is_empty() => ".".to_string(),
Ok(relative) => relative.display().to_string(),
Err(_) => repository.display().to_string(),
};
if !owns_templates(repository, &boundaries, &cache)? {
skipped.push(label);
continue;
}
if !quiet {
wsb::outln!("── {label} ──");
}
update_state(
no_git,
git_add,
version_suffix,
tag,
repository,
quiet,
&cache,
verify,
)
.with_context(|| format!("updating {label}"))?;
updated += 1;
}
if !quiet {
wsb::outln!("update: {updated} repository(ies) updated");
if !skipped.is_empty() {
wsb::outln!(
" no templates of their own, so untouched: {}",
skipped.join(" ")
);
}
}
Ok(())
}
fn discard_recursive(project_root: &Path, dry_run: bool) -> Result<()> {
use wsb::fleet::git::tree::SubmoduleTree;
let cache = RunCache::new();
let tree = SubmoduleTree::discover_allow_empty(project_root)
.with_context(|| format!("discovering submodules under {}", project_root.display()))?;
let mut repositories: Vec<PathBuf> = tree
.bottom_up()
.iter()
.map(|node| node.abs_path.clone())
.collect();
repositories.push(project_root.to_path_buf());
let boundaries: Vec<PathBuf> = tree
.top_down()
.iter()
.map(|node| node.abs_path.clone())
.collect();
let mut visited = 0usize;
for repository in &repositories {
if !owns_templates(repository, &boundaries, &cache)? {
continue;
}
let label = match repository.strip_prefix(project_root) {
Ok(relative) if relative.as_os_str().is_empty() => ".".to_string(),
Ok(relative) => relative.display().to_string(),
Err(_) => repository.display().to_string(),
};
wsb::outln!("── {label} ──");
run_update_discard(repository, dry_run, &cache)?;
visited += 1;
}
if visited == 0 {
wsb::outln!("No repository under here has anything an update writes.");
}
Ok(())
}
fn run_update_discard(project_root: &Path, dry_run: bool, cache: &Arc<RunCache>) -> Result<()> {
use wsb::state::discard::Outcome;
let report = wsb::state::discard::run_with_cache(project_root, dry_run, cache)?;
let verb = if dry_run { "would take back" } else { "took back" };
for file in &report.files {
let shown = file
.path
.strip_prefix(project_root)
.unwrap_or(&file.path)
.display();
match &file.outcome {
Outcome::Reverted => wsb::outln!(" {verb} {shown}"),
Outcome::Partial { kept } => wsb::outln!(
" {verb} {shown} — {kept} region(s) left alone, yours and the update's edits \
are on the same lines"
),
Outcome::Removed => wsb::outln!(" {verb} {shown} (created by the update, removed)"),
Outcome::Unresolvable { reason } => {
wsb::errln!(" refused {shown} — {reason}")
}
Outcome::Untouched => {}
}
}
let changed = report.changed();
let refused = report
.files
.iter()
.filter(|f| matches!(f.outcome, Outcome::Unresolvable { .. }))
.count();
let partial = report.partial().count();
if changed == 0 && refused == 0 {
wsb::outln!("Nothing to take back: no file carries changes from an update.");
return Ok(());
}
wsb::outln!(
"{} {changed} file(s) of {} the update writes.",
if dry_run { "Would change" } else { "Changed" },
report.files.len()
);
if partial > 0 {
wsb::outln!(
"{partial} file(s) still hold an update's edit on a line you also changed — those \
lines are yours and were not touched."
);
}
if refused > 0 {
wsb::errln!("{refused} file(s) were refused; nothing was written to them.");
}
Ok(())
}
fn update_state(
no_git: bool,
git_add: bool,
version_suffix: Option<&str>,
tag: Option<&str>,
project_root: &Path,
quiet: bool,
cache: &Arc<RunCache>,
verify: bool,
) -> Result<()> {
if !no_git && !is_git_repository_in(project_root) {
anyhow::bail!(
"{} is not a git repository. Run 'wsb init' there first, or use 'wsb update --no-git' for a non-git directory.",
project_root.display()
);
}
let pb = make_spinner(quiet, "loading configuration…");
let settings = RepoSettings::require(project_root)?;
if let Some(t) = tag {
wsb::state::validate_git_tag(t)?;
}
pb.set_message("calculating version…");
if git_add {
cache.commit_is_being_made_in(project_root);
}
let mut version_info = if no_git {
calculate_version_without_git(project_root, &settings)?
} else {
calculate_version(project_root, &settings, cache)?
};
if verify && !no_git {
let fresh = RunCache::new();
if git_add {
fresh.commit_is_being_made_in(project_root);
}
let alone = calculate_version(project_root, &settings, &fresh)?;
anyhow::ensure!(
alone.full_version == version_info.full_version,
"--verify: the run's cache calculated version {} for {}, a cache of its own {}",
version_info.full_version,
project_root.display(),
alone.full_version
);
}
version_info.full_version = settings.full_version(
&version_info.major_version,
version_info.minor_version,
version_info.patch_version,
tag.or(version_suffix),
);
pb.set_message(format!("writing {} → {}…", "version", &settings.version.file));
update_version_file_in_repo(&version_info, &settings.version, project_root)?;
info!("Updated version file: {}", settings.version.file);
if !quiet {
pb.println(format!(" {} updated {}", "✓".green(), settings.version.file));
}
let full_version = version_info.full_version.clone();
pb.set_message("rendering .wstemplate files…");
let mut rendered_wstemplate_files: Vec<String> = Vec::new();
{
let engine = WstemplateEngine::for_repo(&settings, version_info.clone(), project_root, Arc::clone(cache));
let produced = engine.produce_relevant()?;
if verify {
let alone = WstemplateEngine::for_repo(&settings, version_info, project_root, RunCache::new());
compare_renders(&produced, &alone.produce_relevant()?, project_root)?;
}
let rendered = engine.write_produced(produced)?;
for r in &rendered {
info!(
"wstemplate: {} -> {}",
r.source_path.display(),
r.output_path.display()
);
rendered_wstemplate_files.push(r.output_path.display().to_string());
if let Some(lock) = &r.lockfile {
if wsb::git_backend::is_tracked(lock)? {
rendered_wstemplate_files.push(lock.display().to_string());
}
}
}
if !rendered.is_empty() && !quiet {
pb.println(format!(
" {} rendered {} .wstemplate file(s)",
"✓".green(),
rendered.len()
));
}
}
if !no_git && git_add && is_git_repository_in(project_root) {
pb.set_message("staging files…");
let mut files_to_add = vec![settings.version.file.clone()];
for file in collect_project_files_for_git_add(project_root, &settings.version)? {
if !files_to_add.contains(&file) {
files_to_add.push(file);
}
}
for file in &rendered_wstemplate_files {
if !files_to_add.contains(file) {
files_to_add.push(file.clone());
}
}
if !files_to_add.is_empty() {
let added_files = add_files_to_git(&files_to_add, &project_root)?;
if !added_files.is_empty() {
info!(
"Added {} files to git staging area: {}",
added_files.len(),
added_files.join(", ")
);
if !quiet {
pb.println(format!(
" {} staged {} file(s)",
"✓".green(),
added_files.len()
));
for file in &added_files {
pb.println(format!(" {}", file));
}
}
}
}
}
if let Some(t) = tag {
if is_git_repository_in(project_root) {
pb.set_message(format!("creating tag {}…", t));
let tag_name = format!("v{}", full_version);
let tag_message = format!("Tagged version {}", full_version);
let repo = wsb::git_backend::discover_repo(project_root)?;
wsb::git_backend::create_tag(&repo, &tag_name, &tag_message)?;
cache.forget_repo(project_root);
info!("Created git tag: {} (tag: {})", tag_name, t);
if !quiet {
pb.println(format!(
" {} created tag {}",
"✓".green(),
tag_name.green().bold()
));
}
} else {
anyhow::bail!("--tag requires a git repository; {} is not one", project_root.display());
}
}
pb.finish_and_clear();
if !quiet {
wsb::outln!("{} {}", "✓".green().bold(), full_version.bold());
}
Ok(())
}
fn collect_project_files_for_git_add(
project_root: &Path,
config: &VersionSettings,
) -> Result<Vec<String>> {
let mut files = Vec::new();
if config.detect_manifests {
for project_file in detect_project_files(project_root)? {
let path = project_file.path.to_string_lossy().to_string();
if !files.contains(&path) {
files.push(path);
}
}
}
for configured_path in &config.manifests {
let full_path = project_root.join(configured_path);
if full_path.exists() {
let path = full_path.to_string_lossy().to_string();
if !files.contains(&path) {
files.push(path);
}
}
}
let mut locks = Vec::new();
for manifest in &files {
if let Some(lock) = wsb::state::wstemplate::lockfile_followed_by(Path::new(manifest)) {
let lock_path = lock.to_string_lossy().to_string();
if lock.is_file() && wsb::git_backend::is_tracked(&lock)? && !files.contains(&lock_path) && !locks.contains(&lock_path) {
locks.push(lock_path);
}
}
}
files.extend(locks);
Ok(files)
}
fn handle_wstemplate_command(action: WstemplateAction, project_root: &Path) -> Result<()> {
let mut settings = RepoSettings::require(project_root)?;
let scan_root = settings.templates_root_of(project_root);
match action {
WstemplateAction::Alias { alias } => {
wsb::settings::repo::check_alias(&alias)?;
settings.alias = alias;
settings.save(project_root)?;
wsb::outln!(
"{} is now known to templates as '{}' ({}; commit it)",
settings.name,
settings.alias,
RepoSettings::path(project_root).display()
);
}
WstemplateAction::Root { path } => {
let root = wsb::platform::canonicalize(&path)
.with_context(|| format!("Cannot resolve path: {}", path.display()))?;
let repo = wsb::platform::canonicalize(project_root)
.with_context(|| format!("Cannot resolve path: {}", project_root.display()))?;
settings.templates_root = wsb_prj::settings::relative_path(&root, &repo)?;
settings.save(project_root)?;
wsb::outln!(
"{}'s templates look under {} ({}; commit it)",
settings.name,
settings.templates_root,
RepoSettings::path(project_root).display()
);
}
WstemplateAction::Show => {
wsb::outln!("alias {}", settings.alias);
wsb::outln!("root {} ({})", settings.templates_root, scan_root.display());
}
WstemplateAction::List => {
let version_info = wsb::state::read_version_from_path(project_root)?;
let engine = WstemplateEngine::for_repo(&settings, version_info, project_root, RunCache::new());
let project_roots = engine.discover_project_roots()?;
let templates = engine.discover_relevant(&project_roots)?;
if templates.is_empty() {
wsb::outln!("No relevant .wstemplate files found.");
} else {
wsb::outln!("{} relevant .wstemplate files:", templates.len());
for path in &templates {
wsb::outln!(" {}", path.display());
}
}
}
WstemplateAction::Vars { alias, remote } => {
let engine = WstemplateEngine::for_repo(
&settings,
wsb::state::read_version_from_path(project_root)?,
project_root,
RunCache::new(),
);
let project_roots = engine.discover_project_roots()?;
let alias = alias.unwrap_or_else(|| settings.alias.clone());
let (root, version) = if alias == settings.alias {
(
project_root.to_path_buf(),
wsb::state::read_version_from_path(project_root)?,
)
} else {
let root = project_roots.get(&alias).cloned().ok_or_else(|| {
let mut known: Vec<&str> =
project_roots.keys().map(|key| key.as_str()).collect();
known.sort();
anyhow::anyhow!(
"Unknown alias '{}'. Known aliases: [{}]",
alias,
known.join(", ")
)
})?;
let version = wsb::state::read_version_from_path(&root)?;
(root, version)
};
let facts = wsb::state::project_facts::gather(
&wsb::state::project_facts::ProjectIdentity {
alias: &alias,
root: &root,
scan_root: &scan_root,
version: &version,
name: if alias == settings.alias {
Some(settings.name.as_str())
} else {
None
},
},
remote,
)?;
wsb::outln!(
"Template variables for {} ({}):",
alias.bold(),
root.display()
);
for field in wsb::state::project_facts::FIELDS {
let usage = format!("{{{{ projects.{}.{} }}}}", alias, field.name);
match facts.values().get(field.name) {
Some(value) => {
let text = match value {
serde_json::Value::String(text) => text.clone(),
other => other.to_string(),
};
wsb::outln!(" {:<44} {}", usage, text);
}
None => {
let reason = facts
.unavailable()
.get(field.name)
.map(String::as_str)
.unwrap_or("unavailable");
wsb::outln!(" {:<44} {}", usage, format!("— {reason}").dimmed());
}
}
}
wsb::outln!(
"\n{}",
"Also available: {{ datetime.iso | date | time | year | month | day | timestamp }}"
.dimmed()
);
wsb::outln!(
"{}",
"The same fields are available as {{ project.FIELD }} for the project owning a template."
.dimmed()
);
}
WstemplateAction::Render => {
let version_info = wsb::state::read_version_from_path(project_root)?;
let engine = WstemplateEngine::for_repo(&settings, version_info, project_root, RunCache::new());
let rendered = engine.render_relevant()?;
if rendered.is_empty() {
wsb::outln!("No templates to render.");
} else {
wsb::outln!("Rendered {} templates:", rendered.len());
for r in &rendered {
wsb::outln!(
" {} \u{2192} {}",
r.source_path.display(),
r.output_path.display()
);
}
}
}
}
Ok(())
}
fn run_scrap_command(paths: Vec<std::path::PathBuf>, command: Option<ScrapCommands>) -> Result<()> {
let mut args = Vec::new();
match command {
Some(ScrapCommands::List { sort }) => {
args.push("list".to_string());
args.push("--sort".to_string());
args.push(sort);
}
Some(ScrapCommands::Clean { days, dry_run }) => {
args.push("clean".to_string());
args.push("--days".to_string());
args.push(days.to_string());
if dry_run {
args.push("--dry-run".to_string());
}
}
Some(ScrapCommands::Purge { force }) => {
args.push("purge".to_string());
if force {
args.push("--force".to_string());
}
}
Some(ScrapCommands::Find { pattern, content }) => {
args.push("find".to_string());
args.push(pattern);
if content {
args.push("--content".to_string());
}
}
Some(ScrapCommands::Archive { output, remove }) => {
args.push("archive".to_string());
if let Some(output_path) = output {
args.push("--output".to_string());
args.push(output_path.to_string_lossy().to_string());
}
if remove {
args.push("--remove".to_string());
}
}
None => {
for path in paths {
args.push(path.to_string_lossy().to_string());
}
}
}
wsb::run_scrap(args)
}
fn run_unscrap_command(
name: Option<String>,
force: bool,
to: Option<std::path::PathBuf>,
) -> Result<()> {
let mut args = Vec::new();
if let Some(item_name) = name {
args.push(item_name);
}
if force {
args.push("--force".to_string());
}
if let Some(target_path) = to {
args.push("--to".to_string());
args.push(target_path.to_string_lossy().to_string());
}
wsb::run_unscrap(args)
}
fn run_ldiff_command(substitute_char: String) -> Result<()> {
let mut chars = substitute_char.chars();
let ch = chars
.next()
.ok_or_else(|| anyhow::anyhow!("Substitute character cannot be empty"))?;
if chars.next().is_some() {
anyhow::bail!(
"Substitute character must be exactly one Unicode character, got: '{}'",
substitute_char
);
}
wsb::ldiff::process_stdin(ch)
}
fn run_compact_command(
input: Option<String>,
output: Option<String>,
token_mode: bool,
) -> Result<()> {
let result = match input {
Some(path) => wsb::compact::compact_file(std::path::Path::new(&path), token_mode)
.with_context(|| format!("Failed to compact file: {}", path))?,
None => {
if std::io::stdin().is_terminal() {
wsb::errln!("Reading from stdin. Pipe input or provide a file path.");
wsb::errln!("Usage: wsb compact [FILE] [-o OUTPUT] [--token-mode]");
anyhow::bail!("No input provided and stdin is a terminal");
}
wsb::compact::compact_stdin(token_mode).with_context(|| "Failed to compact stdin")?
}
};
match output {
Some(path) => {
std::fs::write(&path, &result)
.with_context(|| format!("Failed to write output to: {}", path))?;
}
None => {
wsb::out!("{}", result);
}
}
Ok(())
}
fn run_audit_path_command() -> Result<()> {
let path_var = std::env::var("PATH").context("PATH environment variable not set")?;
let dirs: Vec<&str> = path_var.split(':').collect();
let mut all_bins: std::collections::HashMap<String, Vec<std::path::PathBuf>> =
std::collections::HashMap::new();
for dir in &dirs {
let dir_path = std::path::Path::new(dir);
if let Ok(entries) = std::fs::read_dir(dir_path) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
let is_executable = std::fs::metadata(&path)
.map(|m| {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
m.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
})
.unwrap_or(false);
if is_executable {
all_bins.entry(name.to_string()).or_default().push(path);
}
}
}
}
}
}
let mut shadowed: Vec<(&String, &Vec<std::path::PathBuf>)> = all_bins
.iter()
.filter(|(_, paths)| paths.len() > 1)
.collect();
shadowed.sort_by_key(|(name, _)| (*name).clone());
if shadowed.is_empty() {
wsb::outln!("No shadowed executables found on PATH.");
return Ok(());
}
for (name, paths) in &shadowed {
wsb::outln!("== {} ==", name);
for path in paths.iter() {
wsb::outln!(" {}", path.display());
}
wsb::outln!();
}
wsb::outln!("{} shadowed executable(s) found.", shadowed.len());
Ok(())
}
fn run_ignore_command(line: &str) -> Result<()> {
let current_dir = std::env::current_dir().context("Cannot determine current directory")?;
let entries = std::fs::read_dir(¤t_dir)
.with_context(|| format!("Cannot read directory: {}", current_dir.display()))?;
let mut modified_count = 0;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let gitignore = path.join(".gitignore");
if gitignore.exists() {
let content = std::fs::read_to_string(&gitignore)
.with_context(|| format!("Cannot read {}", gitignore.display()))?;
if !content.lines().any(|l| l == line) {
let mut new_content = content;
if !new_content.ends_with('\n') {
new_content.push('\n');
}
new_content.push_str(line);
new_content.push('\n');
std::fs::write(&gitignore, new_content)
.with_context(|| format!("Cannot write {}", gitignore.display()))?;
wsb::outln!("Added '{}' to {}", line, gitignore.display());
modified_count += 1;
}
}
}
}
if modified_count == 0 {
wsb::outln!("No .gitignore files needed updating (line already present or no .gitignore files found).");
} else {
wsb::outln!("Updated {} .gitignore file(s).", modified_count);
}
Ok(())
}
fn run_last_hash_command(file: &str) -> Result<()> {
let output = wsb::git_backend::git_command()
.args([
"log",
"-n",
"1",
"--follow",
"--pretty=format:%H",
"--",
file,
])
.output()
.context("Failed to execute git log")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git log failed: {}", stderr.trim());
}
let hash = String::from_utf8_lossy(&output.stdout);
let hash = hash.trim();
if hash.is_empty() {
anyhow::bail!("No commit found for file: {}", file);
}
wsb::outln!("{}", hash);
Ok(())
}
fn calculate_version(
project_root: &std::path::Path,
settings: &RepoSettings,
cache: &Arc<RunCache>,
) -> Result<VersionInfo> {
let version_info = VersionInfo::calculate_with_major_in_repo_cached(settings.version.major, project_root, cache)?;
info!("Version calculated: {}", version_info.full_version);
Ok(version_info)
}
fn compare_renders(
shared: &[wsb::state::wstemplate::Produced],
alone: &[wsb::state::wstemplate::Produced],
project_root: &Path,
) -> Result<()> {
if shared.len() != alone.len() {
let named = |set: &[wsb::state::wstemplate::Produced]| {
set.iter()
.map(|one| one.output_path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
};
anyhow::bail!(
"--verify: updating {} renders {} file(s) through the run's cache and {} through a \
cache of its own.\n with the run's: {}\n on its own: {}",
project_root.display(),
shared.len(),
alone.len(),
named(shared),
named(alone)
);
}
for (a, b) in shared.iter().zip(alone) {
anyhow::ensure!(
a.output_path == b.output_path,
"--verify: updating {} renders {} through the run's cache where a cache of its own \
renders {}",
project_root.display(),
a.output_path.display(),
b.output_path.display()
);
anyhow::ensure!(
a.content == b.content,
"--verify: {} renders differently through the run's cache than on its own.\n\
This is the cache serving an answer that stopped being true — report it with the \
template at {}.",
a.output_path.display(),
a.source_path.display()
);
}
Ok(())
}
fn calculate_version_without_git(project_root: &std::path::Path, settings: &RepoSettings) -> Result<VersionInfo> {
let version_path = project_root.join(&settings.version.file);
let version_info = if version_path.exists() {
wsb::state::read_version_from_path(project_root)?
} else {
let major = settings.version.major;
VersionInfo {
major_version: format!("v{major}"),
minor_version: 0,
patch_version: 0,
full_version: format!("{major}.0.0"),
}
};
info!(
"Version calculated without git state: {}",
version_info.full_version
);
Ok(version_info)
}
fn log_action(message: &str) {
match log_to_file(message) {
Ok(_) => {}
Err(_) => {}
}
}
fn log_to_file(message: &str) -> Result<()> {
info!("{}", message);
Ok(())
}
fn ensure_git_repository_at(path: &Path) -> Result<()> {
if is_git_checkout(path) {
return Ok(());
}
wsb::git_backend::init_repository(path)?;
wsb::outln!("Initialized Git repository at {}", path.display());
Ok(())
}
fn setup_new_project(name: Option<String>, repo_root: &Path) -> Result<()> {
let settings = setup_repo_at(repo_root, name.as_deref())?;
print_repo_setup_complete(&settings, repo_root);
Ok(())
}
fn setup_repo_at(repo_root: &Path, name: Option<&str>) -> Result<RepoSettings> {
wsb::outln!(
"{}",
format!("Setting up wsb in {}...", repo_root.display()).bold().green()
);
ensure_git_repository_at(repo_root)?;
refuse_hidden_settings(repo_root)?;
let settings = match RepoSettings::load(repo_root)? {
Some(held) => {
if let Some(name) = name {
if held.name != name {
anyhow::bail!(
"{} already names this repository '{}'; edit it there to rename it",
RepoSettings::path(repo_root).display(),
held.name
);
}
}
wsb::outln!(
" {} {} is already there; it is kept as it is",
"✓".green(),
RepoSettings::path(repo_root).display()
);
held
}
None => {
let (alias, templates_root) = default_alias_and_root(repo_root)?;
let settings = RepoSettings {
name: match name {
Some(name) => name.to_string(),
None => wsb_prj::repository_name(repo_root)?,
},
alias,
templates_root,
version: VersionSettings::at_major(wsb::state::read_major_version_from_file(repo_root).unwrap_or(0)),
};
settings.save(repo_root)?;
wsb::outln!(
" {} wrote {}; commit it, so every clone is set up the same",
"✓".green(),
RepoSettings::path(repo_root).display()
);
settings
}
};
install_pre_commit_hook(repo_root)?;
Ok(settings)
}
fn refuse_hidden_settings(repo_root: &Path) -> Result<()> {
let probe = RepoSettings::path(repo_root);
let output = wsb::git_backend::git_command()
.arg("-C")
.arg(repo_root)
.args(["check-ignore", "--no-index", "-v"])
.arg(&probe)
.output()
.with_context(|| format!("cannot run git in {}", repo_root.display()))?;
match output.status.code() {
Some(1) => Ok(()),
Some(0) => anyhow::bail!(
"{} hides .wsb/, which is committed whole: {}. Remove that rule and run again",
repo_root.display(),
String::from_utf8_lossy(&output.stdout).trim()
),
_ => anyhow::bail!(
"cannot ask git whether .wsb/ is ignored in {}: {}",
repo_root.display(),
String::from_utf8_lossy(&output.stderr).trim()
),
}
}
fn install_pre_commit_hook(repo_root: &Path) -> Result<()> {
let git_dir = repo_git_dir(repo_root)?;
let hooks_dir = git_dir.join("hooks");
if hooks_dir.is_dir() {
let hook_path = hooks_dir.join("pre-commit");
let hook_content = PRE_COMMIT_HOOK;
let existing = match std::fs::read_to_string(&hook_path) {
Ok(existing) => Some(existing),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
return Err(e).with_context(|| format!("reading {}", hook_path.display()))
}
};
let needs_write = match &existing {
None => true,
Some(existing) if existing == hook_content => false,
Some(existing) if is_wsb_hook(existing) => true,
Some(existing) => {
if let Some(problem) = pre_commit_hook_problem(existing) {
wsb::errln!(
" {} left the pre-commit hook at {} as it is: it {}",
"!".yellow(),
hook_path.display(),
problem
);
}
false
}
};
if needs_write {
std::fs::write(&hook_path, hook_content)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&hook_path)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&hook_path, perms)?;
}
wsb::outln!(
" {} installed pre-commit hook: {}",
"✓".green(),
hook_path.display()
);
}
}
Ok(())
}
fn default_alias_and_root(project_root: &Path) -> Result<(String, String)> {
if let Some((_immediate, submodule_name)) = find_parent_repo_submodule(project_root)? {
let outermost = outermost_superproject(project_root)?
.expect("a project with a parent repository has an outermost one");
return Ok((
WstemplateEngine::derive_alias(Path::new(&submodule_name)),
wsb_prj::settings::relative_path(&outermost, project_root)?,
));
}
Ok((
WstemplateEngine::derive_alias(Path::new(&wsb_prj::repository_name(project_root)?)),
".".to_string(),
))
}
fn outermost_superproject(project_root: &Path) -> Result<Option<PathBuf>> {
let mut here = project_root.to_path_buf();
let mut outermost = None;
while let Some((parent, _)) = find_parent_repo_submodule(&here)? {
outermost = Some(parent.clone());
here = parent;
}
Ok(outermost)
}
fn find_parent_repo_submodule(project_root: &Path) -> Result<Option<(PathBuf, String)>> {
let mut parent = project_root.parent();
while let Some(candidate) = parent {
let gitmodules = match GitModules::parse(candidate) {
Ok(gitmodules) => gitmodules,
Err(SuperGitError::NoSubmodules) => {
parent = candidate.parent();
continue;
}
Err(error) => {
return Err(anyhow::anyhow!(
"Failed to parse parent .gitmodules at {}: {}",
candidate.display(),
error
));
}
};
let relative_path = project_root.strip_prefix(candidate).with_context(|| {
format!(
"Failed to compute submodule path for {} inside {}",
project_root.display(),
candidate.display()
)
})?;
if let Some(submodule) = gitmodules
.submodules
.into_iter()
.find(|submodule| submodule.path == relative_path)
{
let directory = relative_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
if submodule.name != directory {
return Err(anyhow::anyhow!(
"submodule at {} is recorded in {} as '{}' but its directory is '{}'.\n\
The alias a template names is derived from that name, so the two have to \
agree — fix the name in .gitmodules or move the directory.",
relative_path.display(),
candidate.join(".gitmodules").display(),
submodule.name,
directory
));
}
return Ok(Some((candidate.to_path_buf(), submodule.name)));
}
parent = candidate.parent();
}
Ok(None)
}
fn print_repo_setup_complete(settings: &RepoSettings, repo_root: &Path) {
wsb::outln!(
"{}",
format!("wsb manages '{}' at {}", settings.name, repo_root.display()).bold().green()
);
}
fn is_git_checkout(path: &Path) -> bool {
let git_path = path.join(".git");
git_path.is_dir() || git_path.is_file()
}
fn repo_git_dir(project_root: &Path) -> Result<PathBuf> {
let git_path = project_root.join(".git");
if git_path.is_dir() {
return Ok(git_path);
}
if git_path.is_file() {
let content = std::fs::read_to_string(&git_path)
.with_context(|| format!("Failed to read {}", git_path.display()))?;
let git_dir = content
.trim()
.strip_prefix("gitdir:")
.map(str::trim)
.ok_or_else(|| anyhow::anyhow!("Invalid gitdir file at {}", git_path.display()))?;
let git_dir = PathBuf::from(git_dir);
return Ok(if git_dir.is_absolute() {
git_dir
} else {
project_root.join(git_dir)
});
}
anyhow::bail!(
"No .git directory or gitdir file at {}",
project_root.display()
)
}
fn init_nested_repos(project_root: &Path) -> Result<()> {
let outcome = SubmoduleOps::init_tree(project_root, true, |node| {
wsb::outln!(
"{}",
format!("Initializing git submodule '{}'", node.label())
.bold()
.green()
);
})
.map_err(|error| {
anyhow::anyhow!(
"Failed to walk submodules at {}: {}",
project_root.display(),
error
)
})?;
if let Some((name, error)) = outcome.failures.into_iter().next() {
anyhow::bail!("Failed to initialize submodule '{}': {}", name, error);
}
let tree = SubmoduleTree::discover_allow_empty(project_root).map_err(|error| {
anyhow::anyhow!(
"Failed to read submodules at {}: {}",
project_root.display(),
error
)
})?;
for node in tree.top_down() {
if !node.initialized {
anyhow::bail!(
"Submodule '{}' is still not checked out after initialization at {}",
node.label(),
node.abs_path.display()
);
}
if !node.abs_path.is_dir() {
anyhow::bail!(
"Submodule '{}' path is not a directory: {}",
node.label(),
node.abs_path.display()
);
}
if !is_git_checkout(&node.abs_path) {
anyhow::bail!(
"Submodule '{}' at {} is not a usable git repository",
node.label(),
node.abs_path.display()
);
}
let settings = setup_repo_at(&node.abs_path, None)?;
print_repo_setup_complete(&settings, &node.abs_path);
}
Ok(())
}
fn read_script_from_stdin() -> Result<String> {
use std::io::Read;
let mut script = String::new();
std::io::stdin()
.read_to_string(&mut script)
.context("Failed to read script from stdin")?;
Ok(script)
}
fn read_script_interactively() -> Result<String> {
use std::io::Read;
wsb::errln!("Enter shell script (Ctrl-D when done):");
let mut script = String::new();
std::io::stdin()
.read_to_string(&mut script)
.context("Failed to read script interactively")?;
Ok(script)
}
fn run_each_command(
project_root: &Path,
file: Option<std::path::PathBuf>,
quiet: bool,
) -> Result<()> {
use colored::Colorize;
use wsb::fleet::config::gitmodules::GitModules;
let script: String = match file {
Some(ref path) => std::fs::read_to_string(path)
.with_context(|| format!("Failed to read script file: {}", path.display()))?,
None => {
use std::io::IsTerminal;
if std::io::stdin().is_terminal() {
read_script_interactively()?
} else {
read_script_from_stdin()?
}
}
};
let script = script.trim();
if script.is_empty() {
anyhow::bail!("Script is empty — nothing to run");
}
let modules = match GitModules::parse(project_root) {
Ok(m) => m,
Err(wsb::fleet::error::SuperGitError::NoSubmodules) => {
anyhow::bail!(
"No submodules found in {} — 'wsb each' requires a fleet root with .gitmodules",
project_root.display()
);
}
Err(e) => return Err(anyhow::anyhow!(".gitmodules parse error: {:#}", e)),
};
let total = modules.submodules.len() as u64;
let pb = make_progress_bar(quiet, total);
for sub in &modules.submodules {
let sub_path = project_root.join(&sub.path);
if !sub_path.is_dir() {
pb.finish_and_clear();
anyhow::bail!(
"Submodule '{}': path '{}' does not exist — run 'git submodule update --init'",
sub.name,
sub.path.display()
);
}
pb.set_message(sub.name.clone());
let status = wsb::git_backend::command_without_repo_scope("sh")
.arg("-c")
.arg(script)
.current_dir(&sub_path)
.env("WSB_SUBMODULE_PATH", &sub_path)
.env("WSB_SUBMODULE_NAME", &sub.name)
.env("WSB_SUPERPROJECT_ROOT", project_root)
.status()
.with_context(|| format!("Failed to launch sh for submodule '{}'", sub.name))?;
if !status.success() {
pb.finish_and_clear();
let code = status
.code()
.map(|c| c.to_string())
.unwrap_or_else(|| "signal".to_string());
anyhow::bail!("Script failed in submodule '{}' (exit {})", sub.name, code);
}
pb.inc(1);
}
pb.finish_and_clear();
if !quiet {
wsb::outln!(
"{} ran in {}/{} submodule(s)",
"✓".green().bold(),
total,
total
);
}
Ok(())
}
fn run_check_command(cwd: &Path, no_network: bool, quiet: bool) -> Result<()> {
use colored::Colorize;
use wsb::fleet::config::gitmodules::GitModules;
#[derive(Clone)]
struct CheckItem {
category: &'static str,
text: String,
}
let mut issues: Vec<CheckItem> = Vec::new();
let mut warnings: Vec<CheckItem> = Vec::new();
let mut ok_items: Vec<String> = Vec::new();
let pb = make_spinner(quiet, "checking worktree…");
macro_rules! ok {
($msg:expr) => {{
ok_items.push(format!(" {} {}", "✓".green(), $msg))
}};
}
macro_rules! issue {
($cat:literal, $msg:expr) => {{
issues.push(CheckItem {
category: $cat,
text: format!(" {} {}", "✗".red().bold(), $msg),
})
}};
($msg:expr) => {{
issues.push(CheckItem {
category: "General",
text: format!(" {} {}", "✗".red().bold(), $msg),
})
}};
}
macro_rules! warn_item {
($cat:literal, $msg:expr) => {{
warnings.push(CheckItem {
category: $cat,
text: format!(" {} {}", "⚠".yellow(), $msg),
})
}};
($msg:expr) => {{
warnings.push(CheckItem {
category: "General",
text: format!(" {} {}", "⚠".yellow(), $msg),
})
}};
}
pb.set_message("resolving the repository and its worktree…");
let project_root = match wsb::roots::find_repo_root(cwd) {
Some(root) => {
ok!(format!("repository: {}", root.display()));
root
}
None => {
issue!(
"Repository",
format!(
"no .wsb/repo.json at or above {} — run 'wsb init' in the repository",
cwd.display()
)
);
pb.finish_and_clear();
wsb::outln!();
for i in &issues {
wsb::outln!("{}", i.text);
}
std::process::exit(wsb::output::finish(1));
}
};
let super_root = wsb::roots::find_super_root(cwd);
match &super_root {
Some(root) if root == &project_root => ok!("worktree root: the repository itself (no submodule nesting)"),
Some(root) if RepoSettings::exists(root) => ok!(format!("worktree root: {}", root.display())),
Some(root) => issue!(
"Repository",
format!(
"the outermost repository {} has no .wsb/repo.json, so this is no worktree wsb manages — run 'wsb init' there",
root.display()
)
),
None => issue!("Repository", "not inside any git repository"),
}
let worktree_root = super_root.clone().unwrap_or_else(|| project_root.clone());
let settings = match RepoSettings::load(&project_root) {
Ok(Some(settings)) => {
ok!(format!(
".wsb/repo.json: '{}', alias '{}', major {}, version file {}",
settings.name, settings.alias, settings.version.major, settings.version.file
));
let scan_root = settings.templates_root_of(&project_root);
if scan_root.is_dir() {
ok!(format!("templates root: {} ({})", settings.templates_root, scan_root.display()));
} else {
issue!("Settings", format!("the templates root {} does not exist", scan_root.display()));
}
Some(settings)
}
Ok(None) => None,
Err(e) => {
issue!("Settings", format!("{e:#}"));
None
}
};
match ProjectSettings::load(&worktree_root) {
Ok(project) => ok!(format!(
".wsb/project.json at the worktree root: {} board(s), {} ignored submodule(s)",
project.boards.len(),
project.ignored_submodules.len()
)),
Err(e) => issue!("Settings", format!("{e:#}")),
}
if let Err(e) = refuse_hidden_settings(&project_root) {
issue!("Git", format!("{e:#}"));
} else {
ok!(".wsb/ is visible to git, so every clone has it");
}
let leftovers = wsb::migrate::leftovers_in(&project_root)?;
if leftovers.is_empty() {
ok!("nothing of an older wsb's layout is left in the checkout");
} else {
for leftover in &leftovers {
issue!("Migrate", format!("{leftover} — run 'wsb migrate'"));
}
}
if let Some(settings) = &settings {
let version_path = project_root.join(&settings.version.file);
match std::fs::read_to_string(&version_path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => warn_item!(
"Version",
format!("{} is not there yet; the next 'wsb update' writes it", version_path.display())
),
Err(e) => issue!("Version", format!("{} is unreadable: {e}", version_path.display())),
Ok(content) => match wsb::state::parse_version_string(content.trim()) {
Ok(declared) if declared.major_version.trim_start_matches('v') == settings.version.major.to_string() => {
ok!(format!("{} declares {}", settings.version.file, declared.full_version))
}
Ok(declared) => warn_item!(
"Version",
format!(
"{} declares {} while .wsb/repo.json commits major {}; the next 'wsb update' rewrites it",
settings.version.file, declared.full_version, settings.version.major
)
),
Err(e) => issue!("Version", format!("{} does not hold a version: {e:#}", version_path.display())),
},
}
}
match wsb::local_state::LocalState::of(&worktree_root) {
Ok(state) => {
ok!(format!("local state: {}", state.dir().display()));
let build_root = wsb::state::stamp_config::StampConfig::load(&worktree_root)
.ok()
.flatten()
.and_then(|config| config.build_root)
.map(|relative| worktree_root.join(relative));
let disposable = [Some(state.logs()), build_root];
for path in disposable.into_iter().flatten().filter(|path| path.is_dir()) {
match wsb::platform::excluded_from_backups(&path) {
Ok(true) => {}
Ok(false) => warn_item!("Backups", format!(
"{} is disposable and backed up — `tmutil addexclusion {}`",
path.display(),
path.display()
)),
Err(error) => warn_item!("Backups", error),
}
}
}
Err(e) => issue!("Local state", format!("{e:#}")),
}
pb.set_message("checking declared local paths…");
let broken_paths = wsb::local_paths::check(&project_root);
if broken_paths.is_empty() {
ok!("every declared local path resolves (cargo, swift, npm, lockfiles)");
} else {
for broken in &broken_paths {
issue!(
"Paths",
format!(
"{}: {} '{}' points at {}, which does not exist",
broken.manifest, broken.kind, broken.declared, broken.resolves_to
)
);
}
}
pb.set_message("checking for committed home paths…");
let home_paths = wsb::local_paths::home_rooted(&project_root);
if home_paths.is_empty() {
ok!("no committed file names a path inside somebody's home directory");
} else {
for found in &home_paths {
issue!(
"Paths",
format!(
"{}:{} names {}, which is one machine's layout — make it relative to the project",
found.manifest, found.line, found.path
)
);
}
}
pb.set_message("checking for source hidden from git…");
let hidden = wsb::local_paths::hidden_source(&project_root);
if hidden.is_empty() {
ok!("no source file is hidden from git by an ignore rule");
} else {
for file in &hidden {
issue!(
"Paths",
format!(
"{} is a source file git is ignoring ({}) — anchor the pattern with a leading slash",
file.path, file.rule
)
);
}
}
pb.set_message("checking pre-commit hook…");
let hook_path = project_root.join(".git").join("hooks").join("pre-commit");
if !hook_path.exists() {
issue!("Git", format!(
"pre-commit hook missing at {} — run 'wsb init' to install it (runs 'wsb update --quiet --git-add' on every commit)",
hook_path.display()
));
} else {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::metadata(&hook_path)?.permissions();
if perms.mode() & 0o111 == 0 {
issue!(
"Git",
format!(
"pre-commit hook at {} is not executable — run: chmod +x {}",
hook_path.display(),
hook_path.display()
)
);
} else {
ok!(format!(
"pre-commit hook is executable: {}",
hook_path.display()
));
}
}
match std::fs::read_to_string(&hook_path) {
Err(e) => issue!("Git", format!("pre-commit hook unreadable: {}", e)),
Ok(hook_content) => match pre_commit_hook_problem(&hook_content) {
Some(problem) => issue!(
"Git",
format!("pre-commit hook at {} {problem}", hook_path.display())
),
None => {
ok!("pre-commit hook invokes '{PRE_COMMIT_MARK}' with the driving wsb")
}
},
}
}
let gitmodules_path = project_root.join(".gitmodules");
let is_fleet_root = gitmodules_path.is_file();
if !is_fleet_root {
ok!("no .gitmodules — single-repo worktree (not a fleet root)");
} else {
ok!(format!(
".gitmodules found at {}",
gitmodules_path.display()
));
match GitModules::parse(&project_root) {
Err(e) => {
issue!("Fleet", format!(".gitmodules parse error: {:#}", e));
}
Ok(modules) => {
ok!(format!(
"{} submodule(s) declared in .gitmodules",
modules.submodules.len()
));
let submodule_status_output = wsb::git_backend::git_command()
.args(["submodule", "status"])
.current_dir(&project_root)
.output()
.ok();
let mut all_aliases: Vec<(String, String)> = Vec::new();
if let Some(settings) = &settings {
all_aliases.push((settings.alias.clone(), project_root.display().to_string()));
}
for sub in &modules.submodules {
let sub_path = project_root.join(&sub.path);
let display = sub.path.display().to_string();
if no_network {
warn_item!(
"Fleet",
format!(
"submodule '{}': skipping remote URL check (--no-network) — url: {}",
sub.name, sub.url
)
);
} else {
let ls_remote = wsb::git_backend::git_command()
.args(["ls-remote", "--exit-code", "--heads", &sub.url])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.output();
match ls_remote {
Ok(out) if out.status.success() => {
ok!(format!(
"submodule '{}': remote url reachable — {}",
sub.name, sub.url
));
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
issue!(
"Fleet",
format!(
"submodule '{}': remote url unreachable — {} ({})",
sub.name,
sub.url,
stderr.trim()
)
);
}
Err(e) => {
issue!("Fleet", format!(
"submodule '{}': could not run 'git ls-remote' to check url {}: {}",
sub.name, sub.url, e
));
}
}
}
if !sub_path.is_dir() {
issue!("Fleet", format!(
"submodule '{}': path '{}' does not exist on disk — run 'git submodule update --init'",
sub.name, display
));
continue;
}
ok!(format!(
"submodule '{}': path exists at {}",
sub.name,
sub_path.display()
));
let sub_git = sub_path.join(".git");
if !sub_git.exists() {
issue!("Fleet", format!(
"submodule '{}': no .git entry — submodule not initialized (run 'git submodule update --init')",
sub.name
));
} else {
ok!(format!(
"submodule '{}': .git entry present (initialized)",
sub.name
));
}
if let Some(ref branch) = sub.branch {
if branch == "." {
ok!(format!(
"submodule '{}': branch tracks superproject HEAD (branch = .)",
sub.name
));
} else if no_network {
warn_item!("Fleet", format!(
"submodule '{}': skipping branch '{}' existence check (--no-network)",
sub.name, branch
));
} else {
let branch_check = wsb::git_backend::git_command()
.args(["ls-remote", "--exit-code", "--heads", &sub.url, branch])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.output();
match branch_check {
Ok(out) if out.status.success() => {
ok!(format!(
"submodule '{}': declared branch '{}' exists on remote",
sub.name, branch
));
}
Ok(_) => {
issue!("Fleet", format!(
"submodule '{}': declared branch '{}' does not exist on remote {}",
sub.name, branch, sub.url
));
}
Err(e) => {
issue!(
"Fleet",
format!(
"submodule '{}': could not check branch '{}' on remote: {}",
sub.name, branch, e
)
);
}
}
}
} else {
warn_item!("Fleet", format!(
"submodule '{}': no branch declared in .gitmodules — submodule will track a detached commit",
sub.name
));
}
if let Some(ref status_out) = submodule_status_output {
if status_out.status.success() {
let status_text = String::from_utf8_lossy(&status_out.stdout);
let sub_path_str = sub.path.to_string_lossy();
let line = status_text.lines().find(|l| {
l.trim_start_matches(['-', '+', 'U', ' '])
.contains(sub_path_str.as_ref())
});
match line {
None => {
issue!("Fleet", format!(
"submodule '{}': not found in 'git submodule status' output — may not be registered in git index",
sub.name
));
}
Some(l) => {
let prefix = l.chars().next().unwrap_or(' ');
match prefix {
'-' => issue!("Fleet", format!(
"submodule '{}': not initialized (git submodule status shows '-') — run 'git submodule update --init'",
sub.name
)),
'+' => issue!("Fleet", format!(
"submodule '{}': checked out commit differs from superproject pointer (git submodule status shows '+') — run 'git submodule update'",
sub.name
)),
'U' => issue!("Fleet", format!(
"submodule '{}': merge conflict in submodule (git submodule status shows 'U')",
sub.name
)),
_ => ok!(format!(
"submodule '{}': commit pointer matches superproject (clean)",
sub.name
)),
}
}
}
} else {
let stderr = String::from_utf8_lossy(&status_out.stderr);
warn_item!(
"Fleet",
format!("could not run 'git submodule status': {}", stderr.trim())
);
}
}
match RepoSettings::load(&sub_path) {
Ok(None) => issue!(
"Settings",
format!(
"submodule '{}': no .wsb/repo.json — run 'wsb init' inside {}",
sub.name,
sub_path.display()
)
),
Err(e) => issue!("Settings", format!("submodule '{}': {e:#}", sub.name)),
Ok(Some(sub_settings)) => {
ok!(format!(
"submodule '{}': .wsb/repo.json — '{}', alias '{}', major {}",
sub.name, sub_settings.name, sub_settings.alias, sub_settings.version.major
));
let version_path = sub_path.join(&sub_settings.version.file);
if !version_path.exists() {
warn_item!(
"Version",
format!(
"submodule '{}': {} is not there yet; the next 'wsb update' writes it",
sub.name, sub_settings.version.file
)
);
}
all_aliases.push((sub_settings.alias, sub_path.display().to_string()));
}
}
match wsb::migrate::leftovers_in(&sub_path) {
Ok(leftovers) => {
for leftover in leftovers {
issue!("Migrate", format!("submodule '{}': {leftover} — run 'wsb migrate'", sub.name));
}
}
Err(e) => issue!("Migrate", format!("submodule '{}': {e:#}", sub.name)),
}
if let Err(e) = refuse_hidden_settings(&sub_path) {
issue!("Git", format!("submodule '{}': {e:#}", sub.name));
}
let sub_git_dir = repo_git_dir(&sub_path)
.unwrap_or_else(|_| sub_path.join(".git"));
let sub_hook = sub_git_dir.join("hooks").join("pre-commit");
if !sub_hook.exists() {
issue!("Git", format!(
"submodule '{}': pre-commit hook missing — run 'wsb init' inside {} to install it",
sub.name, sub_path.display()
));
} else {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(&sub_hook) {
if meta.permissions().mode() & 0o111 == 0 {
issue!(
"Git",
format!(
"submodule '{}': pre-commit hook is not executable",
sub.name
)
);
}
}
}
match std::fs::read_to_string(&sub_hook) {
Err(e) => issue!(
"Git",
format!(
"submodule '{}': pre-commit hook unreadable: {}",
sub.name, e
)
),
Ok(content) => {
if let Some(problem) = pre_commit_hook_problem(&content) {
issue!("Git", format!(
"submodule '{}': pre-commit hook {problem}",
sub.name
));
} else {
ok!(format!(
"submodule '{}': pre-commit hook correct",
sub.name
));
}
}
}
}
}
let mut seen_aliases: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for (alias, source) in &all_aliases {
seen_aliases
.entry(alias.clone())
.or_default()
.push(source.clone());
}
for (alias, sources) in &seen_aliases {
if sources.len() > 1 {
issue!(
"Fleet",
format!(
"duplicate wstemplate alias '{}' in {} projects: {}",
alias,
sources.len(),
sources.join(", ")
)
);
} else {
ok!(format!("wstemplate alias '{}' is unique", alias));
}
}
}
}
}
pb.finish_and_clear();
if !quiet {
wsb::outln!();
for item in &ok_items {
wsb::outln!("{}", item);
}
}
if !warnings.is_empty() {
let mut by_cat: std::collections::BTreeMap<&str, Vec<&str>> =
std::collections::BTreeMap::new();
for w in &warnings {
by_cat.entry(w.category).or_default().push(&w.text);
}
wsb::outln!();
wsb::outln!("{} {} warning(s):", "⚠".yellow(), warnings.len());
for (cat, items) in &by_cat {
wsb::outln!(" [{}]", cat.yellow());
for item in items {
wsb::outln!("{}", item);
}
}
}
if !issues.is_empty() {
let mut by_cat: std::collections::BTreeMap<&str, Vec<&str>> =
std::collections::BTreeMap::new();
for iss in &issues {
by_cat.entry(iss.category).or_default().push(&iss.text);
}
wsb::outln!();
wsb::outln!("{} {} issue(s) found:", "✗".red().bold(), issues.len());
for (cat, items) in &by_cat {
wsb::outln!(" [{}]", cat.red().bold());
for item in items {
wsb::outln!("{}", item);
}
}
wsb::outln!();
std::process::exit(wsb::output::finish(1));
} else {
wsb::outln!();
wsb::outln!("{}", "All checks passed.".green().bold());
}
Ok(())
}
fn add_files_to_git(files: &[String], project_root: &Path) -> Result<Vec<String>> {
let repo = wsb::git_backend::discover_repo(project_root)?;
let repo_root = repo
.workdir()
.context("Bare repositories are not supported for staging")?;
let mut files_to_stage = Vec::new();
for file in files {
let file_path = std::path::Path::new(file);
let absolute = if file_path.is_absolute() {
file_path.to_path_buf()
} else {
project_root.join(file_path)
};
if !absolute.exists() {
continue;
}
let relative_path = match absolute.strip_prefix(repo_root) {
Ok(rel) => rel.to_string_lossy().to_string(),
Err(_) => continue,
};
if let Some(submodule) = wsb::git_backend::submodule_containing(&repo, &relative_path)? {
debug!(
"not staging '{}': it lives in submodule '{}', which owns its own index",
relative_path, submodule
);
continue;
}
files_to_stage.push(relative_path);
}
let added_files = wsb::git_backend::stage_paths(&repo, &files_to_stage)?;
for file in &added_files {
log_action(&format!("Added file to git: {}", file));
}
Ok(added_files)
}
fn install_shell_completions(shell: Option<Shell>) -> Result<()> {
let shell = match shell {
Some(shell) => shell,
None => detect_shell()?,
};
generate_completion_file(shell)?;
let completion_dir = get_completion_dir(shell)?;
let completion_file = get_completion_file_path(shell, &completion_dir)?;
print_activation_hint(shell, &completion_file);
Ok(())
}
fn detect_shell() -> Result<Shell> {
if let Ok(shell) = env::var("SHELL") {
if shell.contains("bash") {
return Ok(Shell::Bash);
} else if shell.contains("zsh") {
return Ok(Shell::Zsh);
} else if shell.contains("fish") {
return Ok(Shell::Fish);
}
}
if env::var("ZSH_VERSION").is_ok() {
return Ok(Shell::Zsh);
} else if env::var("BASH_VERSION").is_ok() {
return Ok(Shell::Bash);
} else if env::var("FISH_VERSION").is_ok() {
return Ok(Shell::Fish);
}
Ok(Shell::Bash)
}
fn generate_completion_file(shell: Shell) -> Result<()> {
let mut completion_script = Vec::new();
{
let mut app = Args::command();
let name = app.get_name().to_string();
generate(shell, &mut app, name, &mut completion_script);
}
let completion_content = String::from_utf8(completion_script)
.context("Failed to convert completion script to string")?;
let completion_dir = get_completion_dir(shell)?;
fs::create_dir_all(&completion_dir).context("Failed to create completion directory")?;
let completion_file = get_completion_file_path(shell, &completion_dir)?;
fs::write(&completion_file, &completion_content)
.context("Failed to write completion script")?;
Ok(())
}
fn get_completion_dir(shell: Shell) -> Result<PathBuf> {
let data_home = env::var("XDG_DATA_HOME").unwrap_or_else(|_| {
let home = env::var("HOME").unwrap_or_default();
format!("{}/.local/share", home)
});
let dir = match shell {
Shell::Zsh => format!("{}/zsh/site-functions", data_home),
Shell::Bash => format!("{}/bash-completion/completions", data_home),
Shell::Fish => {
format!("{}/fish/completions", data_home)
}
Shell::PowerShell => format!("{}/powershell/completions", data_home),
_ => format!("{}/completions", data_home),
};
Ok(PathBuf::from(dir))
}
fn get_completion_file_path(shell: Shell, completion_dir: &std::path::Path) -> Result<PathBuf> {
let file_name = match shell {
Shell::Zsh => "_wsb",
Shell::Bash => "wsb",
Shell::Fish => "wsb.fish",
Shell::PowerShell => "wsb.ps1",
_ => "wsb",
};
Ok(completion_dir.join(file_name))
}
fn print_activation_hint(shell: Shell, file_path: &std::path::Path) {
match shell {
Shell::Bash => {
wsb::errln!("# To enable wsb completions, add this to your ~/.bashrc:");
wsb::errln!("source '{}'", file_path.to_string_lossy());
}
Shell::Zsh => {
let completion_parent = file_path.parent().unwrap_or(std::path::Path::new(""));
wsb::errln!("# To enable wsb completions, add these lines to your ~/.zshrc:");
wsb::errln!("fpath=(\"{}\" $fpath)", completion_parent.to_string_lossy());
wsb::errln!("autoload -U compinit && compinit");
}
Shell::Fish => {
wsb::errln!(
"# wsb completions installed to: {}",
file_path.to_string_lossy()
);
}
Shell::PowerShell => {
wsb::errln!("# To enable wsb completions, add this to your PowerShell profile:");
wsb::errln!(". '{}'", file_path.to_string_lossy());
}
_ => {
wsb::errln!("# Completion script at: {}", file_path.to_string_lossy());
}
}
}
fn run_catalog_command(action: CatalogAction, ctx: &AppContext) -> Result<()> {
let catalogue = ctx.db.catalogue_repo();
let root = &ctx.worktree_root;
match action {
CatalogAction::List { repo } => {
let mut tests = catalogue.all()?;
if let Some(wanted) = &repo {
tests.retain(|test| &test.repo == wanted);
}
for test in &tests {
wsb::output::emit(wsb::output::Record::CatalogueTest {
number: test.number,
name: test.function_name.clone(),
repo: test.repo.clone(),
language: test.language.as_str().to_string(),
file: test.file_path.clone(),
line: test.line_number,
description: test.description.clone(),
});
}
if !wsb::output::is_jsonl() {
for test in &tests {
wsb::outln!(
"{:<10} {:<12} {:<44} {}",
test.number
.map(|n| format!("TEST{n}"))
.unwrap_or_else(|| "—".to_string()),
test.language.as_str(),
test.function_name,
test.location(),
);
}
wsb::outln!("\n{} tests", tests.len());
}
}
CatalogAction::Scan { quiet } => {
let scans = wsb::catalogue::scan::scan_worktree(root, &catalogue)
.map_err(|e| anyhow::anyhow!("{}", e))?;
if !quiet {
let rows: Vec<wsb::report::Row> = scans
.iter()
.map(|scan| {
wsb::report::Row::new(
wsb::catalogue::scan::display_repo(&scan.repo),
format!("{} TESTS", scan.found),
format!("{} numbered", scan.numbered),
)
})
.collect();
let total: usize = scans.iter().map(|s| s.found).sum();
let conclusion =
format!("{total} tests across {} repositories", scans.len());
wsb::report::show(
"Catalogue scan",
&rows,
Some((conclusion.as_str(), wsb::report::Verdict::Ok)),
);
}
}
CatalogAction::Report => {
let survey = wsb::catalogue::report::Survey::read(&catalogue)?;
wsb::out!("{}", survey.render());
if !survey.is_clean() {
std::process::exit(wsb::output::finish(1));
}
}
CatalogAction::Parity {
family,
output,
git_add,
} => {
let members = wsb::catalogue::families::members(root, &family)?;
match output {
None => {
let parity =
wsb::catalogue::report::Parity::read(&catalogue, &members)?;
wsb::out!("{}", parity.render());
}
Some(path) => {
let markdown = wsb::catalogue::report::parity_markdown(
&catalogue, &members,
)?;
let path = if path.is_absolute() { path } else { root.join(path) };
std::fs::write(&path, markdown)
.with_context(|| format!("failed to write {}", path.display()))?;
wsb::outln!("wrote {}", path.display());
if git_add {
let repository = wsb::git_backend::discover_repo(&path)?;
wsb::git_backend::stage_paths(
&repository,
&[path.to_string_lossy().to_string()],
)?;
}
}
}
}
CatalogAction::Free { from, count } => {
for number in catalogue.free_numbers(from, count)? {
wsb::outln!("TEST{number}");
}
}
CatalogAction::Export {
output,
repo,
git_add,
} => {
let markdown =
wsb::catalogue::report::markdown(&catalogue, repo.as_deref())?;
let path = output.unwrap_or_else(|| root.join("TEST_CATALOG.md"));
std::fs::write(&path, markdown)
.with_context(|| format!("failed to write {}", path.display()))?;
wsb::outln!("wrote {}", path.display());
if git_add {
let repository = wsb::git_backend::discover_repo(root)?;
wsb::git_backend::stage_paths(
&repository,
&[path.to_string_lossy().to_string()],
)?;
}
}
}
Ok(())
}
fn validate_server_worktree(worktree: Option<PathBuf>) -> Result<PathBuf> {
let start_dir = match worktree {
Some(p) => wsb::platform::canonicalize(&p)
.with_context(|| format!("worktree path '{}' does not exist", p.display()))?,
None => std::env::current_dir().context("failed to get current directory")?,
};
let worktree_root = roots::find_super_root(&start_dir).ok_or_else(|| {
anyhow::anyhow!(
"cannot start the workbench from '{}': not inside a git repository.\n\
The workbench serves an initialized wsb worktree.\n\
initialize this directory first:\n wsb init\n\
or change into an existing worktree and try again.",
start_dir.display()
)
})?;
if !RepoSettings::exists(&worktree_root) {
bail!(
"cannot start the workbench from '{}': '{}' is no repository wsb manages: it has no {}.\n\
set it up first:\n wsb init\n\
or change into a repository wsb manages and try again.",
start_dir.display(),
worktree_root.display(),
RepoSettings::path(&worktree_root).strip_prefix(&worktree_root).unwrap_or(&RepoSettings::path(&worktree_root)).display()
);
}
Ok(worktree_root)
}
fn run_serve_command(
worktree_root: &Path,
port: Option<u16>,
listen: Option<std::net::IpAddr>,
debug: bool,
parent_pid: Option<u32>,
no_daemon: bool,
) -> Result<()> {
if no_daemon {
bail!(
"there is no workbench without a daemon: the server IS the daemon, and \
this run says not to have one (--no-daemon, or {} in the \
environment).\n \
Run `wsb serve` without it, and put --no-daemon on the commands \
that should stay in this process.",
wsb::daemon::NO_DAEMON_ENV
);
}
let state_dir = wsb::daemon::machine_dir().map_err(|e| anyhow::anyhow!(e))?;
let spawn = wsb::daemon::Spawn {
listen: listen.unwrap_or(wsb::daemon::LOOPBACK),
http_port: daemon_port(port),
parent_pid,
debug,
peer_port: wsb::peer::DEFAULT_PEER_PORT,
};
let (client, info, started) =
wsb::daemon::ensure_running(&state_dir, worktree_root, &spawn)
.map_err(|e| anyhow::anyhow!(e))?;
let worktree = client
.resolve_worktree(worktree_root)
.map_err(|e| anyhow::anyhow!(e))?;
let page = format!(
"http://{}/w/{}",
std::net::SocketAddr::new(info.http_address, info.http_port),
worktree.id
);
if started {
wsb::outln!("workbench started at {page} (wsb-daemon pid {})", info.pid);
if listen.is_some_and(|address| !address.is_loopback()) {
wsb::outln!(
" reachable from other machines, and the workbench has no \
authentication: anyone who can reach it can read and edit this \
worktree, run git on it and write to GitHub as this machine's \
login. Put it behind a tunnel or a TLS reverse proxy."
);
}
} else {
wsb::outln!("workbench already running at {page} (wsb-daemon pid {})", info.pid);
if listen.is_some_and(|address| address != info.http_address) {
wsb::outln!(
" --listen applies only to a daemon this command starts. \
`wsb daemon stop` and then `wsb serve --listen …` moves it."
);
}
if port.is_some_and(|asked| asked != info.http_port) {
let asked = port.expect("just checked");
wsb::outln!(
" --port applies only to a daemon this command starts. \
`wsb daemon restart --port {asked}` moves it."
);
}
}
Ok(())
}
fn runs_without_the_daemon(worktree: Option<&Path>, no_daemon: bool) -> bool {
if no_daemon {
return true;
}
let Some(worktree_root) = worktree else {
return true;
};
!has_a_commit(worktree_root)
}
fn has_a_commit(worktree_root: &Path) -> bool {
wsb::git_backend::open_repo(worktree_root)
.map(|repo| repo.head_id().is_ok())
.unwrap_or(false)
}
fn attach_to_the_daemon(acting: &Path, no_daemon: bool) -> Result<()> {
let worktree = wsb::roots::find_worktree_root(acting);
if runs_without_the_daemon(worktree.as_deref(), no_daemon) {
return Ok(());
}
let worktree_root = worktree.expect("a worktree, since the decision above found one");
let state_dir = wsb::daemon::machine_dir().map_err(|e| anyhow::anyhow!(e))?;
let spawn = wsb::daemon::Spawn {
listen: wsb::daemon::LOOPBACK,
http_port: daemon_port(None),
parent_pid: None,
debug: false,
peer_port: wsb::peer::DEFAULT_PEER_PORT,
};
let (client, _info, _started) = wsb::daemon::ensure_running(&state_dir, &worktree_root, &spawn)
.map_err(|e| anyhow::anyhow!(e))?;
wsb::output::report_to_daemon(&client);
Ok(())
}
fn daemon_port(asked: Option<u16>) -> wsb::daemon::Port {
match asked {
Some(port) => wsb::daemon::Port::Exact(port),
None => wsb::daemon::Port::FirstFreeFrom(wsb::daemon::DEFAULT_HTTP_PORT),
}
}
fn run_daemon_command(action: DaemonAction) -> Result<()> {
let state_dir = wsb::daemon::machine_dir().map_err(|e| anyhow::anyhow!(e))?;
let running = match wsb::daemon::MachineRendezvous::read(&state_dir)
.map_err(|e| anyhow::anyhow!(e))?
{
Some(rendezvous) => {
let client = wsb::daemon::Client::from_rendezvous(&rendezvous)
.map_err(|e| anyhow::anyhow!(e))?;
client.info().ok().map(|info| (client, info))
}
None => None,
};
match action {
DaemonAction::Status => match running {
Some((_, info)) => {
wsb::outln!("wsb-daemon {} (pid {})", info.version, info.pid);
wsb::outln!(
" workbench http://{}{}",
std::net::SocketAddr::new(info.http_address, info.http_port),
if info.http_address.is_loopback() {
""
} else {
" (reachable beyond this machine — no authentication)"
}
);
wsb::outln!(" machine {} ({})", info.machine_name, info.machine_id);
if info.peer_port > 0 {
wsb::outln!(
" peers {}{}",
std::net::SocketAddr::new(info.peer_address, info.peer_port),
if info.peer_address.is_loopback() {
" (this machine only — no peer can reach it)"
} else {
""
}
);
} else if let Some(why) = &info.peer_refused {
wsb::outln!(" peers not listening: {why}");
} else {
wsb::outln!(" peers off (--peer-port 0)");
}
if info.worktrees.is_empty() {
wsb::outln!(" worktrees none open yet");
} else {
wsb::outln!(" worktrees {} open", info.worktrees.len());
}
for worktree in &info.worktrees {
wsb::outln!(" {:<28} {}", worktree.id, worktree.root);
}
report_service(&state_dir, Some(info.pid));
Ok(())
}
None => {
wsb::outln!("no daemon is running on this machine");
wsb::outln!(" one starts by itself with the next command, or `wsb serve`");
report_service(&state_dir, None);
Ok(())
}
},
DaemonAction::Stop => match running {
Some((client, info)) => {
stop_daemon(&client, &info)?;
wsb::daemon::wait_until_stopped(&state_dir, &info)
.map_err(|e| anyhow::anyhow!(e))?;
wsb::outln!("stopped wsb-daemon (pid {})", info.pid);
Ok(())
}
None => {
wsb::outln!("no daemon is running on this machine");
Ok(())
}
},
DaemonAction::Restart { port } => {
if wsb::daemon::service::status(&state_dir)
.is_ok_and(|service| service.installed && service.active)
{
wsb::daemon::service::restart().map_err(|e| anyhow::anyhow!(e))?;
wsb::outln!("asked the supervisor to restart the daemon");
if let Some(port) = port {
wsb::outln!(
" --port {port} was ignored: a service is started with the port \
it was installed with (`wsb daemon install --port {port}`)"
);
}
return Ok(());
}
if let Some((client, info)) = running {
stop_daemon(&client, &info)?;
wsb::daemon::wait_until_stopped(&state_dir, &info)
.map_err(|e| anyhow::anyhow!(e))?;
wsb::outln!("stopped wsb-daemon (pid {})", info.pid);
}
let worktree_root = std::env::current_dir()?;
let spawn = wsb::daemon::Spawn {
listen: wsb::daemon::LOOPBACK,
http_port: daemon_port(port),
parent_pid: None,
debug: false,
peer_port: wsb::peer::DEFAULT_PEER_PORT,
};
let (_client, info, _started) =
wsb::daemon::ensure_running(&state_dir, &worktree_root, &spawn)
.map_err(|e| anyhow::anyhow!(e))?;
wsb::outln!(
"started wsb-daemon {} at http://127.0.0.1:{} (pid {})",
info.version,
info.http_port,
info.pid
);
Ok(())
}
DaemonAction::Log { lines } => {
let path = wsb::daemon::log_path(&state_dir);
wsb::outln!("{}", path.display());
match std::fs::read_to_string(&path) {
Ok(text) => {
let all: Vec<&str> = text.lines().collect();
for line in all.iter().skip(all.len().saturating_sub(lines)) {
wsb::outln!("{line}");
}
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
wsb::outln!(" (no daemon has written to it yet)");
Ok(())
}
Err(e) => Err(anyhow::anyhow!("cannot read {}: {e}", path.display())),
}
}
DaemonAction::Install { port, peer_port } => {
let worktree_root = std::env::current_dir()?;
let spawn = match (port, &running) {
(None, Some((_, info))) => wsb::daemon::service::spawn_for_service(
Some(info.http_port),
peer_port.or(Some(info.peer_port)),
),
_ => wsb::daemon::service::spawn_for_service(port, peer_port),
};
let record = wsb::daemon::service::install(&state_dir, &worktree_root, &spawn)
.map_err(|e| anyhow::anyhow!(e))?;
let supervisor = wsb::daemon::service::supervisor()
.map_err(|e| anyhow::anyhow!(e))?;
let definition = wsb::daemon::service::definition_path(supervisor)
.map_err(|e| anyhow::anyhow!(e))?;
wsb::outln!(
"installed the wsb daemon as a service under {}",
supervisor.describe()
);
wsb::outln!(" definition {}", definition.display());
wsb::outln!(" starts {}", record.executable.display());
wsb::outln!(" worktree {}", record.worktree_root.display());
wsb::outln!(" log {}", record.log.display());
wsb::outln!(" PATH {}", record.path);
if record.autostart {
wsb::outln!(" at login yes (it was already enabled)");
} else {
wsb::outln!(" at login no — `wsb daemon enable` starts it at login, and now");
}
Ok(())
}
DaemonAction::Enable => {
let service_pid = wsb::daemon::service::status(&state_dir)
.ok()
.filter(|service| service.active)
.and_then(|service| service.pid);
if let Some((client, info)) = &running {
if service_pid != Some(info.pid) {
wsb::outln!(
"handing over from the daemon started on demand (pid {})",
info.pid
);
stop_daemon(client, info)?;
wsb::daemon::wait_until_stopped(&state_dir, info)
.map_err(|e| anyhow::anyhow!(e))?;
}
}
let record = wsb::daemon::service::enable(&state_dir)
.map_err(|e| anyhow::anyhow!(e))?;
wsb::outln!(
"the wsb daemon starts at login, and is running now (worktree {})",
record.worktree_root.display()
);
report_linger(&state_dir);
Ok(())
}
DaemonAction::Disable => {
wsb::daemon::service::disable(&state_dir).map_err(|e| anyhow::anyhow!(e))?;
wsb::outln!("the wsb daemon no longer starts at login");
match running {
Some((_, info)) => wsb::outln!(
" the one running now (pid {}) keeps running; `wsb daemon stop` ends it",
info.pid
),
None => wsb::outln!(" one still starts by itself with the next command"),
}
Ok(())
}
DaemonAction::Uninstall => {
match wsb::daemon::service::uninstall(&state_dir).map_err(|e| anyhow::anyhow!(e))? {
true => {
wsb::outln!("removed the wsb daemon service from this machine");
wsb::outln!(" one still starts by itself with the next command");
}
false => wsb::outln!("no wsb daemon service is installed on this machine"),
}
Ok(())
}
}
}
fn report_service(state_dir: &Path, daemon_pid: Option<u32>) {
let Ok(service) = wsb::daemon::service::status(state_dir) else {
return;
};
if !service.installed {
return;
}
wsb::outln!(
" service {} — {}",
service.supervisor.describe(),
if service.autostart {
"starts at login"
} else {
"installed, does not start at login"
}
);
match (service.active, service.pid) {
(true, Some(pid)) if Some(pid) == daemon_pid => {
wsb::outln!(" the daemon above is the one it is keeping")
}
(true, Some(pid)) => wsb::outln!(
" it is running pid {pid}, which is not the daemon above"
),
(true, None) => wsb::outln!(" it is running"),
(false, _) => wsb::outln!(" it is not running one right now"),
}
if service.drifted {
wsb::outln!(
" the definition is not what this wsb would write — \
run `wsb daemon install` again"
);
}
if service.autostart && service.linger == Some(false) {
wsb::outln!(
" \"at login\" means at login: without lingering this user's \
services stop at logout (`loginctl enable-linger $USER`)"
);
}
}
fn report_linger(state_dir: &Path) {
let Ok(service) = wsb::daemon::service::status(state_dir) else {
return;
};
if service.linger == Some(false) {
wsb::outln!(
" this user does not linger, so its services stop at logout: \
`loginctl enable-linger $USER` keeps the daemon across sessions"
);
}
}
fn stop_daemon(client: &wsb::daemon::Client, info: &wsb::daemon::Info) -> Result<()> {
client
.shutdown()
.map_err(|error| anyhow::anyhow!("cannot stop wsb-daemon (pid {}): {error}", info.pid))
}
fn handle_code_command(action: CodeAction, project_root: &Path) -> Result<()> {
use wsb::omnicode::query::{Question, SearchMode};
let (question, repo) = match action {
CodeAction::Tree { depth, hidden, sizes, extensions, no_ignore } => {
return show_codebase_tree(depth, hidden, sizes, extensions, no_ignore);
}
CodeAction::Index { repo } => (Question::Index, repo),
CodeAction::Search { pattern, regex, symbols, structural, include, exclude, repo } => {
let mode = match (regex, symbols, structural) {
(true, _, _) => SearchMode::Regex,
(_, true, _) => SearchMode::Symbols,
(_, _, true) => SearchMode::Structural,
_ => SearchMode::Literal,
};
(Question::Search { pattern, mode, include, exclude }, repo)
}
CodeAction::Symbols { pattern, kind, repo } => (Question::Symbols { pattern, kind }, repo),
CodeAction::Refs { target, repo } => (Question::Refs { target }, repo),
CodeAction::Callers { target, depth, repo } => (Question::Callers { target, depth }, repo),
CodeAction::Callees { target, depth, repo } => (Question::Callees { target, depth }, repo),
CodeAction::Diagnostics { file, repo } => (Question::Diagnostics { file }, repo),
};
let worktree = roots::find_super_root(project_root).unwrap_or_else(|| project_root.to_path_buf());
wsb::omnicode::cli::code(&worktree, repo.as_deref(), &question).map_err(|e| anyhow::anyhow!(e))
}
fn dirs_from_root_to(root: &Path, leaf: &Path) -> Vec<PathBuf> {
let Ok(relative) = leaf.strip_prefix(root) else {
return vec![leaf.to_path_buf()];
};
let mut dirs = vec![root.to_path_buf()];
let mut current = root.to_path_buf();
for component in relative.components() {
current = current.join(component);
dirs.push(current.clone());
}
dirs
}
fn show_codebase_tree(
depth: usize,
show_hidden: bool,
show_sizes: bool,
extensions_filter: Option<String>,
no_ignore: bool,
) -> Result<()> {
use colored::Colorize;
use ignore::gitignore::GitignoreBuilder;
let current_dir = std::env::current_dir()?;
let project_root = find_project_root(¤t_dir);
wsb::outln!("{}", "📁 Codebase Structure".bright_blue().bold());
wsb::outln!(
"{} {}",
"Project Root:".bright_green(),
project_root.display()
);
wsb::outln!(
"{} {}",
"Current Location:".bright_yellow(),
current_dir.display()
);
if current_dir != project_root {
let relative_path = current_dir
.strip_prefix(&project_root)
.unwrap_or(¤t_dir);
wsb::outln!(
"{} {}",
"Relative Path:".bright_cyan(),
relative_path.display()
);
}
wsb::outln!();
let extensions: Option<Vec<String>> = extensions_filter.map(|ext_str| {
ext_str
.split(',')
.map(|s| s.trim().to_lowercase())
.collect()
});
let gitignore = if no_ignore {
None
} else {
let ignore_root = match wsb::git_backend::repo_root(&project_root) {
Ok(root) => root,
Err(_) => project_root.clone(),
};
let mut builder = GitignoreBuilder::new(&ignore_root);
for dir in dirs_from_root_to(&ignore_root, &project_root) {
let candidate = dir.join(".gitignore");
if !candidate.is_file() {
continue;
}
if let Some(error) = builder.add(&candidate) {
anyhow::bail!("failed to read {}: {}", candidate.display(), error);
}
}
Some(
builder
.build()
.context("failed to compile .gitignore rules for the codebase tree")?,
)
};
display_tree(
&project_root,
"",
depth,
0,
show_hidden,
show_sizes,
&extensions,
&gitignore,
)?;
Ok(())
}
fn find_project_root(current: &Path) -> std::path::PathBuf {
let mut path = current.to_path_buf();
let project_markers = [
"Cargo.toml",
"package.json",
"pyproject.toml",
"setup.py",
"composer.json",
"pom.xml",
"build.gradle",
"CMakeLists.txt",
".git",
".svn",
".hg",
"Makefile",
"go.mod",
];
loop {
for marker in &project_markers {
if path.join(marker).exists() {
return path;
}
}
if !path.pop() {
break;
}
}
current.to_path_buf()
}
fn display_tree(
dir: &Path,
prefix: &str,
max_depth: usize,
current_depth: usize,
show_hidden: bool,
show_sizes: bool,
extensions: &Option<Vec<String>>,
gitignore: &Option<ignore::gitignore::Gitignore>,
) -> Result<()> {
if current_depth >= max_depth {
return Ok(());
}
let entries = fs::read_dir(dir)?;
let mut entries: Vec<_> = entries.collect::<Result<Vec<_>, _>>()?;
if let Some(ref gi) = gitignore {
entries.retain(|entry| {
let path = entry.path();
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
let matched = gi.matched(&path, is_dir);
!matched.is_ignore()
});
}
entries.sort_by_key(|entry| {
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
(!is_dir, entry.file_name())
});
let total_entries = entries.len();
for (index, entry) in entries.iter().enumerate() {
let file_name = entry.file_name();
let file_name_str = file_name.to_string_lossy();
let path = entry.path();
let is_last = index == total_entries - 1;
if !show_hidden && file_name_str.starts_with('.') {
continue;
}
let is_dir = entry.file_type()?.is_dir();
let connector = if is_last { "└── " } else { "├── " };
let new_prefix = if is_last { " " } else { "│ " };
if !is_dir {
if let Some(ref exts) = extensions {
if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
if !exts.contains(&ext_str) {
continue;
}
} else if !exts.is_empty() {
continue;
}
}
}
wsb::out!("{}{}", prefix, connector);
if is_dir {
wsb::out!("{}", file_name_str.bright_blue().bold());
} else {
let colored_name = match path.extension().and_then(|s| s.to_str()) {
Some("rs") => file_name_str.bright_red(),
Some("py") => file_name_str.bright_yellow(),
Some("js" | "ts" | "jsx" | "tsx") => file_name_str.bright_green(),
Some("json" | "yaml" | "yml" | "toml") => file_name_str.bright_cyan(),
Some("md" | "txt" | "doc") => file_name_str.white(),
Some("sh" | "bash" | "zsh") => file_name_str.bright_magenta(),
_ => file_name_str.normal(),
};
wsb::out!("{}", colored_name);
}
if show_sizes && !is_dir {
if let Ok(metadata) = entry.metadata() {
let size = format_file_size(metadata.len());
wsb::out!(" {}", size.dimmed());
}
}
wsb::outln!();
if is_dir && current_depth + 1 < max_depth {
let next_prefix = format!("{}{}", prefix, new_prefix);
display_tree(
&path,
&next_prefix,
max_depth,
current_depth + 1,
show_hidden,
show_sizes,
extensions,
gitignore,
)?;
}
}
Ok(())
}
fn format_file_size(bytes: u64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
let mut size = bytes as f64;
let mut unit_index = 0;
while size >= 1024.0 && unit_index < UNITS.len() - 1 {
size /= 1024.0;
unit_index += 1;
}
if unit_index == 0 {
format!("{} {}", bytes, UNITS[unit_index])
} else {
format!("{:.1} {}", size, UNITS[unit_index])
}
}
fn run_version_command(action: VersionAction, ctx: &AppContext) -> Result<()> {
match action {
VersionAction::Show { verbose, format } => handle_version_show(verbose, format, ctx),
VersionAction::Major { version } => handle_version_major(version, ctx),
VersionAction::Tag { prefix, message } => handle_version_tag(prefix, message, ctx),
VersionAction::Info { include_history } => handle_version_info(include_history, ctx),
}
}
fn current_version(ctx: &AppContext) -> Result<(RepoSettings, VersionInfo)> {
let settings = RepoSettings::require(&ctx.repo_root)?;
let mut info = VersionInfo::calculate_with_major_in_repo(settings.version.major, &ctx.repo_root)?;
info.full_version = settings.full_version(&info.major_version, info.minor_version, info.patch_version, None);
Ok((settings, info))
}
fn handle_version_show(verbose: bool, format: String, ctx: &AppContext) -> Result<()> {
let (settings, version_info) = current_version(ctx)?;
let major = settings.version.major;
let calc_info = if verbose {
Some(VersionInfo::get_calculation_info_in_repo(major, &ctx.repo_root)?)
} else {
None
};
match format.as_str() {
"json" => {
let mut json_output = serde_json::json!({
"version": version_info.full_version,
"major": major,
"minor": version_info.minor_version,
"patch": version_info.patch_version,
"repository": settings.name,
});
if let Some(calc_info) = calc_info {
json_output["calculation"] = serde_json::json!({
"total_commits": calc_info.total_commits,
"changes_since_release": calc_info.changes_since_release,
"last_release_tag": calc_info.last_release_tag,
"method": calc_info.calculation_method
});
}
wsb::outln!("{}", serde_json::to_string_pretty(&json_output)?);
}
_ => {
wsb::outln!("{} {}", "Version:".blue().bold(), version_info.full_version.green().bold());
wsb::outln!("{} {}", "Repository:".blue(), settings.name);
if let Some(calc_info) = calc_info {
wsb::outln!("\n{}", "Calculation Breakdown:".blue().bold());
wsb::outln!(" {} {}", "Major (.wsb/repo.json):".blue(), major.to_string().yellow());
wsb::outln!(" {} {}", "Minor (commits):".blue(), version_info.minor_version.to_string().yellow());
wsb::outln!(" {} {}", "Patch (changes):".blue(), version_info.patch_version.to_string().yellow());
match calc_info.last_release_tag {
Some(tag) => wsb::outln!(" {} {}", "Last release tag:".blue(), tag.green()),
None => wsb::outln!(" {} {}", "Last release tag:".blue(), "none".yellow()),
}
wsb::outln!(" {} {}", "Method:".blue(), calc_info.calculation_method.cyan());
}
}
}
Ok(())
}
fn handle_version_major(version: u32, ctx: &AppContext) -> Result<()> {
let mut settings = RepoSettings::require(&ctx.repo_root)?;
settings.version.major = version;
settings.save(&ctx.repo_root)?;
let (_, version_info) = current_version(ctx)?;
info!("Major version set to {}, new version: {}", version, version_info.full_version);
wsb::outln!(
"{} Major version set to {} in {} — commit it",
"✅".green(),
version.to_string().green().bold(),
RepoSettings::path(&ctx.repo_root).display()
);
wsb::outln!("{} New version: {}", "🔢".blue(), version_info.full_version.green().bold());
wsb::outln!("\n{} {}", "Note:".blue(), "Minor/patch are calculated from git state");
wsb::outln!("{} Use 'wsb version tag' to create a release tag", "💡".yellow());
Ok(())
}
fn handle_version_tag(prefix: String, message: Option<String>, ctx: &AppContext) -> Result<()> {
let (_, version_info) = current_version(ctx)?;
let tag_name = format!("{}{}", prefix, version_info.full_version);
let tag_message = message.unwrap_or_else(|| format!("Release version {}", version_info.full_version));
let repo = wsb::git_backend::discover_repo(&ctx.repo_root)?;
wsb::git_backend::create_tag(&repo, &tag_name, &tag_message)?;
info!("Created git tag: {}", tag_name);
wsb::outln!("{} Created git tag: {}", "✅".green(), tag_name.green().bold());
wsb::outln!("{} Message: {}", "📝".blue(), tag_message);
wsb::outln!("\n{} To push tag to remote: git push origin {}", "💡".yellow(), tag_name);
Ok(())
}
fn handle_version_info(include_history: bool, ctx: &AppContext) -> Result<()> {
let (settings, version_info) = current_version(ctx)?;
let major_version = settings.version.major;
let calc_info = VersionInfo::get_calculation_info_in_repo(major_version, &ctx.repo_root)?;
wsb::outln!("{}", "Version Calculation Information".blue().bold());
wsb::outln!("=====================================");
wsb::outln!();
wsb::outln!("{}", "Current Version Breakdown:".green().bold());
wsb::outln!(" {} {}", "Version:".blue(), version_info.full_version.green());
wsb::outln!(
" {} {} (committed in .wsb/repo.json)",
"Major:".blue(),
major_version.to_string().green()
);
wsb::outln!(
" {} {} (total git commits)",
"Minor:".blue(),
version_info.minor_version.to_string().yellow()
);
wsb::outln!(
" {} {} (changes since last v{}.* tag)",
"Patch:".blue(),
version_info.patch_version.to_string().cyan(),
major_version
);
wsb::outln!();
wsb::outln!("{}", "Git Integration:".green().bold());
if let Some(ref git_root) = calc_info.git_root {
wsb::outln!(" {} {}", "Git Root:".blue(), git_root.display().to_string().cyan());
}
match calc_info.last_release_tag {
Some(ref tag) => wsb::outln!(" {} {}", "Last Release Tag:".blue(), tag.green()),
None => wsb::outln!(" {} {}", "Last Release Tag:".blue(), "none found".yellow()),
}
wsb::outln!();
wsb::outln!("{}", "Commands:".green().bold());
wsb::outln!(" {} Set major version", "wsb version major <number>".cyan());
wsb::outln!(" {} Create release tag", "wsb version tag".cyan());
wsb::outln!(" {} Show current version", "wsb version show".cyan());
wsb::outln!();
if include_history {
wsb::outln!("{}", "Git History Analysis:".green().bold());
let repo = wsb::git_backend::discover_repo(&ctx.repo_root)?;
let tags = wsb::git_backend::list_tags(&repo)?;
if tags.is_empty() {
wsb::outln!(" {} {}", "Recent Tags:".blue(), "none found".yellow());
} else {
wsb::outln!(" {}:", "Recent Tags".blue());
for (i, tag) in tags.iter().take(5).enumerate() {
wsb::outln!(" {}. {}", i + 1, tag.green());
}
}
wsb::outln!();
}
Ok(())
}
fn run_migrate_command(cwd: &Path) -> Result<()> {
let report = wsb::cli::annotations::migrate(cwd)?;
for line in &report.done {
wsb::outln!(" {} {line}", "✓".green());
}
for line in &report.refused {
wsb::errln!(" {} {line}", "✗".red());
}
if report.done.is_empty() && report.refused.is_empty() {
wsb::outln!("nothing of an older wsb's layout is left on this machine");
}
if !report.refused.is_empty() {
anyhow::bail!(
"{} item(s) were left as they were; deal with each and run `wsb migrate` again",
report.refused.len()
);
}
Ok(())
}
fn body_of(body: Option<String>, body_file: Option<PathBuf>) -> Result<Option<String>> {
match (body, body_file) {
(Some(text), None) => Ok(Some(text)),
(None, Some(path)) if path.as_os_str() == "-" => {
let mut text = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut text).context("reading the body from standard input")?;
Ok(Some(text))
}
(None, Some(path)) => Ok(Some(
std::fs::read_to_string(&path).with_context(|| format!("reading the body from {}", path.display()))?,
)),
(None, None) => Ok(None),
(Some(_), Some(_)) => anyhow::bail!("give --body or --body-file, not both"),
}
}
fn print_note(held: &wsb::annotations::HeldNote) {
let places: Vec<String> = held.places.iter().map(|place| format!("{}/{}", place.worktree, place.worktree)).collect();
wsb::outln!("{} {}", held.note.id.dimmed(), held.note.title.bold());
wsb::outln!(" written {} on {}; in {}", held.note.created, held.note.machine, places.join(", "));
}
fn run_note_command(action: NoteAction, cwd: &Path) -> Result<()> {
match action {
NoteAction::Add { title, body, body_file, about } => {
let body = body_of(body, body_file)?.unwrap_or_default();
let machine = wsb::daemon::machine_name().map_err(anyhow::Error::msg)?;
let (note, written) = wsb::cli::annotations::note_add(cwd, &title, &body, &about.targets(), &machine)?;
wsb::outln!("{} wrote note {} ({})", "✓".green(), note.id, note.title);
for path in &written {
wsb::outln!(" {}", path.display());
}
wsb::outln!("Commit them to share them with every clone.");
}
NoteAction::List { on } => {
let index = wsb::cli::annotations::index()?;
let on = on.map(|text| text.parse::<wsb::annotations::Ref>()).transpose()?;
let notes = wsb::cli::annotations::notes_about(&index, on.as_ref());
for problem in &index.problems {
wsb::errln!(" {} {problem}", "!".yellow());
}
if notes.is_empty() {
wsb::outln!("no notes");
}
for held in notes {
print_note(held);
}
}
NoteAction::Show { id } => {
let index = wsb::cli::annotations::index()?;
let versions = index.note(&id);
if versions.is_empty() {
anyhow::bail!("no worktree in the workshop holds a note {id}");
}
if versions.len() > 1 {
wsb::outln!("{} worktrees hold {} versions of this note", versions.iter().map(|v| v.places.len()).sum::<usize>(), versions.len());
}
let note = Ref::Note { id: id.clone() };
for held in versions {
print_note(held);
wsb::outln!();
wsb::outln!("{}", held.note.body);
}
for link in index.links_at(¬e) {
if let Some(other) = link.link.other_end(¬e) {
wsb::outln!(" → {other}");
}
}
}
NoteAction::Edit { id, title, body, body_file } => {
let body = body_of(body, body_file)?;
let (note, path) = wsb::cli::annotations::note_edit(cwd, &id, title.as_deref(), body.as_deref())?;
wsb::outln!("{} rewrote note {} ({}) in {}", "✓".green(), note.id, note.title, path.display());
}
NoteAction::Rm { id } => {
for path in wsb::cli::annotations::note_rm(cwd, &id)? {
wsb::outln!("{} removed {}", "✓".green(), path.display());
}
}
}
Ok(())
}
fn run_link_command(action: LinkAction, cwd: &Path) -> Result<()> {
match action {
LinkAction::Add { from, to, label } => {
let path = wsb::cli::annotations::link_add(cwd, &from.parse()?, &to.parse()?, label.as_deref())?;
wsb::outln!("{} linked {from} → {to} ({}); commit it to share it", "✓".green(), path.display());
}
LinkAction::List { at } => {
let index = wsb::cli::annotations::index()?;
let at = at.map(|text| text.parse::<wsb::annotations::Ref>()).transpose()?;
for problem in &index.problems {
wsb::errln!(" {} {problem}", "!".yellow());
}
let links = wsb::cli::annotations::links_at(&index, at.as_ref());
if links.is_empty() {
wsb::outln!("no links");
}
for held in links {
let label = held.link.label.as_deref().map(|label| format!(" [{label}]")).unwrap_or_default();
wsb::outln!("{} → {}{label}", held.link.from, held.link.to);
}
}
LinkAction::Rm { from, to, label } => {
let path = wsb::cli::annotations::link_rm(cwd, &from.parse()?, &to.parse()?, label.as_deref())?;
wsb::outln!("{} removed {}", "✓".green(), path.display());
}
}
Ok(())
}
fn handle_test_command(
dry_run: bool,
install: bool,
args: Vec<String>,
project_root: &Path,
) -> Result<()> {
use wsb::state::detect_project_files;
let project_files = detect_project_files(&project_root)?;
if project_files.is_empty() {
wsb::outln!(
"{} No project files detected in current directory",
"⚠️".yellow()
);
wsb::outln!("{} Supported project types: Rust (Cargo.toml), Node.js (package.json), Python (pyproject.toml), PHP (composer.json), Dart (pubspec.yaml), Java (pom.xml, build.gradle), C++ (CMakeLists.txt)", "💡".blue());
return Ok(());
}
let (test_cmd, test_args, description) = determine_test_command(&project_files)?;
wsb::outln!("{} Detected project: {}", "🔍".blue(), description);
wsb::outln!(
"{} Test command: {} {}",
"🚀".green(),
test_cmd,
test_args.join(" ")
);
if !args.is_empty() {
wsb::outln!("{} Additional args: {}", "📋".blue(), args.join(" "));
}
if dry_run {
wsb::outln!("{} Dry run - would execute:", "👀".cyan());
let mut full_args = test_args.clone();
full_args.extend(args);
wsb::outln!(" {} {}", test_cmd, full_args.join(" "));
return Ok(());
}
if install {
ensure_test_runner_available(&test_cmd, &project_files)?;
}
let mut full_args = test_args.clone();
full_args.extend(args);
wsb::outln!("{} Running tests...", "🧪".green());
let mut command = wsb::git_backend::command_without_repo_scope(&test_cmd);
command.args(&full_args);
let status = command.status()?;
if status.success() {
wsb::outln!("{} Tests completed successfully!", "✅".green());
} else {
wsb::outln!(
"{} Tests failed with exit code: {}",
"❌".red(),
status.code().unwrap_or(-1)
);
std::process::exit(status.code().unwrap_or(1));
}
Ok(())
}
fn determine_test_command(
project_files: &[wsb::state::ProjectFile],
) -> Result<(String, Vec<String>, String)> {
use wsb::state::ProjectFileType;
const PRIORITY: &[ProjectFileType] = &[
ProjectFileType::CargoToml,
ProjectFileType::PackageJson,
ProjectFileType::PyprojectToml,
ProjectFileType::SetupPy,
ProjectFileType::GoMod,
ProjectFileType::MixExs,
ProjectFileType::BuildSbt,
ProjectFileType::ShardYml,
ProjectFileType::JuliaProject,
ProjectFileType::ComposerJson,
ProjectFileType::PubspecYaml,
ProjectFileType::PomXml,
ProjectFileType::BuildGradle,
ProjectFileType::BuildGradleKts,
ProjectFileType::CMakeLists,
ProjectFileType::PackageSwift,
ProjectFileType::Gemspec,
ProjectFileType::Csproj,
];
for wanted in PRIORITY {
if project_files
.iter()
.any(|project_file| project_file.file_type == *wanted)
{
return Ok(test_command_for(wanted));
}
}
anyhow::bail!("No supported test runner found for detected project files")
}
fn test_command_for(file_type: &wsb::state::ProjectFileType) -> (String, Vec<String>, String) {
use wsb::state::ProjectFileType;
match file_type {
ProjectFileType::CargoToml => (
"cargo".to_string(),
vec![
"nextest".to_string(),
"run".to_string(),
"--no-fail-fast".to_string(),
],
"Rust (Cargo.toml)".to_string(),
),
ProjectFileType::PackageJson => (
"npm".to_string(),
vec!["test".to_string()],
"Node.js (package.json)".to_string(),
),
ProjectFileType::PyprojectToml => (
"pytest".to_string(),
vec![],
"Python (pyproject.toml)".to_string(),
),
ProjectFileType::SetupPy => (
"python".to_string(),
vec!["-m".to_string(), "pytest".to_string()],
"Python (setup.py)".to_string(),
),
ProjectFileType::ComposerJson => (
"vendor/bin/phpunit".to_string(),
vec![],
"PHP (composer.json)".to_string(),
),
ProjectFileType::PubspecYaml => (
"flutter".to_string(),
vec!["test".to_string()],
"Dart/Flutter (pubspec.yaml)".to_string(),
),
ProjectFileType::PomXml => (
"mvn".to_string(),
vec!["test".to_string()],
"Java Maven (pom.xml)".to_string(),
),
ProjectFileType::BuildGradle => (
"./gradlew".to_string(),
vec!["test".to_string()],
"Java Gradle (build.gradle)".to_string(),
),
ProjectFileType::BuildGradleKts => (
"./gradlew".to_string(),
vec!["test".to_string()],
"Kotlin Gradle (build.gradle.kts)".to_string(),
),
ProjectFileType::CMakeLists => (
"ctest".to_string(),
vec!["--output-on-failure".to_string()],
"C/C++ (CMakeLists.txt)".to_string(),
),
ProjectFileType::PackageSwift => (
"swift".to_string(),
vec!["test".to_string()],
"Swift (Package.swift)".to_string(),
),
ProjectFileType::Gemspec => (
"bundle".to_string(),
vec!["exec".to_string(), "rspec".to_string()],
"Ruby (gemspec)".to_string(),
),
ProjectFileType::Csproj => (
"dotnet".to_string(),
vec!["test".to_string()],
".NET (csproj)".to_string(),
),
ProjectFileType::GoMod => (
"go".to_string(),
vec!["test".to_string(), "./...".to_string()],
"Go (go.mod)".to_string(),
),
ProjectFileType::MixExs => (
"mix".to_string(),
vec!["test".to_string()],
"Elixir (mix.exs)".to_string(),
),
ProjectFileType::BuildSbt => (
"sbt".to_string(),
vec!["test".to_string()],
"Scala (build.sbt)".to_string(),
),
ProjectFileType::ShardYml => (
"crystal".to_string(),
vec!["spec".to_string()],
"Crystal (shard.yml)".to_string(),
),
ProjectFileType::JuliaProject => (
"julia".to_string(),
vec![
"--project=.".to_string(),
"-e".to_string(),
"using Pkg; Pkg.test()".to_string(),
],
"Julia (Project.toml)".to_string(),
),
}
}
fn ensure_test_runner_available(
test_cmd: &str,
project_files: &[wsb::state::ProjectFile],
) -> Result<()> {
use wsb::state::ProjectFileType;
if wsb::report::which(test_cmd).is_some() {
wsb::outln!(
"{} Test runner {} is already available",
"✅".green(),
test_cmd
);
return Ok(());
}
for project_file in project_files {
match project_file.file_type {
ProjectFileType::CargoToml if test_cmd.contains("nextest") => {
wsb::outln!("{} Installing cargo-nextest...", "📦".blue());
let status = wsb::git_backend::command_without_repo_scope("cargo")
.args(["install", "cargo-nextest", "--locked"])
.status()?;
if status.success() {
wsb::outln!("{} Successfully installed cargo-nextest", "✅".green());
} else {
wsb::outln!(
"{} Failed to install cargo-nextest, falling back to standard cargo test",
"⚠️".yellow()
);
}
return Ok(());
}
ProjectFileType::PyprojectToml | ProjectFileType::SetupPy
if test_cmd.contains("pytest") =>
{
wsb::outln!("{} Installing pytest...", "📦".blue());
let status = wsb::git_backend::command_without_repo_scope("pip")
.args(["install", "pytest"])
.status()?;
if status.success() {
wsb::outln!("{} Successfully installed pytest", "✅".green());
} else {
wsb::outln!("{} Failed to install pytest", "❌".red());
anyhow::bail!("Could not install pytest");
}
return Ok(());
}
_ => {}
}
}
wsb::outln!(
"{} Test runner {} not found, please install it manually",
"⚠️".yellow(),
test_cmd
);
Ok(())
}
#[cfg(test)]
mod daemon_decision_tests {
use super::runs_without_the_daemon;
fn worktree(committed: bool) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("a directory");
let git = |args: &[&str]| {
let out = wsb::git_backend::git_command()
.args(args)
.current_dir(dir.path())
.output()
.unwrap_or_else(|e| panic!("git {args:?}: {e}"));
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
};
git(&["init", "--quiet"]);
wsb::settings::RepoSettings {
name: "fixture".to_string(),
alias: "fixture".to_string(),
templates_root: ".".to_string(),
version: wsb::settings::VersionSettings::at_major(0),
}
.save(dir.path())
.expect("managed");
if committed {
std::fs::write(dir.path().join("README"), "x\n").expect("a file");
git(&["add", "README"]);
git(&["-c", "user.name=t", "-c", "user.email=t@t", "commit", "--quiet", "-m", "first"]);
}
dir
}
#[test]
fn test20013_which_runs_go_beside_the_daemon() {
let committed = worktree(true);
assert!(
!runs_without_the_daemon(Some(committed.path()), false),
"an ordinary command in a committed worktree must report to the daemon"
);
assert!(runs_without_the_daemon(Some(committed.path()), true), "--no-daemon means what it says");
assert!(runs_without_the_daemon(None, false), "no worktree, nothing to serve");
let fresh = worktree(false);
assert!(
runs_without_the_daemon(Some(fresh.path()), false),
"a worktree with no commit has no identity for a daemon to serve"
);
}
}
#[cfg(test)]
mod tests {
use super::owns_templates;
use wsb::state::RunCache;
use std::fs;
use std::path::PathBuf;
#[test]
fn test8826_ownership_follows_the_nearest_repository_boundary() {
let temp = tempfile::tempdir().expect("a scratch directory");
let root = temp.path();
let nested = root.join("family/member");
fs::create_dir_all(&nested).expect("the nested repository");
fs::write(nested.join("Cargo.toml.wstemplate"), "x").expect("the template");
let boundaries = vec![root.join("family"), nested.clone()];
let cache = RunCache::new();
assert!(
owns_templates(&nested, &boundaries, &cache)
.expect("the nested repository is scanned"),
"a repository owns the template sitting directly in it"
);
assert!(
!owns_templates(&root.join("family"), &boundaries, &cache)
.expect("the parent is scanned"),
"a parent must not claim a template that belongs to a repository nested in it"
);
fs::write(root.join("family/own.wstemplate"), "x").expect("the parent's own template");
assert!(
owns_templates(&root.join("family"), &boundaries, &RunCache::new())
.expect("the parent is scanned again"),
"a template outside every nested repository belongs to the parent"
);
let _ = PathBuf::new();
}
}
#[cfg(test)]
mod wstemplate_entry_tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn declare(repo: &Path, name: &str, path: &str) {
let modules = repo.join(".gitmodules");
let mut text = fs::read_to_string(&modules).unwrap_or_default();
text.push_str(&format!(
"[submodule \"{name}\"]\n\tpath = {path}\n\turl = git@example.com:{name}.git\n"
));
fs::write(&modules, text).unwrap();
fs::create_dir_all(repo.join(path)).unwrap();
}
#[test]
fn test11364_a_nested_project_scans_the_whole_workspace() {
let place = TempDir::new().unwrap();
let worktree = place.path();
declare(worktree, "capdag", "capdag");
declare(&worktree.join("capdag"), "capdag-objc", "capdag-objc");
let nested = worktree.join("capdag/capdag-objc");
let (alias, root) = default_alias_and_root(&nested).expect("an alias and root are derived");
assert_eq!(root, "../..", "the templates root must be the outermost superproject");
assert_eq!(alias, "capdag_objc");
}
#[test]
fn test11365_dashes_become_underscores() {
let place = TempDir::new().unwrap();
let worktree = place.path();
declare(worktree, "cartridge-sdks", "cartridge-sdks");
declare(
&worktree.join("cartridge-sdks"),
"capdag-cartridge-sdk-objc",
"capdag-cartridge-sdk-objc",
);
let nested = worktree.join("cartridge-sdks/capdag-cartridge-sdk-objc");
let (alias, _) = default_alias_and_root(&nested).expect("an alias is derived");
assert_eq!(alias, "capdag_cartridge_sdk_objc");
assert!(!alias.contains('-'), "{alias}");
}
#[test]
fn test11366_a_name_that_disagrees_with_its_directory_is_refused() {
let place = TempDir::new().unwrap();
let worktree = place.path();
declare(worktree, "something-else", "capdag-objc");
let nested = worktree.join("capdag-objc");
let error = default_alias_and_root(&nested)
.expect_err("a name that disagrees with its directory must be refused");
let said = format!("{error:#}");
assert!(said.contains("something-else"), "{said}");
assert!(said.contains("capdag-objc"), "{said}");
assert!(said.contains(".gitmodules"), "{said}");
}
#[test]
fn test11367_a_standalone_project_scans_itself() {
let place = TempDir::new().unwrap();
let alone = place.path().join("my-thing");
fs::create_dir_all(&alone).unwrap();
let (alias, root) = default_alias_and_root(&alone).expect("an alias is derived");
assert_eq!(root, ".");
assert_eq!(alias, "my_thing");
}
}