worktrunk 0.42.0

A CLI for Git worktree management, designed for parallel AI agent workflows
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
#![cfg(all(unix, feature = "shell-integration-tests"))]
//! PTY-based tests for interactive approval prompts
//!
//! These tests verify the approval workflow in a real PTY environment where stdin is a TTY.
//! This allows testing the actual interactive prompt behavior that users experience.
//!
//! Note: These tests are separate from `approval_ui.rs` because they require PTY setup
//! to simulate interactive terminals. The non-PTY tests in `approval_ui.rs` verify the
//! error case (non-TTY environments).

use crate::common::pty::{build_pty_command, exec_cmd_in_pty_prompted};
use crate::common::{TestRepo, add_pty_binary_path_filters, add_pty_filters, repo, wt_bin};
use insta::assert_snapshot;
use rstest::rstest;
use std::path::Path;

/// Execute wt in a PTY, waiting for the approval prompt before sending input.
fn exec_wt_in_pty(
    repo: &TestRepo,
    args: &[&str],
    env_vars: &[(String, String)],
    input: &str,
) -> (String, i32) {
    exec_wt_in_pty_cwd(repo.root_path(), args, env_vars, input)
}

/// Execute wt in a PTY from a specific directory.
fn exec_wt_in_pty_cwd(
    cwd: &Path,
    args: &[&str],
    env_vars: &[(String, String)],
    input: &str,
) -> (String, i32) {
    let cmd = build_pty_command(wt_bin().to_str().unwrap(), args, cwd, env_vars, None);
    exec_cmd_in_pty_prompted(cmd, &[input], "[y/N")
}

/// Create insta settings for approval PTY tests.
///
/// Uses shared PTY filters plus test-specific normalizations for config file paths.
fn approval_pty_settings(repo: &TestRepo) -> insta::Settings {
    let mut settings = crate::common::setup_snapshot_settings(repo);

    // Add PTY-specific filters (CRLF, ^D, ANSI resets)
    add_pty_filters(&mut settings);

    // Binary path normalization
    add_pty_binary_path_filters(&mut settings);

    // Config paths specific to these tests
    settings.add_filter(r"/var/folders/[^\s]+/test-config\.toml", "[CONFIG]");

    settings
}

/// Get test env vars with shell integration configured.
///
/// This adds SHELL=/bin/zsh to the env vars, which is needed because:
/// - Tests write to .zshrc to simulate configured shell integration
/// - scan_shell_configs() uses $SHELL to determine which config file to check
/// - Without this, CI (which has SHELL=/bin/bash) wouldn't find the .zshrc config
fn test_env_vars_with_shell(repo: &TestRepo) -> Vec<(String, String)> {
    let mut env_vars = repo.test_env_vars();
    env_vars.push(("SHELL".to_string(), "/bin/zsh".to_string()));
    env_vars
}

