• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

endoze / jjwt / 26650220256

26 May 2026 12:26AM UTC coverage: 98.105%. First build
26650220256

push

github

endoze
refactor: improve code quality, ergonomics, and hot-loop performance

Replace string-based shell selection with a typed enum to catch invalid
shell kinds at compile time. Simplify RemoveArgs by moving workspace
name from struct field to function parameter, eliminating redundant
per-workspace copies.

Optimize jj_lib workspace resolution by introducing trunk-aware variants
of internal_ws_name and wc_commit_id that accept pre-resolved trunk
bookmarks, avoiding repeated resolution in hot loops during diff and
blame computations.

Replace multiple Vector::as_slice() calls with slice references using
the & operator for better ergonomics. Simplify truncate_line logic to
correctly handle edge cases and avoid premature truncation detection.

Use Value::from() for cleaner serde_json construction instead of
explicit Value::String wraps. Replace Iterator::last() with
next_back() in test assertions for correctness on DoubleEndedIterator.

Remove unnecessary clone operations in various contexts where references
or move semantics suffice. Improve error handling in jj config setup
by propagating context through write! operations. Update function
signatures to accept Path instead of PathBuf where appropriate.

48 of 57 new or added lines in 3 files covered. (84.21%)

1294 of 1319 relevant lines covered (98.1%)

182.26 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.35
/src/core/types.rs
1
use indexmap::IndexMap;
2
use std::collections::HashMap;
3
use std::fmt;
4
use std::path::PathBuf;
5
use thiserror::Error;
6

7
/// Default worktree-path template. Matches worktrunk's default: places
8
/// worktrees as siblings of the repository root.
9
pub const DEFAULT_WORKTREE_PATH_TEMPLATE: &str =
10
  "{{ repo_path }}/../{{ repo }}.{{ branch | sanitize }}";
11

12
/// Errors produced by core planning and configuration logic.
13
#[derive(Debug, Error)]
14
pub enum CoreError {
15
  /// TOML configuration could not be parsed.
16
  #[error("config parse error: {0}")]
17
  ConfigParse(String),
18
  /// Minijinja template rendering failed.
19
  #[error("template render error: {0}")]
20
  TemplateRender(String),
21
  /// Named hook does not exist in any configured hook group.
22
  #[error("hook '{0}' not found in config")]
23
  HookNotFound(String),
24
  /// Named hook appears in more than one hook group.
25
  #[error("hook '{0}' is ambiguous: appears in multiple groups")]
26
  HookAmbiguous(String),
27
  /// A workspace with this name is already registered.
28
  #[error("workspace '{0}' already exists")]
29
  WorkspaceExists(String),
30
  /// Target path exists on disk; pass `--clobber` to remove it.
31
  #[error(
32
    "path '{0}' already exists at the target workspace location (use --clobber to remove it)"
33
  )]
34
  TargetPathOccupied(String),
35
  /// Target path is nested inside another workspace's directory.
36
  #[error("path '{0}' is inside another workspace and cannot be clobbered")]
37
  TargetPathInsideOtherWorkspace(String),
38
  /// No workspace with this name is registered.
39
  #[error("workspace '{0}' does not exist")]
40
  WorkspaceMissing(String),
41
  /// Workspace has uncommitted changes and `--force` was not given.
42
  #[error("workspace '{0}' has uncommitted changes (use --force)")]
43
  WorkspaceDirty(String),
44
  /// Bookmark is not merged into trunk and forced deletion was not requested.
45
  #[error("bookmark '{0}' is not fully merged into trunk (use --force)")]
46
  BookmarkUnmerged(String),
47
  /// Current directory is not inside a jj repository.
48
  #[error("not inside a jj repo")]
49
  NotJjRepo,
50
  /// No alias with this name exists in the configuration.
51
  #[error("alias '{0}' not found in config")]
52
  AliasNotFound(String),
53
}
54

55
/// Top-level configuration parsed from a single `wt.toml` file.
56
#[derive(Debug, Clone, serde::Deserialize, Default)]
57
pub struct Config {
58
  /// Settings for the `list` subcommand (URL template, summary toggle).
59
  #[serde(default)]
60
  pub list: Option<ListConfig>,
61
  /// Hooks to run before switching to a workspace.
62
  #[serde(
63
    rename = "pre-switch",
64
    default,
65
    deserialize_with = "crate::core::config::deserialize_hook_groups"
66
  )]
67
  pub pre_switch: Vec<HookGroup>,
68
  /// Hooks to run after switching to a workspace.
69
  #[serde(
70
    rename = "post-switch",
71
    default,
72
    deserialize_with = "crate::core::config::deserialize_hook_groups"
73
  )]
74
  pub post_switch: Vec<HookGroup>,
75
  /// Hooks to run before a workspace is created.
76
  #[serde(
77
    rename = "pre-start",
78
    default,
79
    deserialize_with = "crate::core::config::deserialize_hook_groups"
80
  )]
81
  pub pre_start: Vec<HookGroup>,
82
  /// Hooks to run after a workspace is created.
83
  #[serde(
84
    rename = "post-start",
85
    default,
86
    deserialize_with = "crate::core::config::deserialize_hook_groups"
87
  )]
