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
//! Tests for the user-level global config merged under per-repo
//! `.gwm.toml` (issue #190).
//!
//! The merge is exercised through the path-injected seam
//! `Config::load_layered(repo_root, Some(global_path))` so the
//! contract is pinned without touching the runner's real `$HOME` /
//! `$XDG_CONFIG_HOME` (env-independence rule). `global_config_path_in`
//! pins the on-disk location separately.

use gwm::config::{
  global_config_path, global_config_path_in, resolve_global_config_path, resolve_gwm_config_file, Config,
};
use std::path::Path;
use std::sync::Mutex;
use tempfile::TempDir;

/// Process-wide guard for the env-mutating test below: `set_var` /
/// `remove_var` touch the shared libc env table, so a parallel
/// env-mutating test would race. No other test in this binary mutates
/// process env (they all drive the injected `load_layered` seam), but
/// the lock keeps the idiom consistent with `history_tests.rs`.
fn env_lock() -> &'static Mutex<()> {
  static LOCK: Mutex<()> = Mutex::new(());
  &LOCK
}

/// Write `contents` to `dir/.gwm.toml`.
fn write_repo(dir: &Path, contents: &str) {
  std::fs::write(dir.join(".gwm.toml"), contents).unwrap();
}

/// Write a global config file under `dir` and return its path.
fn write_global(dir: &Path, contents: &str) -> std::path::PathBuf {
  let p = dir.join("config.toml");
  std::fs::write(&p, contents).unwrap();
  p
}

#[test]
fn global_config_path_lives_under_gwm_config_toml() {
  let home = Path::new("/tmp/xdg-home");
  assert_eq!(global_config_path_in(home), home.join("gwm").join("config.toml"));
}

// --- issue #372: the documented `~/.config` path is honoured across
// platforms, not just where `dirs::config_dir()` happens to be `~/.config`.
// These drive the pure resolver seam (existence injected) so the contract
// holds identically on every runner OS, per the env-independence rule.

/// Build a `.../gwm/config.toml` under `home/.config` and `touch` it.
fn touch_dotconfig(home: &Path) -> std::path::PathBuf {
  let dir = home.join(".config").join("gwm");
  std::fs::create_dir_all(&dir).unwrap();
  let cfg = dir.join("config.toml");
  std::fs::write(&cfg, "").unwrap();
  cfg
}

#[test]
fn resolver_prefers_dotconfig_when_it_exists() {
  // A macOS user who followed the docs and put their config at
  // ~/.config/gwm/config.toml must be honoured even though the platform
  // config dir (Application Support) differs from ~/.config (issue #372).
  let home = TempDir::new().unwrap();
  let platform = TempDir::new().unwrap(); // stand-in for Application Support
  let cfg = touch_dotconfig(home.path());

  let resolved = resolve_global_config_path(None, Some(home.path()), Some(platform.path()), |p| p.exists());
  assert_eq!(
    resolved,
    Some(cfg),
    "the documented ~/.config path must win when present"
  );
}

#[test]
fn resolver_falls_back_to_platform_dir_when_only_it_exists() {
  // Back-compat: a config already living at the platform dir keeps working
  // when ~/.config has none.
  let home = TempDir::new().unwrap();
  let platform = TempDir::new().unwrap();
  let cfg = global_config_path_in(platform.path());
  std::fs::create_dir_all(cfg.parent().unwrap()).unwrap();
  std::fs::write(&cfg, "").unwrap();

  let resolved = resolve_global_config_path(None, Some(home.path()), Some(platform.path()), |p| p.exists());
  assert_eq!(
    resolved,
    Some(cfg),
    "an existing platform-dir config must still resolve"
  );
}

#[test]
fn resolver_returns_canonical_dotconfig_when_nothing_exists() {
  // Neither present → point doctor / callers at the documented location.
  let home = TempDir::new().unwrap();
  let platform = TempDir::new().unwrap();

  let resolved = resolve_global_config_path(None, Some(home.path()), Some(platform.path()), |_| false);
  assert_eq!(
    resolved,
    Some(global_config_path_in(&home.path().join(".config"))),
    "with nothing on disk the canonical ~/.config path is reported"
  );
}

#[test]
fn resolver_honours_xdg_outright_even_when_dotconfig_exists() {
  // An explicit $XDG_CONFIG_HOME wins over both candidates and is returned
  // whether or not the file exists — the pre-#372 contract.
  let xdg = TempDir::new().unwrap();
  let home = TempDir::new().unwrap();
  let platform = TempDir::new().unwrap();
  touch_dotconfig(home.path()); // present, yet XDG must still win

  let resolved = resolve_global_config_path(Some(xdg.path()), Some(home.path()), Some(platform.path()), |p| {
    p.exists()
  });
  assert_eq!(resolved, Some(global_config_path_in(xdg.path())));
}

#[test]
fn resolver_linux_dotconfig_equals_platform_dir_is_unchanged() {
  // On Linux dirs::config_dir() == ~/.config, so both candidates coincide
  // and resolution is byte-for-byte pre-#372 whether or not the file exists.
  let home = TempDir::new().unwrap();
  let platform = home.path().join(".config"); // simulate the Linux equality
  let resolved = resolve_global_config_path(None, Some(home.path()), Some(&platform), |_| false);
  assert_eq!(resolved, Some(global_config_path_in(&platform)));
}

