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

endoze / jjwt / 26647148499

29 May 2026 03:48PM UTC coverage: 97.757% (-0.3%) from 98.105%
26647148499

push

github

web-flow
Merge pull request #7 from endoze/fix/workspace-remove-panic

fix(jj): prevent panic when removing a workspace

1351 of 1382 relevant lines covered (97.76%)

174.31 hits per line

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

99.1
/src/core/plan.rs
1
use crate::core::format::{format_age, format_list_json, format_list_table, format_remove_json};
2
use crate::core::template::render;
3
use crate::core::types::*;
4
use serde_json::json;
5
use std::collections::HashMap;
6
use std::path::{Path, PathBuf};
7

8
/// Compute the on-disk path for a workspace by rendering the worktree-path
9
/// template from config.
10
fn workspace_path(root: &Path, name: &str, template: &str) -> Result<PathBuf, CoreError> {
40✔
11
  if name == "default" {
40✔
12
    return Ok(root.to_path_buf());
2✔
13
  }
14

15
  let ctx = RenderContext {
16
    branch: name.into(),
76✔
17
    repo: root.file_name().map(|n| n.to_string_lossy().into_owned()),
228✔
18
    repo_path: Some(root.to_path_buf()),
38✔
19
    ..Default::default()
20
  };
21
  let rendered = render(template, &ctx)?;
152✔
22

23
  Ok(root.join(rendered))
76✔
24
}
25

26
/// Build the environment variable list injected into hook subprocesses.
27
fn hook_env(
48✔
28
  workspace: &str,
29
  ws_path: &Path,
30
  hook_type: &str,
31
  hook_name: &str,
32
  source: HookSource,
33
) -> Vec<(String, String)> {
34
  vec![
48✔
35
    ("JJWT_WORKSPACE".into(), workspace.into()),
144✔
36
    ("JJWT_WORKSPACE_PATH".into(), ws_path.display().to_string()),
144✔
37
    ("JJWT_HOOK_TYPE".into(), hook_type.into()),
144✔
38
    ("JJWT_HOOK_NAME".into(), hook_name.into()),
144✔
39
    ("JJWT_HOOK_SOURCE".into(), source.to_string()),
144✔
40
  ]
41
}
42

43
/// Build the template render context for a hook whose template will run in
44
/// `ws_path`. `hook_type` is the canonical hook-event name (e.g.
45
/// `pre-start`); `hook_name` is the named key inside the hook group.
46
fn render_ctx(
60✔
47
  branch: &str,
48
  ws_path: &Path,
49
  repo_root: &Path,
50
  hook_type: &str,
51
  hook_name: &str,
52
) -> RenderContext {
53
  RenderContext {
54
    branch: branch.into(),
120✔
55
    worktree_path: Some(ws_path.to_path_buf()),
60✔
56
    worktree_name: ws_path
60✔
57
      .file_name()
58
      .map(|n| n.to_string_lossy().into_owned()),
59
    repo: repo_root
60✔
60
      .file_name()
61
      .map(|n| n.to_string_lossy().into_owned()),
62
    repo_path: Some(repo_root.to_path_buf()),
60✔
63
    cwd: Some(ws_path.to_path_buf()),
60✔
64
    hook_type: Some(hook_type.into()),
60✔
65
    hook_name: Some(hook_name.into()),
60✔
66
    args: Vec::new(),
60✔
67
    vars: Vec::new(),
60✔
68
    vars_state: HashMap::new(),
60✔
69
  }
70
}
71

72
/// Render all hooks in the given groups into `RunHook` actions.
73
fn render_hook_group(
206✔
74
  groups: &[SourcedHookGroup],
75
  hook_type: &str,
76
  branch: &str,
77
  ws_path: &Path,
78
  repo_root: &Path,
79
) -> Result<Vec<Action>, CoreError> {
80
  let mut out = Vec::new();
412✔
81

82
  for shg in groups {
238✔
83
    for (name, tmpl) in &shg.group {
120✔
84
      let ctx = render_ctx(branch, ws_path, repo_root, hook_type, name);
308✔
85
      let rendered = render(tmpl, &ctx)?;
176✔
86

87
      out.push(Action::RunHook {
132✔
88
        name: name.clone(),
132✔
89
        raw_cmd: tmpl.clone(),
132✔
90
        rendered_cmd: rendered,
88✔
91
        cwd: ws_path.to_path_buf(),
132✔
92
        env: hook_env(branch, ws_path, hook_type, name, shg.source),
264✔
93
        source: shg.source,
44✔
94
      });
95
    }
96
  }
97

98
  Ok(out)
206✔
99
}
100

101
impl Plan {
102
  /// Render hooks from the given groups and append them to this plan.
103
  /// When `run` is false, does nothing — used to honor `--no-hooks`
104
  /// without splattering branches across every call site.
105
  fn extend_hooks(
214✔
106
    &mut self,
107
    run: bool,
108
    groups: &[SourcedHookGroup],
109
    hook_type: &str,
110
    branch: &str,
111
    ws_path: &Path,
112
    repo_root: &Path,
113
  ) -> Result<(), CoreError> {
114
    if run {
214✔
115
      self.actions.extend(render_hook_group(
618✔
116
        groups, hook_type, branch, ws_path, repo_root,
1,030✔
117
      )?);
118
    }
119

120
    Ok(())
214✔
121
  }
122
}
123

124
/// Build a plan for the `switch` subcommand (create or switch to a workspace).
125
pub fn plan_switch(
50✔
126
  cfg: &MergedConfig,
127
  args: &SwitchArgs,
128
  obs: &ObservedState,
129
) -> Result<Plan, CoreError> {
130
  if !obs.is_jj_repo {
50✔
131
    return Err(CoreError::NotJjRepo);
2✔
132
  }
133

134
  if args.create {
48✔
135
    plan_switch_create(cfg, args, obs)
136✔
136
  } else {
137
    plan_switch_existing(cfg, args, obs)
56✔
138
  }
139
}
140