88
  pub post_start: Vec<HookGroup>,
89
  /// Hooks to run before a workspace is removed.
90
  #[serde(
91
    rename = "pre-remove",
92
    default,
93
    deserialize_with = "crate::core::config::deserialize_hook_groups"
94
  )]
95
  pub pre_remove: Vec<HookGroup>,
96
  /// Hooks to run after a workspace is removed.
97
  #[serde(
98
    rename = "post-remove",
99
    default,
100
    deserialize_with = "crate::core::config::deserialize_hook_groups"
101
  )]
102
  pub post_remove: Vec<HookGroup>,
103
  /// When true, workspace directory deletion runs in the background.
104
  #[serde(rename = "background-remove", default)]
105
  pub background_remove: Option<bool>,
106
  /// Custom subcommands. Each entry maps `jjwt <name>` to a template
107
  /// rendered with the standard hook variables; the result is executed
108
  /// via `sh -c` with stdio inherited from the parent process.
109
  #[serde(default)]
110
  pub aliases: IndexMap<String, String>,
111
  /// Minijinja template for computing workspace directory paths.
112
  #[serde(rename = "worktree-path", default)]
113
  pub worktree_path_template: Option<String>,
114
  /// LLM commit-message generation settings.
115
  #[serde(default)]
116
  pub commit: Option<CommitConfig>,
117
  /// Per-project overrides in the user config. Keyed by repo identity
118
  /// (e.g. `github.com/owner/repo`). Only meaningful in user config;
119
  /// ignored in project config.
120
  #[serde(default)]
121
  pub projects: HashMap<String, Config>,
122
}
123

124
impl Config {
125
  /// Iterate (hook_type, group) pairs over every configured hook group.
126
  /// Used by `jjwt hook` for cross-group lookups and (in 1B.13)
127
  /// `hook show`.
128
  pub fn all_hook_groups(&self) -> Vec<(&'static str, &[HookGroup])> {
×
129
    vec![
×
NEW
130
      ("pre-switch", &self.pre_switch),
×
NEW
131
      ("post-switch", &self.post_switch),
×
NEW
132
      ("pre-start", &self.pre_start),
×
NEW
133
      ("post-start", &self.post_start),
×
NEW
134
      ("pre-remove", &self.pre_remove),
×
NEW
135
      ("post-remove", &self.post_remove),
×
136
    ]
137
  }
138
}
139

140
/// Which configuration layer a hook originated from.
141
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142
pub enum HookSource {
143
  /// Hook defined in the user-level config.
144
  User,
145
  /// Hook defined in the project-level config.
146
  Project,
147
}
148

149
/// Renders the hook source as `"user"` or `"project"`.
150
impl fmt::Display for HookSource {
151
  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72✔
152
    match self {
72✔
153
      HookSource::User => f.write_str("user"),
×
154
      HookSource::Project => f.write_str("project"),
216✔
155
    }
156
  }
157
}
158

159
/// A hook group paired with which config layer it came from.
160
#[derive(Debug, Clone)]
161
pub struct SourcedHookGroup {
162
  /// Whether this group came from user or project config.
163
  pub source: HookSource,
164
  /// Ordered map of hook name to command template.
165
  pub group: HookGroup,
166
}
167

168
/// The six hook lifecycle slots, generic over the hook representation.
169
/// `Config` uses `HookSet<HookGroup>` (raw TOML groups);
170
/// `MergedConfig` uses `HookSet<SourcedHookGroup>` (groups tagged with
171
/// their config layer).
172
#[derive(Debug, Clone)]
173
pub struct HookSet<T> {
174
  /// Hooks fired before switching workspaces.
175
  pub pre_switch: Vec<T>,
176
  /// Hooks fired after switching workspaces.
177
  pub post_switch: Vec<T>,
178
  /// Hooks fired before creating a workspace.
179
  pub pre_start: Vec<T>,
180
  /// Hooks fired after creating a workspace.
181
  pub post_start: Vec<T>,
182
  /// Hooks fired before removing a workspace.
183
  pub pre_remove: Vec<T>,
184
  /// Hooks fired after removing a workspace.
185
  pub post_remove: Vec<T>,
186
}
187

188
impl<T> Default for HookSet<T> {
189
  fn default() -> Self {
6✔
190
    Self {
191
      pre_switch: Vec::new(),
12✔
192
      post_switch: Vec::new(),
12✔
193
      pre_start: Vec::new(),
12✔
194
      post_start: Vec::new(),
12✔
195
      pre_remove: Vec::new(),
6✔
196
      post_remove: Vec::new(),
6✔
197
    }
198
  }
199
}
200