#[test]
fn shared_resolver_honours_filename_and_dotconfig() {
  // The resolver is parameterised by filename so config.toml and aliases.toml
  // share one ~/.config-first contract (issue #374). Exercise it with a
  // non-config filename to pin the plumbing.
  let home = TempDir::new().unwrap();
  let platform = TempDir::new().unwrap();
  let dir = home.path().join(".config").join("gwm");
  std::fs::create_dir_all(&dir).unwrap();
  let cfg = dir.join("aliases.toml");
  std::fs::write(&cfg, "").unwrap();

  let resolved = resolve_gwm_config_file("aliases.toml", None, Some(home.path()), Some(platform.path()), |p| {
    p.exists()
  });
  assert_eq!(
    resolved,
    Some(cfg),
    "an arbitrary filename resolves under ~/.config when present"
  );

  // XDG still wins outright, carrying the requested filename.
  let xdg = TempDir::new().unwrap();
  let via_xdg = resolve_gwm_config_file(
    "aliases.toml",
    Some(xdg.path()),
    Some(home.path()),
    Some(platform.path()),
    |p| p.exists(),
  );
  assert_eq!(via_xdg, Some(xdg.path().join("gwm").join("aliases.toml")));
}

#[test]
fn gwm_no_global_config_env_forces_repo_only() {
  // `GWM_NO_GLOBAL_CONFIG=1` makes `global_config_path()` report no
  // path, so `load_for_repo` degrades to repo-only — the opt-out that
  // keeps tests/CI deterministic on a machine with a real global
  // config (PR #191 review).
  let _guard = env_lock().lock().unwrap_or_else(|p| p.into_inner());
  let prev_flag = std::env::var("GWM_NO_GLOBAL_CONFIG").ok();
  let prev_xdg = std::env::var("XDG_CONFIG_HOME").ok();

  // Pin XDG_CONFIG_HOME so the non-opt-out branch resolves to a known
  // path regardless of the runner's home dir (env-independence rule).
  let xdg = TempDir::new().unwrap();

  // SAFETY: env mutation is serialised by `env_lock()`; we restore the
  // prior values before dropping the guard.
  unsafe {
    std::env::set_var("XDG_CONFIG_HOME", xdg.path());
    std::env::set_var("GWM_NO_GLOBAL_CONFIG", "1");
  }
  assert_eq!(global_config_path(), None, "opt-out must suppress the global path");

  unsafe {
    std::env::set_var("GWM_NO_GLOBAL_CONFIG", "0");
  }
  assert_eq!(
    global_config_path(),
    Some(xdg.path().join("gwm").join("config.toml")),
    "a falsey value must not opt out (path resolves under XDG)"
  );

  // SAFETY: restoration paired with the mutations above, still guarded.
  unsafe {
    match prev_flag {
      Some(v) => std::env::set_var("GWM_NO_GLOBAL_CONFIG", v),
      None => std::env::remove_var("GWM_NO_GLOBAL_CONFIG"),
    }
    match prev_xdg {
      Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
      None => std::env::remove_var("XDG_CONFIG_HOME"),
    }
  }
}

#[cfg(unix)]
#[test]
fn non_utf8_xdg_config_home_still_wins_outright() {
  // Regression (#372 review): a valid-but-non-UTF-8 $XDG_CONFIG_HOME (legal on
  // Unix) must be honoured, not dropped by a `String`-only read that would let
  // a `~/.config` file mask the explicit config home. `global_config_path`
  // reads via `var_os`, so the raw `OsString` survives.
  use std::os::unix::ffi::OsStrExt;
  let _guard = env_lock().lock().unwrap_or_else(|p| p.into_inner());
  let prev_flag = std::env::var_os("GWM_NO_GLOBAL_CONFIG");
  let prev_xdg = std::env::var_os("XDG_CONFIG_HOME");

  // 0xFF is not valid UTF-8, so `std::env::var` would report this path absent.
  let raw = std::ffi::OsStr::from_bytes(b"/tmp/xdg-\xff-home");
  let expected = Path::new(raw).join("gwm").join("config.toml");

  // SAFETY: env mutation is serialised by `env_lock()`; restored below.
  unsafe {
    std::env::remove_var("GWM_NO_GLOBAL_CONFIG");
    std::env::set_var("XDG_CONFIG_HOME", raw);
  }
  let got = global_config_path();

  // SAFETY: restoration paired with the mutations above, still guarded.
  unsafe {
    match prev_flag {
      Some(v) => std::env::set_var("GWM_NO_GLOBAL_CONFIG", v),
      None => std::env::remove_var("GWM_NO_GLOBAL_CONFIG"),
    }
    match prev_xdg {
      Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
      None => std::env::remove_var("XDG_CONFIG_HOME"),
    }
  }
  assert_eq!(got, Some(expected), "non-UTF-8 XDG must win outright, not be dropped");
}

#[test]
fn no_files_resolve_to_default() {
  let repo = TempDir::new().unwrap();
  let missing = repo.path().join("nope.toml");
  let cfg = Config::load_layered(repo.path(), Some(&missing)).unwrap();
  // Same as a bare default — nothing on disk to layer.
  assert!(cfg.theme.preset.is_none());
  assert!(cfg.labels.is_empty());
}

#[test]
fn global_only_applies_when_repo_absent() {
  let gdir = TempDir::new().unwrap();
  let global = write_global(
    gdir.path(),
    r#"
[theme]
preset = "catppuccin"
"#,
  );
  let repo = TempDir::new().unwrap(); // no .gwm.toml
  let cfg = Config::load_layered(repo.path(), Some(&global)).unwrap();
  assert_eq!(cfg.theme.preset.as_deref(), Some("catppuccin"));
}

#[test]
fn repo_only_ignores_a_missing_global() {
  let repo = TempDir::new().unwrap();
  write_repo(
    repo.path(),
    r#"
[theme]
preset = "gruvbox"
"#,
  );
  let missing = repo.path().join("no-global.toml");
  let cfg = Config::load_layered(repo.path(), Some(&missing)).unwrap();
  assert_eq!(cfg.theme.preset.as_deref(), Some("gruvbox"));
}

#[test]
fn repo_scalar_wins_over_global() {
  // Both set the same scalar (theme.preset) → the repo value wins.
  let gdir = TempDir::new().unwrap();
  let global = write_global(
    gdir.path(),
    r#"
[theme]
preset = "catppuccin"
"#,
  );
  let repo = TempDir::new().unwrap();
  write_repo(
    repo.path(),
    r#"
[theme]
preset = "tokyo-night"
"#,
  );
  let cfg = Config::load_layered(repo.path(), Some(&global)).unwrap();
  assert_eq!(
    cfg.theme.preset.as_deref(),
    Some("tokyo-night"),
    "repo .gwm.toml must win on a conflicting scalar"
  );
}

#[test]
fn disjoint_tables_from_both_files_coexist() {
  // Global sets [theme], repo sets [worktree] → both survive the merge.
  let gdir = TempDir::new().unwrap();
  let global = write_global(
    gdir.path(),
    r#"
[theme]
preset = "catppuccin"
"#,
  );
  let repo = TempDir::new().unwrap();
  write_repo(
    repo.path(),
    r#"
[worktree]
base = "/tmp/custom-worktrees"
"#,
  );
  let cfg = Config::load_layered(repo.path(), Some(&global)).unwrap();
  assert_eq!(cfg.theme.preset.as_deref(), Some("catppuccin"), "global theme survives");
  assert_eq!(cfg.worktree.base, "/tmp/custom-worktrees", "repo worktree survives");
}

#[test]
fn nested_table_merges_key_by_key() {
  // Global [theme] sets preset + an override; repo [theme] overrides ONE
  // role. The deep merge keeps the global preset and the untouched
  // override, while the repo's role wins.
  let gdir = TempDir::new().unwrap();
  let global = write_global(
    gdir.path(),
    r##"
[theme]
preset = "catppuccin"
branch = "#111111"
"##,
  );
  let repo = TempDir::new().unwrap();
  write_repo(
    repo.path(),
    r##"
[theme]
accent = "#222222"
"##,
  );
  let cfg = Config::load_layered(repo.path(), Some(&global)).unwrap();
  assert_eq!(cfg.theme.preset.as_deref(), Some("catppuccin"), "global preset kept");
  assert_eq!(
    cfg.theme.overrides.get("branch").map(String::as_str),
    Some("#111111"),
    "untouched global override kept"
  );
  assert_eq!(
    cfg.theme.overrides.get("accent").map(String::as_str),
    Some("#222222"),
    "repo override merged in"
  );
}

#[test]
fn arrays_are_replaced_not_unioned() {
  // Global declares one label, repo declares another. Arrays replace
  // wholesale → only the repo's labels survive (no confusing union).
  let gdir = TempDir::new().unwrap();
  let global = write_global(
    gdir.path(),
    r#"
[[labels]]
name = "global-label"
"#,
  );
  let repo = TempDir::new().unwrap();
  write_repo(
    repo.path(),
    r#"
[[labels]]
name = "repo-label"
"#,
  );
  let cfg = Config::load_layered(repo.path(), Some(&global)).unwrap();
  let names: Vec<&str> = cfg.labels.iter().map(|l| l.name.as_str()).collect();
  assert_eq!(names, vec!["repo-label"], "repo array replaces global array");
}

#[test]
fn merged_result_is_validated() {
  // A bad colour in the *merged* config must fail at load, exactly as a
  // bad repo-only value would — validation runs on the merge.
  let gdir = TempDir::new().unwrap();
  let global = write_global(
    gdir.path(),
    r#"
[theme]
accent = "not-a-color"
"#,
  );
  let repo = TempDir::new().unwrap(); // no repo override
  let err = Config::load_layered(repo.path(), Some(&global)).unwrap_err();
  assert!(
    err.to_string().to_lowercase().contains("color"),
    "expected a colour validation error, got: {err}"
  );
}