141
/// Create a new workspace and switch to it.
142
fn plan_switch_create(
34✔
143
  cfg: &MergedConfig,
144
  args: &SwitchArgs,
145
  obs: &ObservedState,
146
) -> Result<Plan, CoreError> {
147
  if obs.workspaces.iter().any(|w| w.name == args.name) {
76✔
148
    return Err(CoreError::WorkspaceExists(args.name.clone()));
2✔
149
  }
150

151
  let ws_path = workspace_path(
152
    &obs.repo_root,
32✔
153
    &args.name,
32✔
154
    &cfg.worktree_path_template,
32✔
155
  )?;
156

157
  let mut plan = Plan::new();
64✔
158

159
  // Stale directory at the target — usually leftover from an
160
  // interrupted `jj workspace add`. Require `--clobber` to consent to
161
  // removing it, and never clobber when the path is inside an existing
162
  // workspace (that would torch real user data).
163
  if obs.target_path_exists {
32✔
164
    if !args.clobber {
6✔
165
      return Err(CoreError::TargetPathOccupied(ws_path.display().to_string()));
2✔
166
    }
167

168
    let inside_other = obs
8✔
169
      .workspaces
4✔
170
      .iter()
171
      .any(|w| ws_path != w.path && ws_path.starts_with(&w.path));
10✔
172

173
    if inside_other {
4✔
174
      return Err(CoreError::TargetPathInsideOtherWorkspace(
2✔
175
        ws_path.display().to_string(),
2✔
176
      ));
177
    }
178

179
    plan.push(Action::DeleteDir {
6✔
180
      path: ws_path.clone(),
2✔
181
    });
182
  }
183

184
  // `pre-switch` fires before the workspace exists; it runs in the
185
  // repo root so the user still has a cwd to operate from.
186
  plan.extend_hooks(
56✔
187
    !args.no_hooks,
28✔
188
    &cfg.hooks.pre_switch,
28✔
189
    "pre-switch",
190
    &args.name,
28✔
191
    &ws_path,
28✔
192
    &obs.repo_root,
28✔
193
  )?;
194

195
  let revision = args
56✔
196
    .base
28✔
197
    .as_ref()
198
    .or(obs.trunk_bookmark.as_ref())
84✔
199
    .cloned();
200

201
  plan.push(Action::JjWorkspaceAdd {
84✔
202
    name: args.name.clone(),
84✔
203
    path: ws_path.clone(),
56✔
204
    revision,
28✔
205
  });
206
  plan.push(Action::JjBookmarkCreate {
84✔
207
    name: args.name.clone(),
84✔
208
    workspace: args.name.clone(),
28✔
209
  });
210
  plan.push(Action::JjWorkspaceUpdateStale {
84✔
211
    name: args.name.clone(),
28✔
212
  });
213

214
  plan.extend_hooks(
56✔
215
    !args.no_hooks,
28✔
216
    &cfg.hooks.pre_start,
28✔
217
    "pre-start",
218
    &args.name,
28✔
219
    &ws_path,
28✔
220
    &obs.repo_root,
28✔
221
  )?;
222
  plan.extend_hooks(
56✔
223
    !args.no_hooks,
28✔
224
    &cfg.hooks.post_start,
28✔
225
    "post-start",
226
    &args.name,
28✔
227
    &ws_path,
28✔
228
    &obs.repo_root,
28✔
229
  )?;
230

231
  emit_switch_output(
232
    &mut plan,
28✔
233
    &args.name,
28✔
234
    &ws_path,
28✔
235
    &obs.repo_root,
28✔
236
    args.execute.as_deref(),
56✔
237
    args.format,
28✔
238
    true,
239
  )?;
240

241
  plan.extend_hooks(
56✔
242
    !args.no_hooks,
28✔
243
    &cfg.hooks.post_switch,
28✔
244
    "post-switch",
245
    &args.name,
28✔
246
    &ws_path,
28✔
247
    &obs.repo_root,
28✔
248
  )?;
249

250
  Ok(plan)
28✔
251
}
252

253
/// Switch to an existing workspace, optionally re-running start hooks.
254
fn plan_switch_existing(
14✔
255
  cfg: &MergedConfig,
256
  args: &SwitchArgs,
257
  obs: &ObservedState,
258
) -> Result<Plan, CoreError> {
259
  // Prefer a direct workspace match; otherwise honor the trunk-bookmark
260
  // fallback (e.g. `switch main` -> default workspace).
261
  let ws = obs
26✔
262
    .workspaces
14✔
263
    .iter()
264
    .find(|w| w.name == args.name)
38✔
265
    .or_else(|| {
18✔
266
      obs
4✔
267
        .target_resolved_workspace
4✔
268
        .as_deref()
4✔
269
        .and_then(|n| obs.workspaces.iter().find(|w| w.name == n))
12✔
270
    })
271
    .ok_or_else(|| CoreError::WorkspaceMissing(args.name.clone()))?;
20✔
272

273
  let mut plan = Plan::new();
24✔
274

275
  plan.extend_hooks(
24✔
276
    !args.no_hooks,
12✔
277
    &cfg.hooks.pre_switch,
12✔
278
    "pre-switch",
279
    &ws.name,
12✔
280
    &ws.path,
12✔
281
    &obs.repo_root,
12✔
282
  )?;
283

284
  if ws.stale {
14✔
285
    plan.push(Action::JjWorkspaceUpdateStale {
6✔
286
      name: ws.name.clone(),
2✔
287
    });
288
  }
289

290
  if args.rerun_hooks {
12✔
291
    plan.extend_hooks(
4✔
292
      !args.no_hooks,
2✔
293
      &cfg.hooks.pre_start,
2✔
294
      "pre-start",
295
      &ws.name,
2✔
296
      &ws.path,
2✔
297
      &obs.repo_root,
2✔
298
    )?;
299
  }
300

301
  emit_switch_output(
302
    &mut plan,
12✔
303
    &ws.name,
12✔
304
    &ws.path,
12✔
305
    &obs.repo_root,
12✔
306
    args.execute.as_deref(),
24✔
307
    args.format,
12✔
308
    false,
309
  )?;
310

311
  plan.extend_hooks(
24✔
312
    !args.no_hooks,
12✔
313
    &cfg.hooks.post_switch,
12✔
314
    "post-switch",
315
    &ws.name,
12✔
316
    &ws.path,
12✔
317
    &obs.repo_root,
12✔
318
  )?;
319

320
  Ok(plan)
12✔
321
}
322