#[rstest]
fn test_approval_prompt_accept(repo: TestRepo) {
    // Remove origin so worktrunk uses directory name as project identifier
    repo.run_git(&["remote", "remove", "origin"]);

    repo.write_project_config(r#"pre-start = "echo 'test command'""#);
    repo.commit("Add config");

    // Configure shell integration so we get the "Restart shell" hint instead of the prompt
    repo.configure_shell_integration();
    let env_vars = test_env_vars_with_shell(&repo);
    let (output, exit_code) = exec_wt_in_pty(
        &repo,
        &["switch", "--create", "test-approve"],
        &env_vars,
        "y\n",
    );

    assert_eq!(exit_code, 0);
    approval_pty_settings(&repo).bind(|| {
        assert_snapshot!("approval_prompt_accept", &output);
    });
}

#[rstest]
fn test_approval_prompt_decline(repo: TestRepo) {
    // Remove origin so worktrunk uses directory name as project identifier
    repo.run_git(&["remote", "remove", "origin"]);

    repo.write_project_config(r#"pre-start = "echo 'test command'""#);
    repo.commit("Add config");

    // Configure shell integration so we get the "Restart shell" hint instead of the prompt
    repo.configure_shell_integration();
    let env_vars = test_env_vars_with_shell(&repo);
    let (output, exit_code) = exec_wt_in_pty(
        &repo,
        &["switch", "--create", "test-decline"],
        &env_vars,
        "n\n",
    );

    assert_eq!(exit_code, 0);
    approval_pty_settings(&repo).bind(|| {
        assert_snapshot!("approval_prompt_decline", &output);
    });
}

#[rstest]
fn test_approval_prompt_multiple_commands(repo: TestRepo) {
    // Remove origin so worktrunk uses directory name as project identifier
    repo.run_git(&["remote", "remove", "origin"]);

    repo.write_project_config(
        r#"pre-start = [
    {first = "echo 'First command'"},
    {second = "echo 'Second command'"},
    {third = "echo 'Third command'"},
]
"#,
    );
    repo.commit("Add config");

    // Configure shell integration so we get the "Restart shell" hint instead of the prompt
    repo.configure_shell_integration();
    let env_vars = test_env_vars_with_shell(&repo);
    let (output, exit_code) = exec_wt_in_pty(
        &repo,
        &["switch", "--create", "test-multi"],
        &env_vars,
        "y\n",
    );

    assert_eq!(exit_code, 0);
    approval_pty_settings(&repo).bind(|| {
        assert_snapshot!("approval_prompt_multiple_commands", &output);
    });
}

/// TODO: Find a way to test permission errors without skipping when running as root.
/// See test_permission_error_prevents_save in approval_save.rs for details.
#[rstest]
fn test_approval_prompt_permission_error(repo: TestRepo) {
    // Remove origin so worktrunk uses directory name as project identifier
    repo.run_git(&["remote", "remove", "origin"]);

    repo.write_project_config(r#"pre-start = "echo 'test command'""#);
    repo.commit("Add config");

    // Configure shell integration before making the approvals directory read-only
    repo.configure_shell_integration();

    // Create a subdirectory for approvals so we can make it read-only
    // without affecting the temp dir root (which holds .zshrc, git config, etc.)
    let approvals_dir = repo.home_path().join("readonly-approvals");
    let approvals_path = approvals_dir.join("approvals.toml");
    #[cfg(unix)]
    {
        use std::fs;
        use std::os::unix::fs::PermissionsExt;

        fs::create_dir_all(&approvals_dir).unwrap();

        // Make the directory read-only (prevents creating approvals.toml or lock file)
        let mut perms = fs::metadata(&approvals_dir).unwrap().permissions();
        perms.set_mode(0o555); // Read + execute only
        fs::set_permissions(&approvals_dir, perms).unwrap();

        // Test if permissions actually restrict us (skip if running as root)
        let test_file = approvals_dir.join("test_write");
        if fs::write(&test_file, "test").is_ok() {
            // Running as root - restore permissions and skip test
            let _ = fs::remove_file(&test_file);
            let mut perms = fs::metadata(&approvals_dir).unwrap().permissions();
            perms.set_mode(0o755);
            fs::set_permissions(&approvals_dir, perms).unwrap();
            eprintln!("Skipping permission test - running with elevated privileges");
            return;
        }
    }
    let mut env_vars = test_env_vars_with_shell(&repo);
    // Override the approvals path to point to the read-only directory
    env_vars.push((
        "WORKTRUNK_APPROVALS_PATH".to_string(),
        approvals_path.display().to_string(),
    ));
    let (output, exit_code) = exec_wt_in_pty(
        &repo,
        &["switch", "--create", "test-permission"],
        &env_vars,
        "y\n",
    );

    assert_eq!(
        exit_code, 0,
        "Command should succeed even when saving approval fails"
    );
    assert!(
        output.contains("Failed to save command approval"),
        "Should show permission error warning"
    );
    assert!(
        output.contains("Approval will be requested again next time"),
        "Should show hint about approval being requested again"
    );
    assert!(
        output.contains("test command"),
        "Command should still execute despite save failure"
    );
    approval_pty_settings(&repo).bind(|| {
        assert_snapshot!("approval_prompt_permission_error", &output);
    });
}

#[rstest]
fn test_approval_prompt_named_commands(repo: TestRepo) {
    // Remove origin so worktrunk uses directory name as project identifier
    repo.run_git(&["remote", "remove", "origin"]);

    repo.write_project_config(
        r#"pre-start = [
    {install = "echo 'Installing dependencies...'"},
    {build = "echo 'Building project...'"},
    {test = "echo 'Running tests...'"},
]
"#,
    );
    repo.commit("Add config");

    // Configure shell integration so we get the "Restart shell" hint instead of the prompt
    repo.configure_shell_integration();
    let env_vars = test_env_vars_with_shell(&repo);
    let (output, exit_code) = exec_wt_in_pty(
        &repo,
        &["switch", "--create", "test-named"],
        &env_vars,
        "y\n",
    );

    assert_eq!(exit_code, 0);
    assert!(
        output.contains("install") && output.contains("Installing dependencies"),
        "Should show command name 'install' and execute it"
    );
    assert!(
        output.contains("build") && output.contains("Building project"),
        "Should show command name 'build' and execute it"
    );
    assert!(
        output.contains("test") && output.contains("Running tests"),
        "Should show command name 'test' and execute it"
    );
    approval_pty_settings(&repo).bind(|| {
        assert_snapshot!("approval_prompt_named_commands", &output);
    });
}

#[rstest]
fn test_approval_prompt_mixed_approved_unapproved_accept(repo: TestRepo) {
    // Remove origin so worktrunk uses directory name as project identifier
    repo.run_git(&["remote", "remove", "origin"]);

    repo.write_project_config(
        r#"pre-start = [
    {first = "echo 'First command'"},
    {second = "echo 'Second command'"},
    {third = "echo 'Third command'"},
]
"#,
    );
    repo.commit("Add config");

    // Pre-approve the second command
    repo.write_test_approvals(&format!(
        r#"[projects.'{}']
approved-commands = ["echo 'Second command'"]
"#,
        repo.project_id()
    ));

    // Configure shell integration so we get the "Restart shell" hint instead of the prompt
    repo.configure_shell_integration();
    let env_vars = test_env_vars_with_shell(&repo);
    let (output, exit_code) = exec_wt_in_pty(
        &repo,
        &["switch", "--create", "test-mixed-accept"],
        &env_vars,
        "y\n",
    );

    assert_eq!(exit_code, 0);

    // Check that only 2 commands are shown in the prompt (ANSI codes may be in between)
    assert!(
        output.contains("execute") && output.contains("2") && output.contains("command"),
        "Should show 2 unapproved commands in prompt"
    );
    assert!(
        output.contains("First command"),
        "Should execute first command"
    );
    assert!(
        output.contains("Second command"),
        "Should execute pre-approved second command"
    );
    assert!(
        output.contains("Third command"),
        "Should execute third command"
    );
    approval_pty_settings(&repo).bind(|| {
        assert_snapshot!("approval_prompt_mixed_approved_unapproved_accept", &output);
    });
}

