gwm-cli 1.6.1

git worktree manager — TUI + CLI, native libgit2, per-repo bootstrap
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
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
//! Unit tests for the `aliases` module (issue #86).
//!
//! TDD canary for the alias resolution chain. The matrix verifies:
//!   - Parsing the `[aliases]` block from `.gwm.toml` and from
//!     `~/.config/gwm/aliases.toml` (user-level fallback).
//!   - Resolution order: built-in subcommands always win, then repo
//!     `.gwm.toml`, then user `aliases.toml` (last fallback).
//!   - Argv expansion is string-only — `gwm wip` becomes a token
//!     sequence, not a shell command.
//!   - Shell pipelines (`&&`, `|`, `;`, backticks) in alias values are
//!     rejected at load time so `wip = "create … && lazygit"` never
//!     silently passes through to the dispatcher.
//!   - A repo alias whose name collides with a built-in subcommand is
//!     a hard config error surfaced by `Config::load_for_repo`.

use gwm::aliases::{self, BUILT_IN_ALIASES};
use gwm::config::{resolve_gwm_config_file, Config, CONFIG_FILE};
use gwm::error::GwmError;
use tempfile::TempDir;

// ---- User-level file location (#374) ------------------------------------

#[test]
fn user_alias_file_resolves_under_dotconfig() {
  // The user-level `aliases.toml` must be read from the documented
  // `~/.config/gwm/aliases.toml` on every platform — same fix as the global
  // config (#372/#374), not only where `dirs::config_dir()` == `~/.config`.
  // `default_user_path` delegates to this shared resolver with `var_os`, so
  // pinning the resolver for `aliases.toml` pins the user-level location.
  let home = TempDir::new().unwrap();
  let platform = TempDir::new().unwrap(); // stand-in for Application Support
  let dir = home.path().join(".config").join("gwm");
  std::fs::create_dir_all(&dir).unwrap();
  let aliases_file = dir.join("aliases.toml");
  std::fs::write(&aliases_file, "").unwrap();

  let resolved = resolve_gwm_config_file(
    "aliases.toml",
    None, // $XDG_CONFIG_HOME unset
    Some(home.path()),
    Some(platform.path()),
    |p| p.exists(),
  );
  assert_eq!(
    resolved,
    Some(aliases_file),
    "user aliases at the documented ~/.config path must be honoured"
  );
}

// ---- Parsing ------------------------------------------------------------

#[test]
fn config_aliases_default_is_empty() {
  // Absent `[aliases]` block must resolve to an empty map — never `None`
  // or a placeholder set. This is the "aliasing disabled" no-op contract
  // from the issue: zero churn for repos that never opt in.
  let cfg = Config::default();
  assert!(cfg.aliases.is_empty());
}

#[test]
fn config_aliases_round_trip_through_toml() {
  let dir = TempDir::new().unwrap();
  std::fs::write(
    dir.path().join(CONFIG_FILE),
    r#"
[aliases]
wip = "create feat 0 wip"
ll = "list --format names"
"#,
  )
  .unwrap();

  let cfg = Config::load_layered(dir.path(), None).unwrap();
  assert_eq!(cfg.aliases.len(), 2);
  assert_eq!(cfg.aliases.get("wip").map(String::as_str), Some("create feat 0 wip"));
  assert_eq!(cfg.aliases.get("ll").map(String::as_str), Some("list --format names"));
}

#[test]
fn config_aliases_rejects_shadow_of_built_in_subcommand() {
  // `list` is a built-in subcommand; aliasing it would silently shadow
  // the built-in (or, worse, infinite-loop the expansion). Refuse at
  // load time so the user finds out before reaching for the alias.
  let dir = TempDir::new().unwrap();
  std::fs::write(
    dir.path().join(CONFIG_FILE),
    r#"
[aliases]
list = "create feat 0 wip"
"#,
  )
  .unwrap();

  let err = Config::load_layered(dir.path(), None).unwrap_err();
  match err {
    GwmError::Config(msg) => {
      assert!(msg.contains("list"), "error must name the offending alias: {msg}");
      assert!(msg.contains("built-in"), "error must mention built-in shadowing: {msg}");
    }
    other => panic!("expected GwmError::Config, got {other:?}"),
  }
}

#[test]
fn config_aliases_rejects_shadow_of_visible_built_in_alias() {
  // `s` is a visible alias of `switch` (issue #43); `cd` is a visible
  // alias of `path`. Both are reachable as `gwm s` / `gwm cd`, so an
  // alias declaration that would shadow them must fail at load time —
  // otherwise `gwm s` resolves to the user's alias and the built-in
  // becomes unreachable without anyone noticing.
  for shadow in ["s", "cd"] {
    let dir = TempDir::new().unwrap();
    std::fs::write(
      dir.path().join(CONFIG_FILE),
      format!(
        r#"
[aliases]
{shadow} = "create feat 0 wip"
"#
      ),
    )
    .unwrap();
    let err = Config::load_layered(dir.path(), None).unwrap_err();
    match err {
      GwmError::Config(msg) => {
        assert!(msg.contains(shadow), "error must name '{shadow}': {msg}");
      }
      other => panic!("expected GwmError::Config for '{shadow}', got {other:?}"),
    }
  }
}

#[test]
fn config_aliases_rejects_shell_pipeline_in_value() {
  // `&&`, `||`, `|`, `;` and backticks are shell metachars — they
  // cannot be honoured by a string-substitution expansion that hands
  // argv to clap. Refuse at load so users notice the limit (and reach
  // for a shell alias instead) rather than silently dropping the
  // suffix.
  for bad_value in [
    "create feat 0 wip && lazygit",
    "path | pbcopy",
    "list ; remove x",
    "echo `whoami`",
  ] {
    let dir = TempDir::new().unwrap();
    std::fs::write(
      dir.path().join(CONFIG_FILE),
      format!(
        r#"
[aliases]
copy = "{bad_value}"
"#
      ),
    )
    .unwrap();
    let err = Config::load_layered(dir.path(), None).unwrap_err();
    match err {
      GwmError::Config(msg) => {
        assert!(
          msg.contains("copy"),
          "error must name the offending alias 'copy': {msg}"
        );
      }
      other => panic!("expected GwmError::Config for {bad_value:?}, got {other:?}"),
    }
  }
}

#[test]
fn config_aliases_rejects_empty_value() {
  // `wip = ""` is meaningless — there's no command to expand to.
  // Refuse at load time so the user notices the typo.
  let dir = TempDir::new().unwrap();
  std::fs::write(
    dir.path().join(CONFIG_FILE),
    r#"
[aliases]
wip = ""
"#,
  )
  .unwrap();
  let err = Config::load_layered(dir.path(), None).unwrap_err();
  match err {
    GwmError::Config(msg) => assert!(msg.contains("wip"), "{msg}"),
    other => panic!("expected GwmError::Config, got {other:?}"),
  }
}

// ---- ResolvedAliases -----------------------------------------------------

#[test]
fn resolved_aliases_contains_built_in_set() {
  // The built-in row must be observable from `gwm aliases list` so
  // users can answer "why does `gwm s` work without being declared?".
  // Empty repo + no user config still surfaces every visible-alias
  // entry from clap.
  let dir = TempDir::new().unwrap();
  let resolved = aliases::load_layered(Some(dir.path()), None, None).unwrap();
  assert!(!resolved.built_in.is_empty());
  // Sanity: `s → switch` is the canonical example from issue #43;
  // it MUST live in the built-in list for the chain to be honest.
  let s_entry = resolved
    .built_in
    .iter()
    .find(|e| e.name == "s")
    .expect("built-in 's' alias must be present");
  assert_eq!(s_entry.expansion, "switch");
}

#[test]
fn resolved_aliases_repo_overrides_user_for_same_name() {
  // Repo-level beats user-level: a user with `copy = "path foo"` in
  // `~/.config/gwm/aliases.toml` and a repo `.gwm.toml` declaring
  // `copy = "path bar"` must see the repo expansion when `gwm copy`
  // is invoked inside that repo. The order matters because repo
  // aliases follow the repo across machines while user aliases don't.
  let repo_dir = TempDir::new().unwrap();
  std::fs::write(
    repo_dir.path().join(CONFIG_FILE),
    r#"
[aliases]
copy = "path bar"
"#,
  )
  .unwrap();

  let user_dir = TempDir::new().unwrap();
  std::fs::write(
    user_dir.path().join("aliases.toml"),
    r#"
[aliases]
copy = "path foo"
ll = "list --format names"
"#,
  )
  .unwrap();
  let user_path = user_dir.path().join("aliases.toml");

  let resolved = aliases::load_layered(Some(repo_dir.path()), None, Some(&user_path)).unwrap();

  // Repo entry present and wins for "copy".
  assert_eq!(resolved.repo.get("copy").map(String::as_str), Some("path bar"));
  assert_eq!(resolved.user.get("copy").map(String::as_str), Some("path foo"));

  // The effective lookup must return the repo expansion.
  let argv = aliases::expand_argv(vec!["gwm".into(), "copy".into()], &resolved);
  assert_eq!(argv, vec!["gwm", "path", "bar"]);
}

#[test]
fn resolved_aliases_user_only_when_no_repo_block() {
  // Without a repo `[aliases]` block, the user-level file still
  // surfaces — that's the whole point of the user fallback.
  let repo_dir = TempDir::new().unwrap();
  let user_dir = TempDir::new().unwrap();
  std::fs::write(
    user_dir.path().join("aliases.toml"),
    r#"
[aliases]
ll = "list --format names"
"#,
  )
  .unwrap();
  let user_path = user_dir.path().join("aliases.toml");

  let resolved = aliases::load_layered(Some(repo_dir.path()), None, Some(&user_path)).unwrap();
  assert!(resolved.repo.is_empty());
  assert_eq!(resolved.user.get("ll").map(String::as_str), Some("list --format names"));
}

#[test]
fn resolved_aliases_user_missing_file_is_no_op() {
  // An absent user file is the common case (fresh install). It must
  // not fail load, it must resolve to an empty user map.
  let repo_dir = TempDir::new().unwrap();
  let resolved = aliases::load_layered(
    Some(repo_dir.path()),
    None,
    Some(std::path::Path::new("/nope/aliases.toml")),
  )
  .unwrap();
  assert!(resolved.repo.is_empty());
  assert!(resolved.user.is_empty());
}

#[test]
fn resolved_aliases_user_rejects_shell_pipeline() {
  // Same validation as repo-level — a user-level alias with `&&` is
  // rejected at load time. The error names the file and the alias so
  // the user knows which file to fix.
  let user_dir = TempDir::new().unwrap();
  std::fs::write(
    user_dir.path().join("aliases.toml"),
    r#"
[aliases]
copy = "path | pbcopy"
"#,
  )
  .unwrap();
  let user_path = user_dir.path().join("aliases.toml");
  let err = aliases::load_layered(None, None, Some(&user_path)).unwrap_err();
  match err {
    GwmError::Config(msg) => {
      assert!(msg.contains("copy"), "{msg}");
    }
    other => panic!("expected GwmError::Config, got {other:?}"),
  }
}

#[test]
fn resolved_aliases_user_rejects_shadow_of_built_in() {
  // The shadow rule applies symmetrically to the user file.
  let user_dir = TempDir::new().unwrap();
  std::fs::write(
    user_dir.path().join("aliases.toml"),
    r#"
[aliases]
list = "list --format names"
"#,
  )
  .unwrap();
  let user_path = user_dir.path().join("aliases.toml");
  let err = aliases::load_layered(None, None, Some(&user_path)).unwrap_err();
  match err {
    GwmError::Config(msg) => assert!(msg.contains("list"), "{msg}"),
    other => panic!("expected GwmError::Config, got {other:?}"),
  }
}

// ---- expand_argv --------------------------------------------------------

#[test]
fn expand_argv_no_match_passes_through_unchanged() {
  // Argv that doesn't start with an alias is returned verbatim. The
  // dispatcher (clap) still sees what the user typed.
  let resolved = aliases::load_layered(None, None, None).unwrap();
  let argv = vec!["gwm".into(), "list".into(), "--format".into(), "names".into()];
  assert_eq!(aliases::expand_argv(argv.clone(), &resolved), argv);
}

#[test]
fn expand_argv_expands_repo_alias() {
  let repo_dir = TempDir::new().unwrap();
  std::fs::write(
    repo_dir.path().join(CONFIG_FILE),
    r#"
[aliases]
wip = "create feat 0 wip"
"#,
  )
  .unwrap();
  let resolved = aliases::load_layered(Some(repo_dir.path()), None, None).unwrap();
  let argv = vec!["gwm".into(), "wip".into()];
  assert_eq!(
    aliases::expand_argv(argv, &resolved),
    vec!["gwm", "create", "feat", "0", "wip"]
  );
}