323
/// Emit the `PrintLine`s that the shell wrapper consumes after a switch.
324
///
325
/// * **Text, no `--execute`:** one line — the workspace path. The fish/bash
326
///   wrappers `cd` to it (and stay backward-compatible with older binaries).
327
/// * **Text, `--execute "<cmd>"`:** two lines, `cd:<path>` then
328
///   `exec:<rendered-cmd>`. Wrappers parse the prefixes; the `exec` is
329
///   passed to `eval` after `cd`. Users on outdated wrappers must
330
///   re-source `config shell init` to use `-x`.
331
/// * **JSON:** one structured line carrying `name`, `path`, `created`, and
332
///   the rendered `execute` command when present. Wrappers ignore JSON
333
///   output; it exists for tool integration.
334
fn emit_switch_output(
40✔
335
  plan: &mut Plan,
336
  name: &str,
337
  ws_path: &Path,
338
  repo_root: &Path,
339
  execute_tmpl: Option<&str>,
340
  format: OutputFormat,
341
  created: bool,
342
) -> Result<(), CoreError> {
343
  let rendered_exec = if let Some(tmpl) = execute_tmpl {
84✔
344
    let ctx = render_ctx(name, ws_path, repo_root, "switch", "execute");
28✔
345
    let r = render(tmpl, &ctx)?;
16✔
346

347
    Some(r)
4✔
348
  } else {
349
    None
36✔
350
  };
351

352
  match format {
40✔
353
    OutputFormat::Json => {
354
      let mut obj = json!({
12✔
355
        "name": name,
6✔
356
        "path": ws_path.display().to_string(),
18✔
357
        "created": created,
6✔
358
      });
359

360
      if let Some(cmd) = &rendered_exec {
10✔
361
        obj["execute"] = json!(cmd);
2✔
362
      }
363

364
      plan.push(Action::PrintLine(
18✔
365
        serde_json::to_string(&obj).expect("json"),
18✔
366
      ));
367
    }
368
    OutputFormat::Text | OutputFormat::Statusline => {
369
      if let Some(cmd) = rendered_exec {
38✔
370
        plan.push(Action::PrintLine(format!("cd:{}", ws_path.display())));
10✔
371
        plan.push(Action::PrintLine(format!("exec:{cmd}")));
4✔
372
      } else {
373
        plan.push(Action::PrintLine(ws_path.display().to_string()));
96✔
374
      }
375
    }
376
  }
377

378
  Ok(())
40✔
379
}
380

381
/// Build a plan for the `remove` subcommand (forget workspace and clean up).
382
pub fn plan_remove(
32✔
383
  cfg: &MergedConfig,
384
  name: &str,
385
  args: &RemoveArgs,
386
  obs: &ObservedState,
387
) -> Result<Plan, CoreError> {
388
  if !obs.is_jj_repo {
32✔
389
    return Err(CoreError::NotJjRepo);
2✔
390
  }
391

392
  let ws = obs
58✔
393
    .workspaces
30✔
394
    .iter()
395
    .find(|w| w.name == name)
86✔
396
    .ok_or_else(|| CoreError::WorkspaceMissing(name.to_string()))?;
36✔
397

398
  if !args.force && obs.target_workspace_dirty {
42✔
399
    return Err(CoreError::WorkspaceDirty(name.to_string()));
2✔
400
  }
401

402
  // Unmerged-bookmark guard fires when the bookmark would be deleted
403
  // (the default) and isn't yet merged into trunk. `--force-delete`
404
  // (worktrunk's `-D`) opts in to deleting it anyway; `--force` (`-f`)
405
  // is about the worktree, not the bookmark, and on its own only
406
  // lets the *worktree* be removed — the bookmark stays.
407
  if !args.no_delete_branch
26✔
408
    && obs.target_bookmark_exists
22✔
409
    && !obs.target_bookmark_merged
20✔
410
    && !args.force_delete
6✔
411
    && !args.force
4✔
412
  {
413
    return Err(CoreError::BookmarkUnmerged(name.to_string()));
2✔
414
  }
415

416
  let ws_path = ws.path.clone();
72✔
417
  let mut plan = Plan::new();
48✔
418

419
  plan.extend_hooks(
48✔
420
    !args.no_hooks,
24✔
421
    &cfg.hooks.pre_remove,
24✔
422
    "pre-remove",
423
    name,
24✔
424
    &ws_path,
24✔
425
    &obs.repo_root,
24✔
426
  )?;
427

428
  plan.push(Action::JjWorkspaceForget {
72✔
429
    name: name.to_string(),
24✔
430
  });
431

432
  if cfg.background_remove == Some(true) {
26✔
433
    plan.push(Action::DeleteDirBackground {
6✔
434
      path: ws_path.clone(),
2✔
435
    });
436
  } else {
437
    plan.push(Action::DeleteDir {
66✔
438
      path: ws_path.clone(),
22✔
439
    });
440
  }
441

442
  // Delete the bookmark when:
443
  //   - it exists,
444
  //   - the user hasn't opted out via --no-delete-branch,
445
  //   - AND it's either already merged into trunk or the user explicitly
446
  //     asked for forced deletion (`-D`).
447
  let bookmark_deleted = obs.target_bookmark_exists
48✔
448
    && !args.no_delete_branch
22✔
449
    && (obs.target_bookmark_merged || args.force_delete);
22✔
450

451
  if bookmark_deleted {
40✔
452
    plan.push(Action::JjBookmarkDelete {
48✔
453
      name: name.to_string(),
16✔
454
    });
455
  }
456

457
  // `post-remove` runs after the workspace directory is gone; cwd is the
458
  // repo root (the runtime executes it there). Template vars still reflect
459
  // the removed workspace's identity since users may need its name/path
460
  // for cleanup ("docker stop {{ branch | sanitize }}-db" etc.).
461
  plan.extend_hooks(
48✔
462
    !args.no_hooks,
24✔
463
    &cfg.hooks.post_remove,
24✔
464
    "post-remove",
465
    name,
24✔
466
    &obs.repo_root,
24✔
467
    &obs.repo_root,
24✔
468
  )?;
469

470
  if let OutputFormat::Json = args.format {
28✔
471
    plan.push(Action::PrintLine(format_remove_json(
16✔
472
      name,
8✔
473
      &ws_path,
4✔
474
      bookmark_deleted,
4✔
475
    )));
476
  }
477

478
  Ok(plan)
24✔
479
}
480