201
impl<T> HookSet<T> {
202
  /// Iterate (hook_type, groups) pairs over every configured hook slot.
203
  pub fn all_groups(&self) -> [(&'static str, &[T]); 6] {
36✔
204
    [
205
      ("pre-switch", &self.pre_switch),
72✔
206
      ("post-switch", &self.post_switch),
72✔
207
      ("pre-start", &self.pre_start),
72✔
208
      ("post-start", &self.post_start),
72✔
209
      ("pre-remove", &self.pre_remove),
36✔
210
      ("post-remove", &self.post_remove),
36✔
211
    ]
212
  }
213
}
214

215
impl HookSet<SourcedHookGroup> {
216
  /// Merge a user layer and a project layer into sourced hook groups.
217
  /// User hooks come first; project hooks are appended.
218
  fn merge(user: &[HookGroup], project: &[HookGroup]) -> Vec<SourcedHookGroup> {
1,356✔
219
    user
1,356✔
220
      .iter()
221
      .map(|g| SourcedHookGroup {
1,356✔
222
        source: HookSource::User,
16✔
223
        group: g.clone(),
32✔
224
      })
225
      .chain(project.iter().map(|g| SourcedHookGroup {
5,424✔
226
        source: HookSource::Project,
154✔
227
        group: g.clone(),
308✔
228
      }))
229
      .collect()
230
  }
231

232
  /// Merge all six hook slots from user and project configs.
233
  fn from_config_layers(user: &Config, project: &Config) -> Self {
226✔
234
    Self {
235
      pre_switch: Self::merge(&user.pre_switch, &project.pre_switch),
904✔
236
      post_switch: Self::merge(&user.post_switch, &project.post_switch),
904✔
237
      pre_start: Self::merge(&user.pre_start, &project.pre_start),
904✔
238
      post_start: Self::merge(&user.post_start, &project.post_start),
904✔
239
      pre_remove: Self::merge(&user.pre_remove, &project.pre_remove),
904✔
240
      post_remove: Self::merge(&user.post_remove, &project.post_remove),
452✔
241
    }
242
  }
243

244
  /// Convert back to plain `HookGroup` vectors, discarding source provenance.
245
  fn to_raw(&self) -> HookSet<HookGroup> {
12✔
246
    let extract = |groups: &[SourcedHookGroup]| -> Vec<HookGroup> {
84✔
247
      groups.iter().map(|shg| shg.group.clone()).collect()
296✔
248
    };
249

250
    HookSet {
251
      pre_switch: extract(&self.pre_switch),
24✔
252
      post_switch: extract(&self.post_switch),
24✔
253
      pre_start: extract(&self.pre_start),
24✔
254
      post_start: extract(&self.post_start),
24✔
255
      pre_remove: extract(&self.pre_remove),
24✔
256
      post_remove: extract(&self.post_remove),
12✔
257
    }
258
  }
259
}
260

261
/// User and project configs merged into a single effective configuration.
262
#[derive(Debug, Clone)]
263
pub struct MergedConfig {
264
  /// List subcommand settings (URL template, summary toggle).
265
  pub list: Option<ListConfig>,
266
  /// All six hook lifecycle slots, tagged with their config source.
267
  pub hooks: HookSet<SourcedHookGroup>,
268
  /// Whether directory deletion should run in the background.
269
  pub background_remove: Option<bool>,
270
  /// Custom subcommand aliases (name to template).
271
  pub aliases: IndexMap<String, String>,
272
  /// Minijinja template for workspace directory paths. Always populated;
273
  /// defaults to `DEFAULT_WORKTREE_PATH_TEMPLATE` when neither config
274
  /// layer provides a value.
275
  pub worktree_path_template: String,
276
  /// LLM commit-message generation settings.
277
  pub commit: Option<CommitConfig>,
278
}
279

280
impl Default for MergedConfig {
281
  fn default() -> Self {
6✔
282
    Self {
283
      list: None,
284
      hooks: HookSet::default(),
12✔
285
      background_remove: None,
286
      aliases: IndexMap::new(),
12✔
287
      worktree_path_template: DEFAULT_WORKTREE_PATH_TEMPLATE.to_string(),
12✔
288
      commit: None,
289
    }
290
  }
291
}
292

293
impl MergedConfig {
294
  /// Merge a user config (defaults) and a project config (overrides) into a
295
  /// single `MergedConfig`. Matches worktrunk's layering semantics:
296
  /// - Scalars: project wins if present, else user.
297
  /// - Aliases: user entries as base, project entries override per-key.
298
  /// - Hooks: user hooks first, project hooks appended (both contribute).
299
  pub fn from_layers(user: Option<&Config>, project: Option<&Config>) -> Self {
226✔
300
    let u = user.cloned().unwrap_or_default();
904✔
301
    let p = project.cloned().unwrap_or_default();
904✔
302

303
    let hooks = HookSet::from_config_layers(&u, &p);
904✔
304

305
    let list = p.list.or(u.list);
904✔
306
    let background_remove = p.background_remove.or(u.background_remove);
904✔
307
    let worktree_path_template = p
452✔
308
      .worktree_path_template
226✔
309
      .or(u.worktree_path_template)
452✔
310
      .unwrap_or_else(|| DEFAULT_WORKTREE_PATH_TEMPLATE.to_string());
622✔
311
    let commit = p.commit.or(u.commit);
904✔
312

313
    let mut aliases = u.aliases;
452✔
314

315
    for (k, v) in p.aliases {
274✔
316
      aliases.insert(k, v);
48✔
317
    }
318

319
    Self {
320
      list,
321
      hooks,
322
      background_remove,
323
      aliases,
324
      worktree_path_template,
325
      commit,
326
    }
327
  }
328

329
  /// Merge with a per-project override layer from the user config.
330
  ///
331
  /// Merge order: user defaults (excluding `projects`) → matching
332
  /// `projects` entry → project `.config/wt.toml`. The project override
333
  /// acts as a middle layer: it overrides user defaults but is itself
334
  /// overridden by the project-local config.
335
  pub fn from_layers_with_project_id(
16✔
336
    user: Option<&Config>,
337
    project_id: Option<&str>,
338
    project: Option<&Config>,
339
  ) -> Self {
340
    let project_override = user.zip(project_id).and_then(|(u, id)| u.projects.get(id));
122✔
341

342
    match project_override {
16✔
343
      Some(po) => {
12✔
344
        let base = Self::from_layers(user, Some(po));
48✔
345
        let base_cfg = base.to_config_lossy();
36✔
346

347
        Self::from_layers(Some(&base_cfg), project)
36✔
348
      }
349
      None => Self::from_layers(user, project),
12✔
350
    }
351
  }
352

353
  /// Wrap a single `Config` as all-`Project`-sourced. Convenience for
354
  /// callers that don't need layering (and for migrating existing tests).
355
  pub fn from_project(cfg: Config) -> Self {
180✔
356
    Self::from_layers(None, Some(&cfg))
540✔
357
  }
358

359
  /// Iterate (hook_type, groups) pairs over every configured hook group.
360
  pub fn all_hook_groups(&self) -> [(&'static str, &[SourcedHookGroup]); 6] {
36✔
361
    self.hooks.all_groups()
72✔
362
  }
363

364
  /// Convert back to a plain `Config`, discarding source provenance.
365
  /// Used internally for multi-layer merges where the intermediate
366
  /// result feeds into another `from_layers` call.
367
  fn to_config_lossy(&self) -> Config {
12✔
368
    let raw = self.hooks.to_raw();
36✔
369

370
    Config {
371
      list: self.list.clone(),
36✔
372
      pre_switch: raw.pre_switch,
24✔
373
      post_switch: raw.post_switch,
24✔
374
      pre_start: raw.pre_start,
24✔
375
      post_start: raw.post_start,
24✔
376
      pre_remove: raw.pre_remove,
24✔
377
      post_remove: raw.post_remove,
24✔
378
      background_remove: self.background_remove,
24✔
379
      aliases: self.aliases.clone(),
36✔
380
      worktree_path_template: Some(self.worktree_path_template.clone()),
24✔
381
      commit: self.commit.clone(),
24✔
382
      projects: HashMap::new(),
12✔
383
    }
384
  }
385
}
386

387
/// Configuration for the `list` subcommand.
388
#[derive(Debug, Clone, serde::Deserialize)]
389
pub struct ListConfig {
390
  /// Minijinja template rendered into a URL column for each workspace.
391
  pub url: String,
392
  /// Enable LLM-generated one-liner summaries in `list --full`.
393
  #[serde(default)]
394
  pub summary: Option<bool>,
395
}
396

397
/// Settings for LLM-assisted commit message generation.
398
#[derive(Debug, Clone, serde::Deserialize, Default)]
399
pub struct CommitConfig {
400
  /// Configuration for the generation subprocess and prompt template.
401
  pub generation: Option<CommitGenerationConfig>,
402
}
403

404
/// Controls how commit messages are generated via an external LLM command.
405
#[derive(Debug, Clone, serde::Deserialize, Default)]
406
pub struct CommitGenerationConfig {
407
  /// Shell command that reads a prompt from stdin and writes a commit
408
  /// message to stdout. Provider-agnostic — any CLI works.
409
  pub command: Option<String>,
410
  /// Minijinja template for the LLM prompt. When omitted, a built-in
411
  /// default is used.
412
  pub template: Option<String>,
413
  /// Appended to the default template (ignored when `template` is set).
414
  /// Intended for project-level guidance in `.config/wt.toml`.
415
  #[serde(rename = "template-append")]
416
  pub template_append: Option<String>,
417
}
418

419
/// Ordered map of hook name to command template within a single group.
420
pub type HookGroup = IndexMap<String, String>;
421

422
/// Variables available to minijinja templates during hook/alias rendering.
423
#[derive(Debug, Clone, Default)]
424
pub struct RenderContext {
425
  /// Branch/workspace name the operation targets.
426
  pub branch: String,
427
  /// Absolute path of the workspace this template is being rendered for.
428
  pub worktree_path: Option<PathBuf>,
429
  /// Workspace directory name (`basename(worktree_path)` when present).
430
  pub worktree_name: Option<String>,
431
  /// Repository root directory name.
432
  pub repo: Option<String>,
433
  /// Absolute path of the repository root.
434
  pub repo_path: Option<PathBuf>,
435
  /// Directory the hook command will run in (often the same as
436
  /// `worktree_path`; differs for some hook types we don't yet emit).
437
  pub cwd: Option<PathBuf>,
438
  /// Hook type being rendered, e.g. `pre-start`.
439
  pub hook_type: Option<String>,
440
  /// Named key of the hook command inside its group.
441
  pub hook_name: Option<String>,
442
  /// Tokens forwarded from the CLI to a manually-invoked hook
443
  /// (`jjwt hook <type> -- <args>`).
444
  pub args: Vec<String>,
445
  /// Extra variables from `--var KEY=VAL`.
446
  pub vars: Vec<(String, String)>,
447
  /// Per-workspace persistent variables (from `.jj/jjwt-state.toml`).
448
  /// Accessible in templates as `{{ vars.KEY }}`.
449
  pub vars_state: HashMap<String, String>,
450
}
451

452
/// A registered jj workspace with its on-disk location and freshness.
453
#[derive(Debug, Clone, PartialEq, Eq)]
454
pub struct Workspace {
455
  /// Workspace name as registered with jj.
456
  pub name: String,
457
  /// Absolute path to the workspace directory.
458
  pub path: PathBuf,
459
  /// Whether jj considers this workspace stale (needs update).
460
  pub stale: bool,
461
}
462

463
/// Snapshot of the repository and workspace state observed by the shell.
464
#[derive(Debug, Clone, PartialEq, Eq, Default)]
465
pub struct ObservedState {
466
  /// Absolute path to the repository root.
467
  pub repo_root: PathBuf,
468
  /// Whether the current directory is inside a jj repository.
469
  pub is_jj_repo: bool,
470
  /// All registered workspaces with their paths and staleness.
471
  pub workspaces: Vec<Workspace>,
472
  /// Workspace whose path contains cwd (deepest match), if any.
473
  pub current_workspace: Option<String>,
474
  /// Whether the target workspace path already exists on disk (for switch --create).
475
  pub target_path_exists: bool,
476
  /// `jj status` output non-empty for the target workspace (for remove).
477
  pub target_workspace_dirty: bool,
478
  /// Whether the bookmark's target is an ancestor of trunk (for remove).
479
  pub target_bookmark_merged: bool,
480
  /// Whether the bookmark exists at all (for remove).
481
  pub target_bookmark_exists: bool,
482
  /// Workspace name that `target_name` resolves to when it isn't itself a
483
  /// workspace. Set when `target_name` equals the trunk bookmark, in which
484
  /// case it resolves to "default". Mirrors worktrunk's behavior of using
485
  /// the default branch name to address the root worktree.
486
  pub target_resolved_workspace: Option<String>,
487
  /// Name of the trunk bookmark (e.g. "main", "master"). Used as the
488
  /// default base revision when creating workspaces.
489
  pub trunk_bookmark: Option<String>,
490
}
491

492
/// A single step in an execution plan produced by the planner.
493
#[derive(Debug, Clone, PartialEq, Eq)]
494
pub enum Action {
495
  /// Register a new jj workspace at the given path.
496
  JjWorkspaceAdd {
497
    /// Workspace name to register.
498
    name: String,
499
    /// On-disk path for the new workspace.
500
    path: PathBuf,
501
    /// Base revision (bookmark name) to check out after creation. When
502
    /// set, the new workspace's `@` is reparented from root onto this
503
    /// revision.
504
    revision: Option<String>,
505
  },
506
  /// Create a jj bookmark pointing at the workspace's working copy.
507
  JjBookmarkCreate {
508
    /// Bookmark name to create.
509
    name: String,
510
    /// Workspace whose `@` the bookmark targets.
511
    workspace: String,
512
  },
513
  /// Unregister a jj workspace (does not delete files).
514
  JjWorkspaceForget {
515
    /// Workspace name to forget.
516
    name: String,
517
  },
518
  /// Delete a jj bookmark.
519
  JjBookmarkDelete {
520
    /// Bookmark name to delete.
521
    name: String,
522
  },
523
  /// Bring a stale workspace up to date.
524
  JjWorkspaceUpdateStale {
525
    /// Workspace name to update.
526
    name: String,
527
  },
528
  /// Synchronously delete a directory tree.
529
  DeleteDir {
530
    /// Path to remove.
531
    path: PathBuf,
532
  },
533
  /// Delete a directory tree in a background process.
534
  DeleteDirBackground {
535
    /// Path to remove asynchronously.
536
    path: PathBuf,
537
  },
538
  /// Rename a jj workspace.
539
  JjWorkspaceRename {
540
    /// Current workspace name.
541
    old_name: String,
542
    /// New workspace name.
543
    new_name: String,
544
  },
545
  /// Move a directory from one path to another.
546
  RenameDir {
547
    /// Current path.
548
    from: PathBuf,
549
    /// Destination path.
550
    to: PathBuf,
551
  },
552
  /// Rename a jj bookmark.
553
  JjBookmarkRename {
554
    /// Current bookmark name.
555
    old_name: String,
556
    /// New bookmark name.
557
    new_name: String,
558
  },
559
  /// Execute a rendered hook command in a subprocess.
560
  RunHook {
561
    /// Named key of the hook inside its group.
562
    name: String,
563
    /// Fully rendered shell command string.
564
    rendered_cmd: String,
565
    /// Working directory for the hook subprocess.
566
    cwd: PathBuf,
567
    /// Environment variables injected into the hook process.
568
    env: Vec<(String, String)>,
569
    /// Which config layer defined this hook.
570
    source: HookSource,
571
  },
572
  /// Run a command with stdio inherited from the parent process. Used by
573
  /// `jjwt <alias>` and (in 1B.17) `jjwt switch -x`. A non-zero exit
574
  /// becomes an error so the surrounding plan halts.
575
  Exec {
576
    /// Fully rendered shell command string.
577
    rendered_cmd: String,
578
    /// Working directory for the exec subprocess.
579
    cwd: PathBuf,
580
    /// Environment variables injected into the subprocess.
581
    env: Vec<(String, String)>,
582
  },
583
  /// Print a line to stdout (consumed by the shell wrapper).
584
  PrintLine(String),
585
}
586

587
/// An ordered sequence of actions to be executed by the runtime.
588
#[derive(Debug, Clone, PartialEq, Eq, Default)]
589
pub struct Plan {
590
  /// Actions to execute in order.
591
  pub actions: Vec<Action>,
592
}
593

594
impl Plan {
595
  /// Create an empty plan.
596
  pub fn new() -> Self {
152✔
597
    Self::default()
152✔
598
  }
599

600
  /// Append an action to the end of the plan.
601
  pub fn push(&mut self, a: Action) {
344✔
602
    self.actions.push(a);
1,032✔
603
  }
604
}
605

606
/// Output format negotiated by `--format`. Text is the default; JSON is
607
/// emitted as a single line on the same `PrintLine` action so the runtime
608
/// is oblivious to the format choice.
609
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
610
pub enum OutputFormat {
611
  #[default]
612
  Text,
613
  Json,
614
  /// Compact one-line summary for status displays (e.g. Claude Code).
615
  Statusline,
616
}
617

618
/// Arguments for the `switch` subcommand.
619
#[derive(Debug, Clone, Default)]
620
pub struct SwitchArgs {
621
  /// Target workspace name.
622
  pub name: String,
623
  /// When true, create the workspace if it does not exist.
624
  pub create: bool,
625
  /// Re-run start hooks even though the workspace already exists.
626
  pub rerun_hooks: bool,
627
  /// Skip all hooks for this invocation. Set by `--no-hooks`
628
  /// (and the deprecated `--no-verify` alias).
629
  pub no_hooks: bool,
630
  /// Optional command template to run after switching. Equivalent to
631
  /// worktrunk's `-x`. The template is expanded with the standard hook
632
  /// variables; the rendered command is emitted to the shell wrapper as
633
  /// an `exec:` directive.
634
  pub execute: Option<String>,
635
  /// Remove a stale directory at the target workspace path before
636
  /// creating the workspace. Worktrunk's `--clobber`. Refused when the
637
  /// stale path lives inside another registered workspace.
638
  pub clobber: bool,
639
  /// Base revision (bookmark name, etc.) for the new workspace. When
640
  /// omitted, defaults to the trunk bookmark. Only used with `--create`.
641
  pub base: Option<String>,
642
  /// Only show what would be done without actually doing it.
643
  pub dry_run: bool,
644
  /// Output format (text, JSON, or statusline).
645
  pub format: OutputFormat,
646
}
647

648
/// Arguments for the `remove` subcommand.
649
#[derive(Debug, Clone, Default)]
650
pub struct RemoveArgs {
651
  /// Force worktree removal: bypass the "uncommitted changes" check.
652
  /// Worktrunk's `-f`.
653
  pub force: bool,
654
  /// Skip all hooks for this invocation.
655
  pub no_hooks: bool,
656
  /// Never delete the bookmark, even if it is merged into trunk.
657
  /// Worktrunk's `--no-delete-branch`.
658
  pub no_delete_branch: bool,
659
  /// Delete the bookmark even when not merged into trunk. Worktrunk's
660
  /// `-D` / `--force-delete`.
661
  pub force_delete: bool,
662
  /// Only show what would be done without actually doing it.
663
  pub dry_run: bool,
664
  /// Output format (text, JSON, or statusline).
665
  pub format: OutputFormat,
666
}
667

668
/// Arguments for the `hook` subcommand (manual hook invocation).
669
#[derive(Debug, Clone)]
670
pub struct HookArgs {
671
  /// Named key of the hook to run.
672
  pub name: String,
673
  /// Workspace to use as context for template rendering.
674
  pub current_workspace: String,
675
  /// Extra template variables from `--var KEY=VAL`.
676
  pub vars: Vec<(String, String)>,
677
}
678

679
/// Arguments for a custom alias invocation.
680
#[derive(Debug, Clone)]
681
pub struct AliasArgs {
682
  /// Alias name to look up in config.
683
  pub name: String,
684
  /// Tokens forwarded from the CLI; bound to `{{ args }}` in the template.
685
  pub forwarded: Vec<String>,
686
}
687

688
/// Arguments for the `relocate` subcommand (rename a workspace).
689
#[derive(Debug, Clone, Default)]
690
pub struct RelocateArgs {
691
  /// Current workspace name.
692
  pub old_name: String,
693
  /// Desired new workspace name.
694
  pub new_name: String,
695
  /// Also rename the associated bookmark.
696
  pub rename_bookmark: bool,
697
  /// Output format (text, JSON, or statusline).
698
  pub format: OutputFormat,
699
}
700

701
/// Arguments for the `prune` subcommand (bulk-remove merged workspaces).
702
#[derive(Debug, Clone, Default)]
703
pub struct PruneArgs {
704
  /// Only report what would be pruned; do not modify anything.
705
  pub dry_run: bool,
706
  /// Skip all hooks during pruning.
707
  pub no_hooks: bool,
708
  /// Output format (text, JSON, or statusline).
709
  pub format: OutputFormat,
710
}
711

712
/// Relationship of a bookmark to the trunk branch.
713
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
714
pub enum TrunkRel {
715
  /// Bookmark's @ equals trunk exactly.
716
  IsTrunk,
717
  /// Bookmark's @ is an ancestor of trunk (merged in).
718
  Ancestor,
719
  /// Diverged from trunk (both ahead and behind).
720
  Diverged,
721
  /// Strictly ahead of trunk.
722
  Ahead,
723
  /// Strictly behind trunk.
724
  Behind,
725
  /// No measurable relationship (e.g. unborn).
726
  None,
727
}
728

729
/// Per-workspace status indicators for list display.
730
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
731
pub struct StatusFlags {
732
  /// The `@` commit has a non-empty diff vs its parent (jj analog of
733
  /// worktrunk's "staged" indicator).
734
  pub has_changes: bool,
735
  /// Tracked files have working-copy modifications.
736
  pub modified: bool,
737
  /// Untracked files present.
738
  pub untracked: bool,
739
  /// Workspace is stale.
740
  pub stale: bool,
741
  /// Working copy has conflicts.
742
  pub conflicts: bool,
743
  /// The bookmark has a remote-tracking variant (e.g. `<name>@origin`).
744
  pub has_remote: bool,
745
  /// Relationship of this workspace's `@` to trunk.
746
  pub vs_trunk: Option<TrunkRel>,
747
}
748

749
/// Lines added and removed in a diff.
750
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
751
pub struct LineDiff {
752
  /// Number of lines added.
753
  pub added: u32,
754
  /// Number of lines removed.
755
  pub removed: u32,
756
}
757

758
/// Commit distance ahead of and behind trunk.
759
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
760
pub struct AheadBehind {
761
  /// Commits ahead of trunk.
762
  pub ahead: u32,
763
  /// Commits behind trunk.
764
  pub behind: u32,
765
}
766

767
/// Commit metadata and diff stats for a workspace's `@`, gathered in batch
768
/// via a single `jj log` call across all workspaces. Includes fields that
769
/// previously required separate `jj status` and `jj diff --stat` calls.
770
#[derive(Debug, Clone, PartialEq, Eq, Default)]
771
pub struct CommitInfo {
772
  /// Short change ID (8 chars).
773
  pub commit_short: String,
774
  /// Seconds since `@`'s committer timestamp.
775
  pub age_seconds: i64,
776
  /// First line of `@`'s description.
777
  pub message_first_line: String,
778
  /// Working copy has unresolved conflicts.
779
  pub conflicts: bool,
780
  /// Lines added in `@`'s diff vs parent.
781
  pub head_added: u32,
782
  /// Lines removed in `@`'s diff vs parent.
783
  pub head_removed: u32,
784
}
785

786
/// Per-workspace details gathered by the shell from `jj` for rendering
787
/// the list table. Pure data — the core never reads from `jj`.
788
#[derive(Debug, Clone, PartialEq, Eq, Default)]
789
pub struct WorkspaceDetails {
790
  /// Tracked files have working-copy modifications.
791
  pub modified: bool,
792
  /// Untracked files present.
793
  pub untracked: bool,
794
  /// Working copy has unresolved conflicts.
795
  pub conflicts: bool,
796
  /// Short change ID (8 chars).
797
  pub commit_short: String,
798
  /// Seconds since `@`'s committer timestamp.
799
  pub age_seconds: i64,
800
  /// First line of `@`'s description.
801
  pub message_first_line: String,
802
  /// Lines added in the working-copy diff.
803
  pub head_added: u32,
804
  /// Lines removed in the working-copy diff.
805
  pub head_removed: u32,
806
}
807

808
/// CI check status for a workspace's bookmark, queried from gh/glab.
809
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
810
pub enum CiStatus {
811
  /// All checks passed.
812
  Pass,
813
  /// One or more checks failed.
814
  Fail,
815
  /// Checks are still running.
816
  Pending,
817
  /// No CI information available.
818
  #[default]
819
  None,
820
}
821

822
/// Renders the CI status as a lowercase string.
823
impl fmt::Display for CiStatus {
824
  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54✔
825
    match self {
54✔
826
      CiStatus::Pass => f.write_str("pass"),
6✔
827
      CiStatus::Fail => f.write_str("fail"),
6✔
828
      CiStatus::Pending => f.write_str("pending"),
6✔
829
      CiStatus::None => f.write_str("none"),
144✔
830
    }
831
  }
832
}
833

834
/// Raw per-workspace data observed by the shell for list rendering.
835
#[derive(Debug, Clone, PartialEq, Eq)]
836
pub struct ObservedListRow {
837
  /// The workspace identity and path.
838
  pub workspace: Workspace,
839
  /// Working-copy status and commit metadata.
840
  pub details: WorkspaceDetails,
841
  /// Commits ahead of trunk.
842
  pub ahead: u32,
843
  /// Commits behind trunk.
844
  pub behind: u32,
845
  /// True when the bookmark for this workspace has a remote-tracking
846
  /// variant (e.g. `<name>@origin`).
847
  pub has_remote_bookmark: bool,
848
  /// CI check status from forge CLI (gh/glab). Only populated when
849
  /// `--full` is used.
850
  pub ci_status: CiStatus,
851
  /// LLM-generated one-liner summary. Only populated when `--full` is
852
  /// used and `[list] summary = true`.
853
  pub summary: String,
854
}
855

856
/// State for the prune command: all workspaces with their merge status.
857
#[derive(Debug, Clone, Default)]
858
pub struct ObservedPruneState {
859
  /// Absolute path to the repository root.
860
  pub repo_root: PathBuf,
861
  /// Whether the current directory is inside a jj repository.
862
  pub is_jj_repo: bool,
863
  /// Name of the workspace containing cwd, if any.
864
  pub current_workspace: Option<String>,
865
  /// All registered workspaces.
866
  pub workspaces: Vec<Workspace>,
867
  /// Per-workspace: (bookmark_exists, bookmark_merged, workspace_dirty).
868
  pub workspace_status: Vec<(String, bool, bool, bool)>,
869
}
870

871
/// Presentation hints observed from the terminal environment. The shell
872
/// constructs this from I/O (terminal detection, `NO_COLOR`, terminal
873
/// size) and passes it into the core as plain data.
874
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
875
pub struct DisplayHints {
876
  /// Whether to emit ANSI escape sequences.
877
  pub styled: bool,
878
  /// Terminal width in columns, if known. `None` means unbounded (e.g.
879
  /// piped output).
880
  pub term_width: Option<u16>,
881
}
882

883
/// Full observed state for the list subcommand.
884
#[derive(Debug, Clone, PartialEq, Eq, Default)]
885
pub struct ObservedListState {
886
  /// Absolute path to the repository root.
887
  pub repo_root: PathBuf,
888
  /// Whether the current directory is inside a jj repository.
889
  pub is_jj_repo: bool,
890
  /// Name of the workspace whose path contains cwd, if any.
891
  pub current_workspace: Option<String>,
892
  /// Per-workspace observation data.
893
  pub rows: Vec<ObservedListRow>,
894
  /// Names of bookmarks without a workspace, only populated when the
895
  /// caller asked for `--bookmarks`.
896
  pub extra_bookmark_names: Vec<String>,
897
  /// Names of remote-only bookmarks, only populated when the caller
898
  /// asked for `--remotes`. Format: bare local name (the `@<remote>`
899
  /// suffix is stripped).
900
  pub extra_remote_only_names: Vec<String>,
901
  /// Whether `--full` mode is active (show all columns).
902
  pub full: bool,
903
}
904

905
/// What kind of row this is. `Workspace` rows have a real path and full
906
/// observation details. `Bookmark` rows are bookmarks without a workspace
907
/// (either local-only-no-worktree or remote-only) and have empty
908
/// working-copy state.
909
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
910
pub enum ListRowKind {
911
  /// Row represents a registered jj workspace.
912
  #[default]
913
  Workspace,
914
  /// Row represents a bookmark without a workspace.
915
  Bookmark,
916
}
917

918
/// Options that gate which rows `observe_list` collects.
919
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
920
pub struct ListOptions {
921
  /// Include local bookmarks that don't have a workspace.
922
  pub include_bookmarks: bool,
923
  /// Include remote-only bookmarks (`<name>@<remote>` with no local).
924
  pub include_remotes: bool,
925
  /// Show additional columns (CI, URL, Commit, Age, Summary) and query
926
  /// CI status and LLM summaries when enabled.
927
  pub full: bool,
928
}
929

930
/// A fully resolved row for the list table, ready for rendering.
931
#[derive(Debug, Clone, PartialEq, Eq)]
932
pub struct ListRow {
933
  /// Workspace name (also bookmark name by jjwt convention).
934
  pub name: String,
935
  /// Absolute on-disk path of the workspace (empty for `Branch` rows).
936
  pub path: PathBuf,
937
  /// Relative display path for the list table (e.g. ".", "./sibling.feat").
938
  pub display_path: String,
939
  /// Whether this row represents a workspace or a standalone branch.
940
  pub kind: ListRowKind,
941
  /// Rendered from `[list].url`; "" if no config.
942
  pub url: String,
943
  /// Workspace whose path contains cwd.
944
  pub is_current: bool,
945
  /// Workspace name is "default" (lives at repo root).
946
  pub is_default: bool,
947
  /// Working-copy and trunk-relationship status indicators.
948
  pub status: StatusFlags,
949
  /// Working-copy line diff (`jj diff -r @ --stat`).
950
  pub head_diff: LineDiff,
951
  /// Commits ahead of and behind trunk.
952
  pub vs_trunk: AheadBehind,
953
  /// 8-char short change ID for the workspace's `@`.
954
  pub commit: String,
955
  /// Pre-formatted relative age (e.g. "9h", "2w", "1mo").
956
  pub age: String,
957
  /// First line of `@`'s description.
958
  pub message: String,
959
  /// CI check status from forge CLI.
960
  pub ci_status: CiStatus,
961
  /// LLM-generated one-liner summary.
962
  pub summary: String,
963
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc