worktrunk 0.42.0

A CLI for Git worktree management, designed for parallel AI agent workflows
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
//! `wt config alias` subcommands.
//!
//! Introspection and preview for aliases configured in user config
//! (`~/.config/worktrunk/config.toml`) and project config (`.config/wt.toml`).
//! `show` prints the template text, source-labeled, with one gutter block per
//! alias entry and `# <name>` comment lines above named pipeline steps.
//! `dry-run` parses a per-invocation argument vector with the same parser
//! `wt <alias>` uses, then expands templates using the same context as
//! execution — so previews match what the real run will do. The two share a
//! layout; only the header verb differs (`:` vs ` would run:`).
//!
//! ## Why `dry-run` lives here rather than on the alias dispatch
//!
//! Previous versions exposed dry-run via `wt <alias> --dry-run`. That routed
//! through `AliasOptions::parse` and required every caller to handle the
//! "preview vs run" branch. Lifting it into a dedicated subcommand keeps the
//! alias-dispatch path single-purpose (always runs) and gives preview a
//! natural home alongside `show`.

use std::collections::{BTreeSet, HashMap};
use std::io::Write;

use anyhow::Context;
use color_print::cformat;
use worktrunk::config::{
    ALIAS_ARGS_KEY, CommandConfig, ProjectConfig, UserConfig, append_aliases,
    referenced_vars_for_config, template_references_var, validate_template_syntax,
};
use worktrunk::git::{Repository, WorktrunkError};
use worktrunk::styling::{format_bash_with_gutter, info_message, println};

use crate::commands::alias::{AliasOptions, AliasSource, TOP_LEVEL_BUILTINS};
use crate::commands::build_invalid_subcommand_error;
use crate::commands::command_executor::{
    CommandContext, build_hook_context, expand_shell_template,
};
use crate::commands::did_you_mean;

/// Show the configured template(s) for an alias, tagged by source.
///
/// When the same name is defined in both user and project config, both
/// entries are printed (user first, matching runtime execution order).
pub fn handle_alias_show(name: String) -> anyhow::Result<()> {
    let repo = Repository::current()?;
    let user_config = UserConfig::load()?;
    let project_config = ProjectConfig::load(&repo, true)?;
    let entries = entries_for_name(&repo, &user_config, project_config.as_ref(), &name);

    if entries.is_empty() {
        return Err(unknown_alias_error(
            &repo,
            &user_config,
            project_config.as_ref(),
            &name,
            "show",
        ));
    }

    warn_if_shadowed(&name);

    for (i, (cfg, source)) in entries.iter().enumerate() {
        if i > 0 {
            println!();
        }
        let bodies: Vec<String> = cfg.commands().map(|c| c.template.clone()).collect();
        println!("{}", format_entry(&name, cfg, *source, &bodies, None));
    }
    Ok(())
}

/// Emit a warning if `name` is a top-level built-in subcommand. Aliases with
/// these names are unreachable via `wt <name>` — clap matches the built-in
/// first. Reported in `show`/`dry-run` so the user finds out at the discovery
/// surface rather than silently during an invocation that never reaches the
/// alias.
fn warn_if_shadowed(name: &str) {
    if TOP_LEVEL_BUILTINS.contains(&name) {
        worktrunk::styling::eprintln!(
            "{}",
            worktrunk::styling::warning_message(cformat!(
                "Alias <bold>{name}</> is shadowed by built-in <bold>wt {name}</>"
            ))
        );
    }
}

/// Preview an alias invocation: parse the args, build the template context,
/// and print the rendered command(s) without executing.
///
/// Lazy semantics are preserved: templates referencing `vars.*` are shown
/// raw (after syntax validation) because those values resolve from git
/// config at execution time, potentially written by earlier pipeline steps.
/// Other templates expand against the current context.
pub fn handle_alias_dry_run(name: String, args: Vec<String>) -> anyhow::Result<()> {
    let repo = Repository::current()?;
    let user_config = UserConfig::load()?;
    let project_config = ProjectConfig::load(&repo, true)?;
    let entries = entries_for_name(&repo, &user_config, project_config.as_ref(), &name);

    if entries.is_empty() {
        return Err(unknown_alias_error(
            &repo,
            &user_config,
            project_config.as_ref(),
            &name,
            "dry-run",
        ));
    }

    // Reuse the real parser so previews stay aligned with runtime parsing —
    // including `--KEY=VALUE` routing and positional forwarding. When both
    // user and project configs define the alias, union the referenced vars
    // so a flag binds if any entry's template references it.
    let mut referenced: BTreeSet<String> = BTreeSet::new();
    for (cfg, _) in &entries {
        referenced.extend(referenced_vars_for_config(cfg)?);
    }
    let mut parse_args = Vec::with_capacity(1 + args.len());
    parse_args.push(name.clone());
    parse_args.extend(args);
    let (opts, warnings) = AliasOptions::parse(parse_args, &referenced)?;
    warn_if_shadowed(&name);
    for warning in &warnings {
        worktrunk::styling::eprintln!("{}", worktrunk::styling::warning_message(warning));
    }

    let wt = repo.current_worktree();
    let wt_path = wt.root().context("Failed to get worktree root")?;
    let branch = wt.branch().ok().flatten();
    let ctx = CommandContext::new(&repo, &user_config, branch.as_deref(), &wt_path, false);
    let extra_refs: Vec<(&str, &str)> = opts
        .vars
        .iter()
        .map(|(k, v)| (k.as_str(), v.as_str()))
        .collect();
    let mut context_map = build_hook_context(&ctx, &extra_refs)?;
    context_map.insert(
        ALIAS_ARGS_KEY.to_string(),
        serde_json::to_string(&opts.positional_args)
            .expect("Vec<String> serialization should never fail"),
    );

    let routing = format_routing_summary(&opts);

    for (i, (cfg, source)) in entries.iter().enumerate() {
        if i > 0 {
            println!();
        }
        let bodies: Vec<String> = cfg
            .commands()
            .map(|c| render_preview(&c.template, &context_map, &repo, &name))
            .collect::<anyhow::Result<_>>()?;
        println!(
            "{}",
            format_entry_with_routing(
                &name,
                cfg,
                *source,
                &bodies,
                Some("would run"),
                routing.as_deref()
            )
        );
    }
    Ok(())
}