481
/// Plan a user-defined alias invocation. Looks up `args.name` in
482
/// `cfg.aliases`, renders the template with the current observation
483
/// context (workspace identity if the cwd is inside one), and emits an
484
/// `Exec` action.
485
pub fn plan_alias(
6✔
486
  cfg: &MergedConfig,
487
  args: &AliasArgs,
488
  obs: &ObservedState,
489
) -> Result<Plan, CoreError> {
490
  let tmpl = cfg
10✔
491
    .aliases
6✔
492
    .get(&args.name)
12✔
493
    .ok_or_else(|| CoreError::AliasNotFound(args.name.clone()))?;
12✔
494

495
  // Locate the active workspace so vars like `branch` and `worktree_path`
496
  // bind sensibly. If cwd isn't inside any workspace, fall back to the
497
  // repo root so `repo` / `repo_path` still resolve.
498
  let (branch, ws_path) = match obs.current_workspace.as_deref() {
12✔
499
    Some(name) => {
2✔
500
      let ws = obs
4✔
501
        .workspaces
2✔
502
        .iter()
503
        .find(|w| w.name == name)
6✔
504
        .ok_or_else(|| CoreError::WorkspaceMissing(name.into()))?;
2✔
505

506
      (ws.name.clone(), ws.path.clone())
6✔
507
    }
508
    None => (String::new(), obs.repo_root.clone()),
4✔
509
  };
510

511
  let ctx = RenderContext {
512
    branch,
513
    worktree_path: Some(ws_path.clone()),
4✔
514
    worktree_name: ws_path
4✔
515
      .file_name()
516
      .map(|n| n.to_string_lossy().into_owned()),
517
    repo: obs
4✔
518
      .repo_root
519
      .file_name()
520
      .map(|n| n.to_string_lossy().into_owned()),
521
    repo_path: Some(obs.repo_root.clone()),
4✔
522
    cwd: Some(ws_path.clone()),
4✔
523
    hook_type: None,
524
    hook_name: Some(args.name.clone()),
4✔
525
    args: args.forwarded.clone(),
8✔
526
    vars: Vec::new(),
4✔
527
    vars_state: HashMap::new(),
4✔
528
  };
529
  let rendered = render(tmpl, &ctx)?;
16✔
530
  let mut plan = Plan::new();
8✔
531

532
  plan.push(Action::Exec {
12✔
533
    rendered_cmd: rendered,
8✔
534
    cwd: ws_path,
4✔
535
    env: Vec::new(),
4✔
536
  });
537

538
  Ok(plan)
4✔
539
}
540

541
/// Build a plan for the `hook` subcommand (manual single-hook invocation).
542
pub fn plan_hook(
10✔
543
  cfg: &MergedConfig,
544
  args: &HookArgs,
545
  obs: &ObservedState,
546
) -> Result<Plan, CoreError> {
547
  let ws = obs
18✔
548
    .workspaces
10✔
549
    .iter()
550
    .find(|w| w.name == args.current_workspace)
30✔
551
    .ok_or_else(|| CoreError::WorkspaceMissing(args.current_workspace.clone()))?;
16✔
552

553
  // Search every configured hook group; remember which one matched so
554
  // the rendered template can advertise the correct `hook_type` to the
555
  // user's command.
556
  let mut matches: Vec<(&str, &str, HookSource)> = Vec::new();
24✔
557

558
  for (hook_type, groups) in cfg.all_hook_groups() {
112✔
559
    for shg in groups {
74✔
560
      if let Some(tmpl) = shg.group.get(&args.name) {
68✔
561
        matches.push((hook_type, tmpl.as_str(), shg.source));
32✔
562
      }
563
    }
564
  }
565

566
  let (hook_type, tmpl, source) = match matches.len() {
20✔
567
    0 => return Err(CoreError::HookNotFound(args.name.clone())),
2✔
568
    1 => matches[0],
4✔
569
    _ => return Err(CoreError::HookAmbiguous(args.name.clone())),
2✔
570
  };
571

572
  let mut ctx = render_ctx(
573
    &args.current_workspace,
4✔
574
    &ws.path,
4✔
575
    &obs.repo_root,
4✔
576
    hook_type,
4✔
577
    &args.name,
4✔
578
  );
579
  ctx.vars = args.vars.clone();
12✔
580

581
  let rendered = render(tmpl, &ctx)?;
16✔
582
  let mut plan = Plan::new();
8✔
583

584
  plan.push(Action::RunHook {
12✔
585
    name: args.name.clone(),
12✔
586
    raw_cmd: tmpl.to_string(),
12✔
587
    rendered_cmd: rendered,
8✔
588
    cwd: ws.path.clone(),
12✔
589
    env: hook_env(
8✔
590
      &args.current_workspace,
8✔
591
      &ws.path,
8✔
592
      hook_type,
8✔
593
      &args.name,
8✔
594
      source,
4✔
595
    ),
596
    source,
4✔
597
  });
598

599
  Ok(plan)
4✔
600
}
601