#[rstest]
fn test_approval_prompt_mixed_approved_unapproved_decline(repo: TestRepo) {
    // Remove origin so worktrunk uses directory name as project identifier
    repo.run_git(&["remote", "remove", "origin"]);

    repo.write_project_config(
        r#"pre-start = [
    {first = "echo 'First command'"},
    {second = "echo 'Second command'"},
    {third = "echo 'Third command'"},
]
"#,
    );
    repo.commit("Add config");

    // Pre-approve the second command
    repo.write_test_approvals(&format!(
        r#"[projects.'{}']
approved-commands = ["echo 'Second command'"]
"#,
        repo.project_id()
    ));

    // Configure shell integration so we get the "Restart shell" hint instead of the prompt
    repo.configure_shell_integration();
    let env_vars = test_env_vars_with_shell(&repo);
    let (output, exit_code) = exec_wt_in_pty(
        &repo,
        &["switch", "--create", "test-mixed-decline"],
        &env_vars,
        "n\n",
    );

    assert_eq!(
        exit_code, 0,
        "Command should succeed even when declined (worktree still created)"
    );
    // Check that only 2 commands are shown in the prompt (ANSI codes may be in between)
    assert!(
        output.contains("execute") && output.contains("2") && output.contains("command"),
        "Should show only 2 unapproved commands in prompt (not 3)"
    );
    // When declined, ALL commands are skipped (including pre-approved ones)
    assert!(
        output.contains("Commands declined"),
        "Should show 'Commands declined' message"
    );
    // Commands appear in the prompt, but should not be executed
    // Check for "Running pre-start" which indicates execution
    assert!(
        !output.contains("Running pre-start"),
        "Should NOT execute any commands when declined"
    );
    assert!(
        output.contains("Created branch") && output.contains("and worktree"),
        "Should still create worktree even when commands declined"
    );
    approval_pty_settings(&repo).bind(|| {
        assert_snapshot!("approval_prompt_mixed_approved_unapproved_decline", &output);
    });
}