/// Summarize how each CLI token routed, as `# ` comment lines suitable for the
/// top of a dry-run body. Returns `None` when nothing would have been bound or
/// forwarded — the common no-args case stays clean.
fn format_routing_summary(opts: &AliasOptions) -> Option<String> {
    if opts.vars.is_empty() && opts.positional_args.is_empty() {
        return None;
    }
    let mut lines = String::new();
    if !opts.vars.is_empty() {
        let bound = opts
            .vars
            .iter()
            .map(|(k, v)| format!("{k}={}", shell_escape::unix::escape(v.into())))
            .collect::<Vec<_>>()
            .join(", ");
        lines.push_str(&format!("# bound: {bound}\n"));
    }
    if !opts.positional_args.is_empty() {
        let args = opts
            .positional_args
            .iter()
            .map(|a| shell_escape::unix::escape(a.into()).into_owned())
            .collect::<Vec<_>>()
            .join(" ");
        lines.push_str(&format!("# args: {args}\n"));
    }
    Some(lines)
}

/// Render a single command template for preview. Mirrors execution-time lazy
/// semantics — see the module-level docstring.
fn render_preview(
    template: &str,
    context: &HashMap<String, String>,
    repo: &Repository,
    alias_name: &str,
) -> anyhow::Result<String> {
    if template_references_var(template, "vars") {
        validate_template_syntax(template, alias_name)
            .map_err(|e| anyhow::anyhow!("syntax error in alias {alias_name}: {e}"))?;
        Ok(template.to_string())
    } else {
        Ok(expand_shell_template(template, context, repo, alias_name)?)
    }
}

/// Resolve `name` against user + project config, preserving runtime execution
/// order (user first, then project).
fn entries_for_name(
    repo: &Repository,
    user_config: &UserConfig,
    project_config: Option<&ProjectConfig>,
    name: &str,
) -> Vec<(CommandConfig, AliasSource)> {
    let project_id = repo.project_identifier().ok();
    let mut entries = Vec::new();
    if let Some(cfg) = user_config.aliases(project_id.as_deref()).get(name) {
        entries.push((cfg.clone(), AliasSource::User));
    }
    if let Some(pc) = project_config
        && let Some(cfg) = pc.aliases.get(name)
    {
        entries.push((cfg.clone(), AliasSource::Project));
    }
    entries
}