602
/// Build a plan for the `relocate` subcommand (rename workspace and directory).
603
pub fn plan_relocate(
14✔
604
  cfg: &MergedConfig,
605
  args: &RelocateArgs,
606
  obs: &ObservedState,
607
) -> Result<Plan, CoreError> {
608
  if !obs.is_jj_repo {
14✔
609
    return Err(CoreError::NotJjRepo);
2✔
610
  }
611

612
  let ws = obs
22✔
613
    .workspaces
12✔
614
    .iter()
615
    .find(|w| w.name == args.old_name)
32✔
616
    .ok_or_else(|| CoreError::WorkspaceMissing(args.old_name.clone()))?;
18✔
617

618
  if obs.workspaces.iter().any(|w| w.name == args.new_name) {
44✔
619
    return Err(CoreError::WorkspaceExists(args.new_name.clone()));
2✔
620
  }
621

622
  let old_path = ws.path.clone();
24✔
623
  let new_path = workspace_path(
624
    &obs.repo_root,
8✔
625
    &args.new_name,
8✔
626
    &cfg.worktree_path_template,
8✔
627
  )?;
628

629
  let mut plan = Plan::new();
16✔
630

631
  plan.push(Action::JjWorkspaceRename {
24✔
632
    old_name: args.old_name.clone(),
24✔
633
    new_name: args.new_name.clone(),
8✔
634
  });
635
  plan.push(Action::RenameDir {
24✔
636
    from: old_path.clone(),
24✔
637
    to: new_path.clone(),
8✔
638
  });
639

640
  if args.rename_bookmark {
12✔
641
    plan.push(Action::JjBookmarkRename {
12✔
642
      old_name: args.old_name.clone(),
12✔
643
      new_name: args.new_name.clone(),
4✔
644
    });
645
  }
646

647
  match args.format {
8✔
648
    OutputFormat::Json => {
4✔
649
      let obj = json!({
12✔
650
        "old_name": args.old_name,
8✔
651
        "new_name": args.new_name,
8✔
652
        "old_path": old_path.display().to_string(),
16✔
653
        "new_path": new_path.display().to_string(),
16✔
654
        "bookmark_renamed": args.rename_bookmark,
8✔
655
      });
656

657
      plan.push(Action::PrintLine(
12✔
658
        serde_json::to_string(&obj).expect("json"),
12✔
659
      ));
660
    }
661
    OutputFormat::Text | OutputFormat::Statusline => {
4✔
662
      plan.push(Action::PrintLine(format!(
8✔
663
        "Relocated '{}' → '{}'",
4✔
664
        args.old_name, args.new_name
4✔
665
      )));
666
    }
667
  }
668

669
  Ok(plan)
8✔
670
}
671

672
/// Build a plan for the `prune` subcommand (remove all merged workspaces).
673
pub fn plan_prune(
22✔
674
  cfg: &MergedConfig,
675
  args: &PruneArgs,
676
  obs: &ObservedPruneState,
677
) -> Result<Plan, CoreError> {
678
  if !obs.is_jj_repo {
22✔
679
    return Err(CoreError::NotJjRepo);
2✔
680
  }
681

682
  let mut plan = Plan::new();
40✔
683
  let mut pruned: Vec<String> = Vec::new();
60✔
684

685
  for (name, bm_exists, bm_merged, _dirty) in &obs.workspace_status {
204✔
686
    // Skip default workspace and current workspace.
687
    if name == "default" {
46✔
688
      continue;
20✔
689
    }
690

691
    if obs.current_workspace.as_deref() == Some(name.as_str()) {
52✔
692
      continue;
4✔
693
    }
694

695
    // Only prune if the bookmark is merged into trunk.
696
    if !bm_exists || !bm_merged {
44✔
697
      continue;
4✔
698
    }
699

700
    let Some(ws) = obs.workspaces.iter().find(|w| &w.name == name) else {
138✔
701
      continue;
×
702
    };
703

704
    if args.dry_run {
18✔
705
      pruned.push(name.clone());
16✔
706

707
      continue;
4✔
708
    }
709

710
    // Emit the same actions as plan_remove for each merged workspace.
711
    plan.extend_hooks(
28✔
712
      !args.no_hooks,
14✔
713
      &cfg.hooks.pre_remove,
14✔
714
      "pre-remove",
715
      name,
14✔
716
      &ws.path,
14✔
717
      &obs.repo_root,
14✔
718
    )?;
719

720
    plan.push(Action::JjWorkspaceForget { name: name.clone() });
42✔
721

722
    if cfg.background_remove == Some(true) {
16✔
723
      plan.push(Action::DeleteDirBackground {
6✔
724
        path: ws.path.clone(),
2✔
725
      });
726
    } else {
727
      plan.push(Action::DeleteDir {
36✔
728
        path: ws.path.clone(),
12✔
729
      });
730
    }
731

732
    plan.push(Action::JjBookmarkDelete { name: name.clone() });
42✔
733

734
    plan.extend_hooks(
28✔
735
      !args.no_hooks,
14✔
736
      &cfg.hooks.post_remove,
14✔
737
      "post-remove",
738
      name,
14✔
739
      &obs.repo_root,
14✔
740
      &obs.repo_root,
14✔
741
    )?;
742

743
    pruned.push(name.clone());
56✔
744
  }
745

746
  // Output.
747
  match args.format {
20✔
748
    OutputFormat::Json => {
6✔
749
      plan.push(Action::PrintLine(
18✔
750
        serde_json::to_string(&serde_json::json!({
24✔
751
          "dry_run": args.dry_run,
12✔
752
          "pruned": pruned,
12✔
753
        }))
754
        .expect("json"),
6✔
755
      ));
756
    }
757
    OutputFormat::Text | OutputFormat::Statusline => {
758
      if pruned.is_empty() {
32✔
759
        plan.push(Action::PrintLine("Nothing to prune.".into()));
12✔
760
      } else if args.dry_run {
16✔
761
        plan.push(Action::PrintLine(format!(
8✔
762
          "Would prune {} workspace(s): {}",
2✔
763
          pruned.len(),
6✔
764
          pruned.join(", ")
4✔
765
        )));
766
      } else {
767
        plan.push(Action::PrintLine(format!(
32✔
768
          "Pruned {} workspace(s): {}",
8✔
769
          pruned.len(),
24✔
770
          pruned.join(", ")
16✔
771
        )));
772
      }
773
    }
774
  }
775

776
  Ok(plan)
20✔
777
}
778