#[rstest]
fn test_approval_prompt_remove_decline(repo: TestRepo) {
    // Remove origin so worktrunk uses directory name as project identifier
    repo.run_git(&["remote", "remove", "origin"]);

    // Create a worktree to remove
    let output = repo
        .wt_command()
        .args(["switch", "--create", "to-remove", "--yes"])
        .output()
        .unwrap();
    assert!(output.status.success(), "Initial switch should succeed");

    // Add pre-remove hook
    repo.write_project_config(r#"pre-remove = "echo 'pre-remove hook'""#);
    repo.commit("Add pre-remove config");

    // Configure shell integration
    repo.configure_shell_integration();
    let env_vars = test_env_vars_with_shell(&repo);

    // Decline the approval prompt
    let (output, exit_code) = exec_wt_in_pty(&repo, &["remove", "to-remove"], &env_vars, "n\n");

    assert_eq!(
        exit_code, 0,
        "Remove should succeed even when hooks declined"
    );
    assert!(
        output.contains("Commands declined"),
        "Should show 'Commands declined' message"
    );
    approval_pty_settings(&repo).bind(|| {
        assert_snapshot!("approval_prompt_remove_decline", &output);
    });
}

#[rstest]
fn test_approval_prompt_step_commit_decline(mut repo: TestRepo) {
    // Remove origin so worktrunk uses directory name as project identifier
    repo.run_git(&["remote", "remove", "origin"]);

    // Add pre-commit hook to project config and commit it
    repo.write_project_config(r#"pre-commit = "echo 'pre-commit hook'""#);
    repo.commit("Add pre-commit config");

    // Create a feature worktree
    let feature_wt = repo.add_worktree("feature-commit");

    // Make dirty changes in the feature worktree
    std::fs::write(feature_wt.join("new-file.txt"), "new content").unwrap();

    // Configure LLM commit generation
    repo.write_test_config(
        r#"
[commit.generation]
command = "cat >/dev/null && echo 'feat: test commit message'"
"#,
    );

    let env_vars = test_env_vars_with_shell(&repo);

    // Decline the pre-commit hook approval prompt
    let (output, exit_code) =
        exec_wt_in_pty_cwd(&feature_wt, &["step", "commit"], &env_vars, "n\n");

    assert_eq!(
        exit_code, 0,
        "Commit should succeed even when hooks declined. Output:\n{output}"
    );
    assert!(
        output.contains("Commands declined"),
        "Should show 'Commands declined' message. Output:\n{output}"
    );
    assert!(
        output.contains("committing without hooks"),
        "Should indicate commit proceeds without hooks. Output:\n{output}"
    );
}

#[rstest]
fn test_approval_prompt_step_squash_decline(mut repo: TestRepo) {
    // Remove origin so worktrunk uses directory name as project identifier
    repo.run_git(&["remote", "remove", "origin"]);

    // Add pre-commit hook to project config and commit it
    repo.write_project_config(r#"pre-commit = "echo 'pre-commit hook'""#);
    repo.commit("Add pre-commit config");

    // Create a feature worktree with multiple commits ahead of main
    let feature_wt = repo.add_worktree("feature-squash");
    repo.commit_in_worktree(&feature_wt, "file1.txt", "content 1", "feat: first change");
    repo.commit_in_worktree(&feature_wt, "file2.txt", "content 2", "feat: second change");

    // Configure LLM commit generation
    repo.write_test_config(
        r#"
[commit.generation]
command = "cat >/dev/null && echo 'feat: squashed commit message'"
"#,
    );

    let env_vars = test_env_vars_with_shell(&repo);

    // Decline the pre-commit hook approval prompt
    let (output, exit_code) =
        exec_wt_in_pty_cwd(&feature_wt, &["step", "squash"], &env_vars, "n\n");

    assert_eq!(
        exit_code, 0,
        "Squash should succeed even when hooks declined. Output:\n{output}"
    );
    assert!(
        output.contains("Commands declined"),
        "Should show 'Commands declined' message. Output:\n{output}"
    );
    assert!(
        output.contains("squashing without hooks"),
        "Should indicate squash proceeds without hooks. Output:\n{output}"
    );
}

/// `wt config approvals add` accepts the prompt — covers the success branch of
/// `add_approvals` after `approve_command_batch` returns Ok(true).
#[rstest]
fn test_config_approvals_add_accept(repo: TestRepo) {
    repo.run_git(&["remote", "remove", "origin"]);
    repo.write_project_config(r#"pre-start = "echo 'test command'""#);
    repo.commit("Add config");

    let env_vars = repo.test_env_vars();
    let (output, exit_code) =
        exec_wt_in_pty(&repo, &["config", "approvals", "add"], &env_vars, "y\n");

    assert_eq!(exit_code, 0, "add should exit cleanly. Output:\n{output}");
    assert!(
        output.contains("Commands approved"),
        "Should show approval success. Output:\n{output}"
    );
}

/// `wt config approvals add` declines the prompt — covers the declined branch
/// of `add_approvals` after `approve_command_batch` returns Ok(false).
#[rstest]
fn test_config_approvals_add_decline(repo: TestRepo) {
    repo.run_git(&["remote", "remove", "origin"]);
    repo.write_project_config(r#"pre-start = "echo 'test command'""#);
    repo.commit("Add config");

    let env_vars = repo.test_env_vars();
    let (output, exit_code) =
        exec_wt_in_pty(&repo, &["config", "approvals", "add"], &env_vars, "n\n");

    assert_eq!(
        exit_code, 0,
        "decline should exit cleanly. Output:\n{output}"
    );
    assert!(
        output.contains("Commands declined"),
        "Should show decline message. Output:\n{output}"
    );
}