/// Print an "unrecognized alias 'X'" error matching clap's `InvalidSubcommand`
/// layout — same `error:` / `tip:` / `Usage:` block and exit code 2 as
/// `wt <typo>` and `wt step <typo>`. `sub` is `"show"` or `"dry-run"`,
/// anchoring the error on the real clap subcommand so the Usage line reads
/// `Usage: wt config alias <sub> <NAME>`.
///
/// Built as a real `clap::Error` with `ErrorKind::InvalidSubcommand`, rendered
/// by clap, then string-substituted to say "alias" instead of "subcommand" —
/// the positional is an alias name, not a subcommand, so the tighter wording
/// reads more honestly at this surface. Going through clap's rendering gets
/// NO_COLOR / TTY detection, singular-vs-plural "similar" phrasing, and
/// styling correct automatically; modifying the final string is cheaper than
/// reimplementing those.
///
/// Substitutions are scoped to clap's fixed phrases, never the bare word
/// "subcommand" — otherwise an alias or typo containing the literal string
/// `subcommand` (e.g. `my-subcommand`) would be mangled when echoed into
/// the error. Plural `subcommands` is rewritten before singular `subcommand`
/// because `"similar subcommand"` is a prefix of `"similar subcommands"`.
///
/// Returns `AlreadyDisplayed { exit_code: 2 }` rather than calling
/// `process::exit`, so `main`'s `finish_command` still runs `terminate_output`
/// (ANSI reset for shell integration) and `diagnostic::write_if_verbose`.
fn unknown_alias_error(
    repo: &Repository,
    user_config: &UserConfig,
    project_config: Option<&ProjectConfig>,
    name: &str,
    sub: &str,
) -> anyhow::Error {
    let project_id = repo.project_identifier().ok();
    let mut merged = user_config.aliases(project_id.as_deref());
    if let Some(pc) = project_config {
        append_aliases(&mut merged, &pc.aliases);
    }
    let suggestions = did_you_mean(name, merged.into_keys());

    let mut top = crate::cli::build_command();
    let sub_cmd = top
        .find_subcommand_mut("config")
        .expect("`config` subcommand is defined in the CLI")
        .find_subcommand_mut("alias")
        .expect("`config alias` subcommand is defined in the CLI")
        .find_subcommand_mut(sub)
        .unwrap_or_else(|| panic!("`config alias {sub}` subcommand is defined in the CLI"));
    // `render_usage` needs `bin_name`; clap only sets it on match, so when we
    // synthesize the error ahead of that, set it to the display_name
    // `apply_help_template_recursive` would apply.
    sub_cmd.set_bin_name(format!("wt config alias {sub}"));
    let err = build_invalid_subcommand_error(sub_cmd, name, suggestions);

    let rewritten = err
        .render()
        .ansi()
        .to_string()
        .replace("unrecognized subcommand", "unrecognized alias")
        .replace("similar subcommands", "similar aliases")
        .replace("similar subcommand", "similar alias");

    let mut stream = anstream::AutoStream::auto(std::io::stderr());
    let _ = write!(stream, "{rewritten}");
    WorktrunkError::AlreadyDisplayed { exit_code: 2 }.into()
}

/// Format one alias entry: `○ Alias <name> (<source>)[ <verb>]:` header
/// followed by a single gutter block of the command bodies. Each named step
/// gets a `# <name>` comment line above its body; anonymous steps render the
/// body alone. Joining into one block matches the old `--dry-run` layout and
/// keeps `show`/`dry-run` visually aligned — the only difference is the verb.
fn format_entry(
    name: &str,
    cfg: &CommandConfig,
    source: AliasSource,
    bodies: &[String],
    verb: Option<&str>,
) -> String {
    format_entry_with_routing(name, cfg, source, bodies, verb, None)
}

/// As `format_entry`, with optional routing comment lines prepended to the
/// body. Used by `dry-run` to surface `--KEY` bindings and forwarded args.
fn format_entry_with_routing(
    name: &str,
    cfg: &CommandConfig,
    source: AliasSource,
    bodies: &[String],
    verb: Option<&str>,
    routing: Option<&str>,
) -> String {
    let label = source.label();
    let suffix = match verb {
        Some(v) => format!(" {v}:"),
        None => ":".to_string(),
    };
    let mut body = String::new();
    if let Some(routing) = routing {
        body.push_str(routing);
    }
    for (cmd, rendered) in cfg.commands().zip(bodies) {
        if !body.is_empty() && !body.ends_with('\n') {
            body.push('\n');
        }
        if let Some(step_name) = &cmd.name {
            body.push_str(&format!("# {step_name}\n"));
        }
        body.push_str(rendered);
    }
    info_message(cformat!(
        "Alias <bold>{name}</> ({label}){suffix}\n{}",
        format_bash_with_gutter(&body)
    ))
    .to_string()
}

#[cfg(test)]
mod tests {
    use super::*;
    use ansi_str::AnsiStr;

    fn cfg_from_toml(toml_str: &str) -> CommandConfig {
        #[derive(serde::Deserialize)]
        struct Wrap {
            cmd: CommandConfig,
        }
        toml::from_str::<Wrap>(toml_str).unwrap().cmd
    }

    #[test]
    fn test_format_entry_show_single() {
        let cfg = cfg_from_toml(r#"cmd = "echo {{ branch }}""#);
        let bodies: Vec<String> = cfg.commands().map(|c| c.template.clone()).collect();
        let out = format_entry("greet", &cfg, AliasSource::User, &bodies, None);
        insta::assert_snapshot!(out.ansi_strip());
    }

    #[test]
    fn test_format_entry_show_pipeline() {
        let cfg = cfg_from_toml(
            r#"
cmd = [
    { install = "npm install" },
    { build = "npm run build", lint = "npm run lint" },
]
"#,
        );
        let bodies: Vec<String> = cfg.commands().map(|c| c.template.clone()).collect();
        let out = format_entry("deploy", &cfg, AliasSource::Project, &bodies, None);
        insta::assert_snapshot!(out.ansi_strip());
    }

    #[test]
    fn test_format_entry_dry_run_pipeline() {
        // The verb only changes the header suffix — body layout is identical.
        let cfg = cfg_from_toml(
            r#"
cmd = [
    { install = "npm install" },
    { build = "npm run build", lint = "npm run lint" },
]
"#,
        );
        let bodies: Vec<String> = cfg.commands().map(|c| c.template.clone()).collect();
        let out = format_entry(
            "deploy",
            &cfg,
            AliasSource::Project,
            &bodies,
            Some("would run"),
        );
        insta::assert_snapshot!(out.ansi_strip());
    }
}