779
/// Derive the trunk relationship from ahead/behind commit counts.
780
fn trunk_rel(ahead: u32, behind: u32) -> Option<TrunkRel> {
48✔
781
  match (ahead, behind) {
48✔
782
    (0, 0) => Some(TrunkRel::IsTrunk),
23✔
783
    (0, _) => Some(TrunkRel::Ancestor),
1✔
784
    (_, 0) => Some(TrunkRel::Ahead),
1✔
785
    (_, _) => Some(TrunkRel::Diverged),
23✔
786
  }
787
}
788

789
/// Transform an observed workspace row into a renderable `ListRow`.
790
fn build_list_row(
44✔
791
  cfg: &MergedConfig,
792
  obs_row: &ObservedListRow,
793
  repo_root: &Path,
794
  is_current: bool,
795
) -> Result<ListRow, CoreError> {
796
  let w = &obs_row.workspace;
88✔
797
  let d = &obs_row.details;
88✔
798
  let is_default = w.path == repo_root;
88✔
799
  let url = if let Some(list) = &cfg.list {
124✔
800
    let ctx = RenderContext {
801
      branch: w.name.clone(),
72✔
802
      worktree_path: Some(w.path.clone()),
36✔
803
      worktree_name: w.path.file_name().map(|n| n.to_string_lossy().into_owned()),
180✔
804
      repo: repo_root
36✔
805
        .file_name()
806
        .map(|n| n.to_string_lossy().into_owned()),
807
      repo_path: Some(repo_root.to_path_buf()),
36✔
808
      cwd: Some(w.path.clone()),
36✔
809
      ..Default::default()
810
    };
811

812
    render(&list.url, &ctx)?
108✔
813
  } else {
814
    String::new()
8✔
815
  };
816

817
  // A workspace shows `|` when its bookmark has a remote variant. As a
818
  // small convenience: workspaces sitting exactly on `trunk()` also show
819
  // `|` since trunk in practice tracks an upstream — this catches the
820
  // `default` workspace whose name doesn't itself match a bookmark.
821
  let is_on_trunk = obs_row.ahead == 0 && obs_row.behind == 0;
110✔
822
  let has_remote = obs_row.has_remote_bookmark || is_on_trunk;
110✔
823
  let status = StatusFlags {
824
    has_changes: d.head_added > 0 || d.head_removed > 0,
88✔
825
    modified: d.modified,
44✔
826
    untracked: d.untracked,
44✔
827
    stale: w.stale,
44✔
828
    conflicts: d.conflicts,
44✔
829
    has_remote,
830
    vs_trunk: trunk_rel(obs_row.ahead, obs_row.behind),
132✔
831
  };
832

833
  let display_path = if is_default {
88✔
834
    ".".to_string()
44✔
835
  } else {
836
    w.path
44✔
837
      .strip_prefix(repo_root)
22✔
838
      .map(|rel| format!("./{}", rel.display()))
88✔
839
      .unwrap_or_else(|_| w.path.display().to_string())
22✔
840
  };
841

842
  Ok(ListRow {
44✔
843
    name: w.name.clone(),
132✔
844
    path: w.path.clone(),
132✔
845
    display_path,
88✔
846
    kind: ListRowKind::Workspace,
88✔
847
    url,
88✔
848
    is_current,
88✔
849
    is_default,
88✔
850
    status,
88✔
851
    head_diff: LineDiff {
88✔
852
      added: d.head_added,
88✔
853
      removed: d.head_removed,
88✔
854
    },
855
    vs_trunk: AheadBehind {
88✔
856
      ahead: obs_row.ahead,
88✔
857
      behind: obs_row.behind,
88✔
858
    },
859
    commit: d.commit_short.clone(),
132✔
860
    age: format_age(d.age_seconds),
132✔
861
    message: d.message_first_line.clone(),
132✔
862
    ci_status: obs_row.ci_status,
88✔
863
    summary: obs_row.summary.clone(),
44✔
864
  })
865
}
866

867
/// Build a placeholder row for a bookmark that doesn't have a workspace.
868
/// Phase 1 leaves working-copy details empty; richer details can be added
869
/// in Phase 2 alongside `worktree-path` template support.
870
fn build_bookmark_row(name: &str) -> ListRow {
2✔
871
  ListRow {
872
    name: name.into(),
6✔
873
    path: PathBuf::new(),
4✔
874
    display_path: String::new(),
4✔
875
    kind: ListRowKind::Bookmark,
876
    url: String::new(),
4✔
877
    is_current: false,
878
    is_default: false,
879
    status: StatusFlags::default(),
4✔
880
    head_diff: LineDiff::default(),
4✔
881
    vs_trunk: AheadBehind::default(),
4✔
882
    commit: String::new(),
4✔
883
    age: String::new(),
4✔
884
    message: String::new(),
4✔
885
    ci_status: CiStatus::None,
886
    summary: String::new(),
2✔
887
  }
888
}
889