#[test]
fn expand_argv_built_in_subcommand_always_wins() {
  // Even if the user-level file somehow declares `list = "create
  // feat 0 wip"` (which `load` should reject — but bypass via direct
  // ResolvedAliases construction is possible for tests), the
  // expansion path itself MUST treat `list` as a built-in token and
  // pass it through unchanged. This is the second line of defence on
  // top of the load-time shadow check.
  use gwm::aliases::{AliasEntry, ResolvedAliases};
  use std::collections::BTreeMap;

  let mut user = BTreeMap::new();
  user.insert("list".to_string(), "create feat 0 wip".to_string());
  let resolved = ResolvedAliases {
    built_in: vec![AliasEntry {
      name: "s",
      expansion: "switch",
    }],
    repo: BTreeMap::new(),
    user,
  };
  let argv = vec!["gwm".into(), "list".into()];
  assert_eq!(aliases::expand_argv(argv.clone(), &resolved), argv);
}

#[test]
fn expand_argv_appends_user_trailing_arguments() {
  // `gwm wip --foo bar` with `wip = "create feat 0 wip"` expands to
  // `gwm create feat 0 wip --foo bar` — the user-supplied trailing
  // args are appended after the substitution, matching git's
  // `[alias]` behaviour.
  let repo_dir = TempDir::new().unwrap();
  std::fs::write(
    repo_dir.path().join(CONFIG_FILE),
    r#"
[aliases]
wip = "create feat 0 wip"
"#,
  )
  .unwrap();
  let resolved = aliases::load_layered(Some(repo_dir.path()), None, None).unwrap();
  let argv = vec!["gwm".into(), "wip".into(), "--no-bootstrap".into()];
  assert_eq!(
    aliases::expand_argv(argv, &resolved),
    vec!["gwm", "create", "feat", "0", "wip", "--no-bootstrap"]
  );
}

#[test]
fn expand_argv_only_first_position_is_substituted() {
  // `gwm create feat 86 wip` must NOT expand the `wip` token (it's a
  // positional, not the subcommand slot). Only argv[1] is a
  // candidate.
  let user_dir = TempDir::new().unwrap();
  std::fs::write(
    user_dir.path().join("aliases.toml"),
    r#"
[aliases]
wip = "create feat 0 wip"
"#,
  )
  .unwrap();
  let user_path = user_dir.path().join("aliases.toml");
  let resolved = aliases::load_layered(None, None, Some(&user_path)).unwrap();
  let argv = vec!["gwm".into(), "create".into(), "feat".into(), "86".into(), "wip".into()];
  assert_eq!(aliases::expand_argv(argv.clone(), &resolved), argv);
}

#[test]
fn expand_argv_no_recursion_alias_pointing_at_alias() {
  // `wip = "ll"` followed by `ll = "list --format names"` must NOT
  // double-expand — `gwm wip` becomes `gwm ll`, which then errors at
  // clap parse time (ll isn't a subcommand) UNLESS we also expand a
  // second time. We pick "no recursion" to mirror git's behaviour
  // (one substitution, then dispatch). This is the simplest contract
  // and the easiest to reason about.
  let repo_dir = TempDir::new().unwrap();
  std::fs::write(
    repo_dir.path().join(CONFIG_FILE),
    r#"
[aliases]
wip = "ll"
ll = "list --format names"
"#,
  )
  .unwrap();
  let resolved = aliases::load_layered(Some(repo_dir.path()), None, None).unwrap();
  let argv = vec!["gwm".into(), "wip".into()];
  // After ONE expansion, argv[1] is `ll` — recursion would substitute
  // it again, but we explicitly don't.
  assert_eq!(aliases::expand_argv(argv, &resolved), vec!["gwm", "ll"]);
}

#[test]
fn expand_argv_empty_argv_passes_through() {
  // `gwm` (no args, opens the TUI) must not be touched.
  let resolved = aliases::load_layered(None, None, None).unwrap();
  let argv = vec!["gwm".into()];
  assert_eq!(aliases::expand_argv(argv.clone(), &resolved), argv);
}

#[test]
fn expand_argv_global_flags_before_alias_are_preserved() {
  // `gwm --allow-bootstrap wip` (global flag BEFORE the alias) is the
  // tricky case. clap parses global flags anywhere, so the alias slot
  // is technically argv[1] OR argv[2] depending on the user. We
  // pick the simple rule: expansion looks at the first non-flag
  // token in argv[1..]. Anything starting with `-` (short or long
  // flag) is skipped over.
  let repo_dir = TempDir::new().unwrap();
  std::fs::write(
    repo_dir.path().join(CONFIG_FILE),
    r#"
[aliases]
wip = "create feat 0 wip"
"#,
  )
  .unwrap();
  let resolved = aliases::load_layered(Some(repo_dir.path()), None, None).unwrap();
  let argv = vec!["gwm".into(), "--allow-bootstrap".into(), "wip".into()];
  assert_eq!(
    aliases::expand_argv(argv, &resolved),
    vec!["gwm", "--allow-bootstrap", "create", "feat", "0", "wip"]
  );
}