890
/// Build a plan for the `list` subcommand (render workspace table).
891
pub fn plan_list(
24✔
892
  cfg: &MergedConfig,
893
  obs: &ObservedListState,
894
  display: &DisplayHints,
895
  format: OutputFormat,
896
) -> Result<Plan, CoreError> {
897
  if !obs.is_jj_repo {
24✔
898
    return Err(CoreError::NotJjRepo);
2✔
899
  }
900

901
  let mut rows = Vec::with_capacity(
902
    obs.rows.len() + obs.extra_bookmark_names.len() + obs.extra_remote_only_names.len(),
88✔
903
  );
904

905
  for r in &obs.rows {
66✔
906
    let is_current = obs.current_workspace.as_deref() == Some(r.workspace.name.as_str());
132✔
907

908
    rows.push(build_list_row(cfg, r, &obs.repo_root, is_current)?);
308✔
909
  }
910

911
  for n in &obs.extra_bookmark_names {
26✔
912
    rows.push(build_bookmark_row(n));
6✔
913
  }
914

915
  for n in &obs.extra_remote_only_names {
22✔
916
    rows.push(build_bookmark_row(n));
×
917
  }
918

919
  let mut plan = Plan::new();
44✔
920

921
  let current = obs.current_workspace.as_deref();
66✔
922

923
  let body = match format {
44✔
924
    OutputFormat::Text => format_list_table(&rows, display.styled, display.term_width, obs.full),
60✔
925
    OutputFormat::Json => format_list_json(&rows),
16✔
926
    OutputFormat::Statusline => crate::core::format::format_statusline(&rows, current),
6✔
927
  };
928

929
  plan.push(Action::PrintLine(body));
66✔
930

931
  Ok(plan)
22✔
932
}
933

934
/// Render a hook template using the current workspace context, returning
935
/// `None` when no observed state is available.
936
fn render_hook_template(obs: Option<&ObservedState>, tmpl: &str) -> Option<String> {
12✔
937
  obs.map(|obs| {
32✔
938
    let (branch, ws_path) = current_workspace_or_root(obs);
24✔
939
    let ctx = render_ctx(&branch, &ws_path, &obs.repo_root, "", "");
56✔
940

941
    match render(tmpl, &ctx) {
16✔
942
      Ok(rendered) => rendered,
16✔
943
      Err(e) => format!("<error: {e}>"),
×
944
    }
945
  })
946
}
947

948
/// Render the hook entries as a JSON array and push the result onto `plan`.
949
fn plan_hook_show_json(
12✔
950
  entries: &[(&str, &str, &str, HookSource)],
951
  expanded: bool,
952
  obs: Option<&ObservedState>,
953
) -> Result<Plan, CoreError> {
954
  let mut plan = Plan::new();
24✔
955

956
  let items: Vec<serde_json::Value> = entries
36✔
957
    .iter()
958
    .map(|(hook_type, name, tmpl, source)| {
24✔
959
      let mut obj = json!({
24✔
960
        "type": hook_type,
12✔
961
        "name": name,
12✔
962
        "source": source.to_string(),
36✔
963
        "template": tmpl,
12✔
964
      });
965

966
      if expanded && let Some(rendered) = render_hook_template(obs, tmpl) {
32✔
967
        obj["rendered"] = json!(rendered);
4✔
968
      }
969

970
      obj
12✔
971
    })
972
    .collect();
973

974
  plan.push(Action::PrintLine(
36✔
975
    serde_json::to_string(&items).expect("json"),
36✔
976
  ));
977

978
  Ok(plan)
12✔
979
}
980

981
/// Render the hook entries as a human-readable text table and push the result
982
/// onto `plan`.
983
fn plan_hook_show_text(
10✔
984
  entries: &[(&str, &str, &str, HookSource)],
985
  expanded: bool,
986
  obs: Option<&ObservedState>,
987
) -> Result<Plan, CoreError> {
988
  let mut plan = Plan::new();
20✔
989
  let mut lines = Vec::new();
20✔
990

991
  // Compute column widths.
992
  let type_w = entries
20✔
993
    .iter()
994
    .map(|(t, _, _, _)| t.len())
34✔
995
    .max()
996
    .unwrap_or(4)
997
    .max(4);
998
  let name_w = entries
20✔
999
    .iter()
1000
    .map(|(_, n, _, _)| n.len())
34✔
1001
    .max()
1002
    .unwrap_or(4)
1003
    .max(4);
1004
  let source_w = entries
20✔
1005
    .iter()
1006
    .map(|(_, _, _, s)| match s {
22✔
1007
      HookSource::User => 4,
×
1008
      HookSource::Project => 7,
12✔
1009
    })
1010
    .max()
1011
    .unwrap_or(6);
1012

1013
  // Header.
1014
  let last_col = if expanded { "Rendered" } else { "Template" };
30✔
1015

1016
  lines.push(format!(
30✔
1017
    "{:<type_w$}  {:<name_w$}  {:<source_w$}  {}",
1018
    "Type", "Name", "Source", last_col,
1019
  ));
1020

1021
  // Separator.
1022
  lines.push(format!(
30✔
1023
    "{:<type_w$}  {:<name_w$}  {:<source_w$}  {}",
1024
    "-".repeat(type_w),
30✔
1025
    "-".repeat(name_w),
30✔
1026
    "-".repeat(source_w),
30✔
1027
    "-".repeat(8),
20✔
1028
  ));
1029

1030
  for (hook_type, name, tmpl, source) in entries {
58✔
1031
    let display_val = if expanded {
24✔
1032
      render_hook_template(obs, tmpl)
18✔
1033
        .map(|r| truncate_line(&r, 60))
14✔
1034
        .unwrap_or_else(|| tmpl.to_string())
10✔
1035
    } else {
1036
      truncate_line(tmpl, 60)
12✔
1037
    };
1038

1039
    lines.push(format!(
36✔
1040
      "{:<type_w$}  {:<name_w$}  {:<source_w$}  {}",
1041
      hook_type, name, source, display_val,
1042
    ));
1043
  }
1044

1045
  plan.push(Action::PrintLine(lines.join("\n")));
40✔
1046

1047
  Ok(plan)
10✔
1048
}
1049

1050
/// Build a plan for `hook show` (list all configured hooks).
1051
pub fn plan_hook_show(
26✔
1052
  cfg: &MergedConfig,
1053
  expanded: bool,
1054
  obs: Option<&ObservedState>,
1055
  format: OutputFormat,
1056
  source_filter: Option<HookSource>,
1057
) -> Result<Plan, CoreError> {
1058
  // Gather all hooks across all types, applying source filter if set.
1059
  let mut entries: Vec<(&str, &str, &str, HookSource)> = Vec::new();
78✔
1060

1061
  for (hook_type, groups) in cfg.all_hook_groups() {
364✔
1062
    for shg in groups {
186✔
1063
      if let Some(filter) = source_filter
34✔
1064
        && shg.source != filter
4✔
1065
      {
1066
        continue;
2✔
1067
      }
1068

1069
      for (name, tmpl) in &shg.group {
112✔
1070
        entries.push((hook_type, name.as_str(), tmpl.as_str(), shg.source));
140✔
1071
      }
1072
    }
1073
  }
1074

1075
  if entries.is_empty() {
52✔
1076
    let mut plan = Plan::new();
4✔
1077

1078
    plan.push(Action::PrintLine("No hooks configured.".into()));
6✔
1079

1080
    return Ok(plan);
2✔
1081
  }
1082

1083
  match format {
24✔
1084
    OutputFormat::Json => plan_hook_show_json(&entries, expanded, obs),
48✔
1085
    OutputFormat::Text => plan_hook_show_text(&entries, expanded, obs),
40✔
1086
    OutputFormat::Statusline => {
1087
      let mut plan = Plan::new();
4✔
1088

1089
      // Statusline not meaningful for hook show; fall back to text count.
1090
      plan.push(Action::PrintLine(format!(
8✔
1091
        "{} hook(s) configured",
2✔
1092
        entries.len()
2✔
1093
      )));
1094

1095
      Ok(plan)
2✔
1096
    }
1097
  }
1098
}
1099

1100
/// Extract current workspace branch + path from observation, falling back to
1101
/// repo root when not inside a workspace.
1102
fn current_workspace_or_root(obs: &ObservedState) -> (String, PathBuf) {
8✔
1103
  obs
8✔
1104
    .current_workspace
8✔
1105
    .as_deref()
1106
    .and_then(|name| obs.workspaces.iter().find(|w| w.name == name))
36✔
1107
    .map(|w| (w.name.clone(), w.path.clone()))
32✔
1108
    .unwrap_or_else(|| (String::new(), obs.repo_root.clone()))
14✔
1109
}
1110

1111
/// Truncate a string to `max` visible characters, appending `...` if shortened.
1112
/// Uses `char_indices()` to avoid panicking on multi-byte UTF-8 boundaries.
1113
fn truncate_line(s: &str, max: usize) -> String {
20✔
1114
  let first_line = s.lines().next().unwrap_or(s);
100✔
1115

1116
  if max <= 3 {
20✔
1117
    // Short max: only emit dots if we actually need to truncate.
1118
    let mut chars = first_line.chars();
9✔
1119

1120
    if chars.by_ref().take(max + 1).count() <= max {
12✔
1121
      return first_line.to_string();
×
1122
    }
1123

1124
    return ".".repeat(max);
9✔
1125
  }
1126

1127
  let target = max - 3;
34✔
1128
  let mut last_idx = 0;
34✔
1129
  let mut count = 0;
34✔
1130

1131
  for (idx, ch) in first_line.char_indices() {
590✔
1132
    count += 1;
278✔
1133

1134
    if count <= target {
529✔
1135
      last_idx = idx + ch.len_utf8();
502✔
1136
    } else if count > max {
278✔
1137
      return format!("{}...", &first_line[..last_idx]);
12✔
1138
    }
1139
  }
1140

1141
  // Walked the whole string and the char count never exceeded `max`.
1142
  first_line.to_string()
22✔
1143
}
1144

1145
#[cfg(test)]
1146
mod tests {
1147
  use super::*;
1148

1149
  #[test]
1150
  fn truncate_line_no_op_when_short() {
1151
    assert_eq!(truncate_line("hello", 10), "hello");
1152
  }
1153

1154
  #[test]
1155
  fn truncate_line_exact_length() {
1156
    assert_eq!(truncate_line("hello", 5), "hello");
1157
  }
1158

1159
  #[test]
1160
  fn truncate_line_ascii_truncation() {
1161
    assert_eq!(truncate_line("hello world", 8), "hello...");
1162
  }
1163

1164
  #[test]
1165
  fn truncate_line_multibyte_utf8() {
1166
    // "äöü" is 6 bytes but 3 chars — must not panic
1167
    assert_eq!(truncate_line("äöüxyz", 5), "äö...");
1168
  }
1169

1170
  #[test]
1171
  fn truncate_line_cjk() {
1172
    assert_eq!(truncate_line("日本語テスト", 5), "日本...");
1173
  }
1174

1175
  #[test]
1176
  fn truncate_line_emoji() {
1177
    // Each emoji is 4 bytes but 1 char — must not panic
1178
    assert_eq!(truncate_line("🎉🎊🎈🎁🎀🎗️", 5), "🎉🎊...");
1179
  }
1180

1181
  #[test]
1182
  fn truncate_line_max_less_than_three() {
1183
    assert_eq!(truncate_line("hello", 2), "..");
1184
    assert_eq!(truncate_line("hello", 1), ".");
1185
    assert_eq!(truncate_line("hello", 0), "");
1186
  }
1187

1188
  #[test]
1189
  fn truncate_line_uses_first_line() {
1190
    assert_eq!(truncate_line("first\nsecond", 20), "first");
1191
  }
1192

1193
  #[test]
1194
  fn trunk_rel_is_trunk() {
1195
    assert_eq!(trunk_rel(0, 0), Some(TrunkRel::IsTrunk));
1196
  }
1197

1198
  #[test]
1199
  fn trunk_rel_ancestor() {
1200
    assert_eq!(trunk_rel(0, 5), Some(TrunkRel::Ancestor));
1201
  }
1202

1203
  #[test]
1204
  fn trunk_rel_ahead() {
1205
    assert_eq!(trunk_rel(3, 0), Some(TrunkRel::Ahead));
1206
  }
1207

1208
  #[test]
1209
  fn trunk_rel_diverged() {
1210
    assert_eq!(trunk_rel(2, 4), Some(TrunkRel::Diverged));
1211
  }
1212
}
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