// ---- expand_argv_os (OsString surface) ----------------------------------

#[test]
fn expand_argv_os_matches_expand_argv_for_utf8_inputs() {
  // The `OsString` surface MUST behave identically to the `String`
  // surface on valid UTF-8 argv — the only divergence is in how
  // non-UTF-8 tokens flow through (see the test below). Pin a repo
  // alias + global-flag scenario so a refactor of the OsString path
  // does not silently change the contract for the common case.
  use std::ffi::OsString;
  let repo_dir = tempfile::TempDir::new().unwrap();
  std::fs::write(
    repo_dir.path().join(".gwm.toml"),
    r#"
[aliases]
wip = "create feat 0 wip"
"#,
  )
  .unwrap();
  let resolved = aliases::load_layered(Some(repo_dir.path()), None, None).unwrap();
  let argv: Vec<OsString> = vec!["gwm".into(), "--allow-bootstrap".into(), "wip".into()];
  let expected: Vec<OsString> = vec![
    "gwm".into(),
    "--allow-bootstrap".into(),
    "create".into(),
    "feat".into(),
    "0".into(),
    "wip".into(),
  ];
  assert_eq!(aliases::expand_argv_os(argv, &resolved), expected);
}

#[cfg(unix)]
#[test]
fn expand_argv_os_passes_non_utf8_token_through_unchanged() {
  // Non-UTF-8 argv must NOT panic the expander and must NOT be coerced
  // into a UTF-8 round-trip. Alias keys are `String`, so a non-UTF-8
  // token cannot match — the contract is "return argv unchanged and
  // let clap surface the unknown subcommand verbatim".
  use std::ffi::OsString;
  use std::os::unix::ffi::OsStringExt;
  let resolved = aliases::load_layered(None, None, None).unwrap();
  let bad: OsString = OsString::from_vec(vec![0xff, 0xfe, 0x80]);
  let argv: Vec<OsString> = vec!["gwm".into(), bad.clone()];
  let out = aliases::expand_argv_os(argv.clone(), &resolved);
  assert_eq!(out, argv, "non-UTF-8 token must flow through unchanged");
}

#[cfg(unix)]
#[test]
fn expand_argv_os_expands_alias_after_non_utf8_flag_value() {
  // A non-UTF-8 token in a flag-VALUE position (i.e. before the
  // alias slot, but introduced by a flag like `--config <path>`)
  // should NOT prevent the alias from being expanded. The expander
  // only skips tokens that *start* with `-`, which means flag
  // values are currently treated as the alias slot — so this test
  // pins the conservative behaviour: a non-UTF-8 token in slot 1
  // is treated as the alias slot, can't match (alias keys are
  // String), and argv flows through unchanged.
  use std::ffi::OsString;
  use std::os::unix::ffi::OsStringExt;
  let resolved = aliases::load_layered(None, None, None).unwrap();
  let bad: OsString = OsString::from_vec(vec![0xff]);
  let argv: Vec<OsString> = vec!["gwm".into(), "--verbose".into(), bad.clone()];
  let out = aliases::expand_argv_os(argv.clone(), &resolved);
  assert_eq!(out, argv);
}

// ---- Built-in alias snapshot --------------------------------------------

#[test]
fn built_in_aliases_constant_matches_clap_visible_aliases() {
  // BUILT_IN_ALIASES is the snapshot used by `aliases list` and the
  // shadow check. It MUST stay in lockstep with the `visible_alias`
  // attributes on the clap subcommands — otherwise a `gwm cd` that
  // works through clap would not appear under `aliases list` and
  // would not be protected from being shadowed by user config.
  //
  // Current set: `cd → path` (issue #67), `s → switch` (issue #43).
  let names: Vec<&str> = BUILT_IN_ALIASES.iter().map(|e| e.name).collect();
  assert!(names.contains(&"cd"), "expected 'cd' in BUILT_IN_ALIASES: {names:?}");
  assert!(names.contains(&"s"), "expected 's' in BUILT_IN_ALIASES: {names:?}");
}