mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
92dfb686bb
`wt config approvals add` refused every non-interactive run — even with `--yes`, whose hint then suggested the flag already passed — so there was no way to pre-approve a project's commands unattended. An orchestrator (tend's Codex Cloud container was the motivating case) had to hand-write `approvals.toml` from `wt config approvals list --format=json` output, a third-party reimplementation of `add` that breaks whenever the schema changes. The `wt config approvals` docs already promised "`--yes` to bypass prompts in CI" and described `stale` entries as "what `--yes` would silently re-approve"; behavior now matches them. The two `--yes` meanings stay distinct: on a command that runs project commands it grants consent for that run alone and records nothing (unchanged), while on `add` — whose product is the record — it lists what it trusts and writes it. `add` no longer routes through `approve_command_batch` (the execution gate) for this: it prompts or announces, then saves itself, which also makes a failed `approvals.toml` write fail the command instead of warning behind a `✓ saved` line and exit 0 — an orchestrator reading only the exit code would otherwise walk into the prompt it just paid to avoid. The non-interactive hint's pre-approval suggestion now carries `--yes` (`run wt config approvals add --yes`), since a hint reached in CI must name a command that runs there. Per the existing `list --format=json` docs, `add --yes` re-approves templates edited since an earlier approval without comment; the `add` help now says so and points at the `stale` field for reading them first, and the worktrunk skill's escalation rule tells agents not to reach for it on a user's behalf. > _This was written by Claude Code on behalf of max-sixty_
738 lines
26 KiB
Rust
738 lines
26 KiB
Rust
//! Integration tests for the `wt config approvals` subcommands
|
|
|
|
use crate::common::{
|
|
BareRepoTest, TestRepo, TestRepoBase, make_snapshot_cmd, make_snapshot_cmd_with_global_flags,
|
|
repo, set_temp_home_env, setup_snapshot_settings, setup_snapshot_settings_with_home,
|
|
setup_temp_snapshot_settings, temp_home, wt_command,
|
|
};
|
|
use insta_cmd::assert_cmd_snapshot;
|
|
use rstest::rstest;
|
|
use std::fs;
|
|
use tempfile::TempDir;
|
|
use worktrunk::config::Approvals;
|
|
|
|
/// Helper to snapshot add-approvals command
|
|
fn snapshot_add_approvals(test_name: &str, repo: &TestRepo, args: &[&str]) {
|
|
let settings = setup_snapshot_settings(repo);
|
|
settings.bind(|| {
|
|
let mut cmd = make_snapshot_cmd(repo, "config", &[], None);
|
|
cmd.arg("approvals").arg("add").args(args);
|
|
assert_cmd_snapshot!(test_name, cmd);
|
|
});
|
|
}
|
|
|
|
/// Helper to snapshot clear-approvals command
|
|
fn snapshot_clear_approvals(test_name: &str, repo: &TestRepo, args: &[&str]) {
|
|
let settings = setup_snapshot_settings(repo);
|
|
settings.bind(|| {
|
|
let mut cmd = make_snapshot_cmd(repo, "config", &[], None);
|
|
cmd.arg("approvals").arg("clear").args(args);
|
|
assert_cmd_snapshot!(test_name, cmd);
|
|
});
|
|
}
|
|
|
|
/// Snapshot the list command in both formats from one setup. The sections and
|
|
/// the JSON payload answer the same question about the same state, so every
|
|
/// scenario below covers both without restating its config.
|
|
fn snapshot_list_approvals(test_name: &str, repo: &TestRepo) {
|
|
let settings = setup_snapshot_settings(repo);
|
|
settings.bind(|| {
|
|
let mut cmd = make_snapshot_cmd(repo, "config", &[], None);
|
|
cmd.arg("approvals").arg("list");
|
|
assert_cmd_snapshot!(test_name, cmd);
|
|
|
|
let mut cmd = make_snapshot_cmd(repo, "config", &[], None);
|
|
cmd.arg("approvals").arg("list").arg("--format=json");
|
|
assert_cmd_snapshot!(format!("{test_name}_json"), cmd);
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// list tests
|
|
// ============================================================================
|
|
|
|
#[rstest]
|
|
fn test_list_approvals_no_config(repo: TestRepo) {
|
|
snapshot_list_approvals("list_approvals_no_config", &repo);
|
|
}
|
|
|
|
/// The main listing: hooks, an alias, and commit-message guidance, with a mix
|
|
/// of approved and unapproved commands, plus a stale approval whose command
|
|
/// was removed from the config.
|
|
#[rstest]
|
|
fn test_list_approvals_mixed(repo: TestRepo) {
|
|
// Remove origin so project_identifier uses the canonical worktree path —
|
|
// matches what `Repository::project_identifier` computes at runtime.
|
|
repo.run_git(&["remote", "remove", "origin"]);
|
|
repo.write_project_config(
|
|
r#"pre-merge = "cargo test"
|
|
|
|
[post-start]
|
|
dev = "npm run dev"
|
|
|
|
[aliases]
|
|
deploy = "echo deploying {{ branch }}"
|
|
|
|
[commit.generation]
|
|
template-append = "Use conventional commits."
|
|
"#,
|
|
);
|
|
repo.commit("Add config");
|
|
|
|
let project_id = repo.project_id();
|
|
let mut approvals = Approvals::default();
|
|
approvals
|
|
.approve_commands(
|
|
project_id,
|
|
vec![
|
|
"cargo test".to_string(),
|
|
"echo deploying {{ branch }}".to_string(),
|
|
"some removed command".to_string(),
|
|
],
|
|
repo.test_approvals_path(),
|
|
)
|
|
.unwrap();
|
|
|
|
snapshot_list_approvals("list_approvals_mixed", &repo);
|
|
}
|
|
|
|
/// With everything approved, the UNAPPROVED section shows an explicit
|
|
/// `(none)` so the all-clear is visible at a glance.
|
|
#[rstest]
|
|
fn test_list_approvals_all_approved(repo: TestRepo) {
|
|
repo.run_git(&["remote", "remove", "origin"]);
|
|
repo.write_project_config(r#"pre-merge = "cargo test""#);
|
|
repo.commit("Add config");
|
|
|
|
let mut approvals = Approvals::default();
|
|
approvals
|
|
.approve_command(
|
|
repo.project_id(),
|
|
"cargo test".to_string(),
|
|
repo.test_approvals_path(),
|
|
)
|
|
.unwrap();
|
|
|
|
snapshot_list_approvals("list_approvals_all_approved", &repo);
|
|
}
|
|
|
|
/// Without a project config there is nothing to require approval, but
|
|
/// approvals already on record still list — all as stale.
|
|
#[rstest]
|
|
fn test_list_approvals_stale_only(repo: TestRepo) {
|
|
repo.run_git(&["remote", "remove", "origin"]);
|
|
repo.commit("Initial commit");
|
|
|
|
let mut approvals = Approvals::default();
|
|
approvals
|
|
.approve_command(
|
|
repo.project_id(),
|
|
"orphan command".to_string(),
|
|
repo.test_approvals_path(),
|
|
)
|
|
.unwrap();
|
|
|
|
snapshot_list_approvals("list_approvals_stale_only", &repo);
|
|
}
|
|
|
|
// ============================================================================
|
|
// add-approvals tests
|
|
// ============================================================================
|
|
|
|
#[rstest]
|
|
fn test_add_approvals_no_config(repo: TestRepo) {
|
|
snapshot_add_approvals("add_approvals_no_config", &repo, &[]);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_add_approvals_all_with_none_approved(repo: TestRepo) {
|
|
repo.write_project_config(r#"pre-start = "echo 'test'""#);
|
|
repo.commit("Add config");
|
|
|
|
snapshot_add_approvals("add_approvals_all_none_approved", &repo, &["--all"]);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_add_approvals_empty_config(repo: TestRepo) {
|
|
repo.write_project_config("");
|
|
repo.commit("Add empty config");
|
|
|
|
snapshot_add_approvals("add_approvals_empty_config", &repo, &[]);
|
|
}
|
|
|
|
/// `wt hook approvals` is the deprecated alias for `wt config approvals`.
|
|
/// Both `add` and `clear` must emit the deprecation warning and still forward
|
|
/// to the same handler.
|
|
#[rstest]
|
|
#[case::add("add", "hook_approvals_deprecated_add")]
|
|
#[case::clear("clear", "hook_approvals_deprecated_clear")]
|
|
fn test_hook_approvals_emits_deprecation_warning(
|
|
repo: TestRepo,
|
|
#[case] action: &str,
|
|
#[case] snapshot_name: &str,
|
|
) {
|
|
let settings = setup_snapshot_settings(&repo);
|
|
settings.bind(|| {
|
|
let mut cmd = make_snapshot_cmd(&repo, "hook", &[], None);
|
|
cmd.arg("approvals").arg(action);
|
|
assert_cmd_snapshot!(snapshot_name, cmd);
|
|
});
|
|
}
|
|
|
|
/// `--yes` is what makes `add` usable unattended: with no terminal to prompt
|
|
/// on it lists what it trusts and writes the approvals. The flag is global, so
|
|
/// it records the same thing on either side of the subcommand.
|
|
#[rstest]
|
|
fn test_add_approvals_yes_records_without_a_terminal(repo: TestRepo) {
|
|
repo.run_git(&["remote", "remove", "origin"]);
|
|
repo.write_project_config(
|
|
r#"pre-merge = "cargo test"
|
|
|
|
[aliases]
|
|
deploy = "echo deploying"
|
|
"#,
|
|
);
|
|
repo.commit("Add config");
|
|
|
|
snapshot_add_approvals("add_approvals_yes", &repo, &["--yes"]);
|
|
|
|
let recorded = fs::read_to_string(repo.test_approvals_path()).unwrap();
|
|
assert!(recorded.contains("cargo test"), "{recorded}");
|
|
assert!(recorded.contains("echo deploying"), "{recorded}");
|
|
|
|
// Same run again from the global flag position, which reaches the command
|
|
// through clap's global `--yes` rather than the subcommand's own parse.
|
|
fs::remove_file(repo.test_approvals_path()).unwrap();
|
|
let output = make_snapshot_cmd_with_global_flags(&repo, "config", &[], None, &["--yes"])
|
|
.args(["approvals", "add"])
|
|
.output()
|
|
.unwrap();
|
|
assert!(output.status.success(), "{output:?}");
|
|
assert_eq!(
|
|
fs::read_to_string(repo.test_approvals_path()).unwrap(),
|
|
recorded
|
|
);
|
|
}
|
|
|
|
/// The record is all `add` produces, so a write it cannot make fails the
|
|
/// command. A warning plus exit 0 would leave an orchestrator that pre-approves
|
|
/// and reads the exit code walking into the prompt it just paid to avoid.
|
|
#[rstest]
|
|
fn test_add_approvals_yes_fails_when_approvals_cannot_be_saved(repo: TestRepo) {
|
|
repo.write_project_config(r#"pre-merge = "cargo test""#);
|
|
repo.commit("Add config");
|
|
|
|
// A regular file where the approvals directory would go: creating the
|
|
// parent fails on every platform, with no permission bits involved.
|
|
let blocker = repo.test_approvals_path().with_file_name("blocker");
|
|
fs::write(&blocker, "").unwrap();
|
|
|
|
let output = repo
|
|
.wt_command()
|
|
.args(["config", "approvals", "add", "--yes"])
|
|
.env("WORKTRUNK_APPROVALS_PATH", blocker.join("approvals.toml"))
|
|
.output()
|
|
.unwrap();
|
|
|
|
assert!(!output.status.success(), "{output:?}");
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("Failed to save command approval"),
|
|
"{stderr}"
|
|
);
|
|
assert!(
|
|
!stderr.contains("Commands approved"),
|
|
"a failed write must not report success: {stderr}"
|
|
);
|
|
}
|
|
|
|
/// Regression: `wt config approvals add` must walk project aliases as well as
|
|
/// hooks. With only an alias declared (no hook commands), the alias should
|
|
/// appear in the approval batch.
|
|
#[rstest]
|
|
fn test_add_approvals_includes_aliases(repo: TestRepo) {
|
|
repo.write_project_config(
|
|
r#"[aliases]
|
|
deploy = "echo deploying {{ branch }}"
|
|
"#,
|
|
);
|
|
repo.commit("Add alias-only project config");
|
|
|
|
snapshot_add_approvals("add_approvals_includes_aliases", &repo, &[]);
|
|
}
|
|
|
|
// ============================================================================
|
|
// clear-approvals tests
|
|
// ============================================================================
|
|
|
|
#[rstest]
|
|
fn test_clear_approvals_no_approvals(repo: TestRepo) {
|
|
snapshot_clear_approvals("clear_approvals_no_approvals", &repo, &[]);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_clear_approvals_with_approvals(repo: TestRepo) {
|
|
// Remove origin so project_identifier uses the canonical worktree path —
|
|
// matches what `Repository::project_identifier` computes at runtime.
|
|
repo.run_git(&["remote", "remove", "origin"]);
|
|
repo.commit("Initial commit");
|
|
repo.write_project_config(r#"pre-start = "echo 'test'""#);
|
|
repo.commit("Add config");
|
|
|
|
// Manually approve the command using the same project id wt will compute.
|
|
let mut approvals = Approvals::default();
|
|
approvals
|
|
.approve_command(
|
|
repo.project_id(),
|
|
"echo 'test'".to_string(),
|
|
repo.test_approvals_path(),
|
|
)
|
|
.unwrap();
|
|
|
|
// Now clear approvals
|
|
snapshot_clear_approvals("clear_approvals_with_approvals", &repo, &[]);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_clear_approvals_global_no_approvals(repo: TestRepo) {
|
|
snapshot_clear_approvals("clear_approvals_global_no_approvals", &repo, &["--global"]);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_clear_approvals_global_with_approvals(repo: TestRepo) {
|
|
// Remove origin so project_identifier uses the canonical worktree path —
|
|
// matches what `Repository::project_identifier` computes at runtime.
|
|
repo.run_git(&["remote", "remove", "origin"]);
|
|
repo.commit("Initial commit");
|
|
repo.write_project_config(r#"pre-start = "echo 'test'""#);
|
|
repo.commit("Add config");
|
|
|
|
// Manually approve the command using the same project id wt will compute.
|
|
let mut approvals = Approvals::default();
|
|
approvals
|
|
.approve_command(
|
|
repo.project_id(),
|
|
"echo 'test'".to_string(),
|
|
repo.test_approvals_path(),
|
|
)
|
|
.unwrap();
|
|
|
|
// Now clear all global approvals
|
|
snapshot_clear_approvals(
|
|
"clear_approvals_global_with_approvals",
|
|
&repo,
|
|
&["--global"],
|
|
);
|
|
}
|
|
|
|
/// A hand-written pattern entry approves commands `clear` deliberately can't
|
|
/// touch; the hint names the entry so the surviving approval is traceable.
|
|
#[rstest]
|
|
fn test_clear_approvals_only_pattern_entry(repo: TestRepo) {
|
|
// Remove origin so project_identifier uses the canonical worktree path —
|
|
// matches what `Repository::project_identifier` computes at runtime.
|
|
repo.run_git(&["remote", "remove", "origin"]);
|
|
repo.commit("Initial commit");
|
|
repo.write_project_config(r#"pre-start = "echo 'test'""#);
|
|
repo.commit("Add config");
|
|
|
|
// Written raw: pattern entries are hand-edited by definition (the write
|
|
// paths refuse `*`).
|
|
fs::write(
|
|
repo.test_approvals_path(),
|
|
"[projects.\"*\"]\napproved-commands = [\"echo 'test'\"]\n",
|
|
)
|
|
.unwrap();
|
|
|
|
snapshot_clear_approvals("clear_approvals_only_pattern_entry", &repo, &[]);
|
|
}
|
|
|
|
/// Clearing the exact entry succeeds, and the hint says why `list` will still
|
|
/// show the pattern entry's command as approved.
|
|
#[rstest]
|
|
fn test_clear_approvals_exact_cleared_pattern_remains(repo: TestRepo) {
|
|
// Remove origin so project_identifier uses the canonical worktree path —
|
|
// matches what `Repository::project_identifier` computes at runtime.
|
|
repo.run_git(&["remote", "remove", "origin"]);
|
|
repo.commit("Initial commit");
|
|
repo.write_project_config(r#"pre-start = "echo 'test'""#);
|
|
repo.commit("Add config");
|
|
|
|
fs::write(
|
|
repo.test_approvals_path(),
|
|
"[projects.\"*\"]\napproved-commands = [\"echo 'other'\"]\n",
|
|
)
|
|
.unwrap();
|
|
// The exact entry rides the same file: mutations reload it from disk
|
|
// under the lock, so the pattern entry survives alongside.
|
|
let mut approvals = Approvals::default();
|
|
approvals
|
|
.approve_command(
|
|
repo.project_id(),
|
|
"echo 'test'".to_string(),
|
|
repo.test_approvals_path(),
|
|
)
|
|
.unwrap();
|
|
|
|
snapshot_clear_approvals("clear_approvals_exact_cleared_pattern_remains", &repo, &[]);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_clear_approvals_after_clear(repo: TestRepo) {
|
|
// Remove origin so project_identifier uses the canonical worktree path —
|
|
// matches what `Repository::project_identifier` computes at runtime.
|
|
repo.run_git(&["remote", "remove", "origin"]);
|
|
repo.commit("Initial commit");
|
|
repo.write_project_config(r#"pre-start = "echo 'test'""#);
|
|
repo.commit("Add config");
|
|
|
|
// Manually approve the command using the same project id wt will compute.
|
|
let mut approvals = Approvals::default();
|
|
approvals
|
|
.approve_command(
|
|
repo.project_id(),
|
|
"echo 'test'".to_string(),
|
|
repo.test_approvals_path(),
|
|
)
|
|
.unwrap();
|
|
|
|
// Clear approvals
|
|
let mut cmd = make_snapshot_cmd(&repo, "config", &[], None);
|
|
cmd.arg("approvals").arg("clear");
|
|
cmd.output().unwrap();
|
|
|
|
// Try to clear again (should show "no approvals")
|
|
snapshot_clear_approvals("clear_approvals_after_clear", &repo, &[]);
|
|
}
|
|
|
|
/// `clear --stale` removes only the approvals whose commands left the project
|
|
/// config, echoing what was removed; valid approvals survive, so the follow-up
|
|
/// list shows the remaining command approved with no stale block.
|
|
#[rstest]
|
|
fn test_clear_approvals_stale(repo: TestRepo) {
|
|
repo.run_git(&["remote", "remove", "origin"]);
|
|
repo.write_project_config(r#"pre-merge = "cargo test""#);
|
|
repo.commit("Add config");
|
|
|
|
let mut approvals = Approvals::default();
|
|
approvals
|
|
.approve_commands(
|
|
repo.project_id(),
|
|
vec![
|
|
"cargo test".to_string(),
|
|
"some removed command".to_string(),
|
|
"other removed command".to_string(),
|
|
],
|
|
repo.test_approvals_path(),
|
|
)
|
|
.unwrap();
|
|
|
|
snapshot_clear_approvals("clear_approvals_stale", &repo, &["--stale"]);
|
|
snapshot_list_approvals("list_approvals_after_clear_stale", &repo);
|
|
}
|
|
|
|
/// With every approval matching a config command, `clear --stale` is a no-op.
|
|
#[rstest]
|
|
fn test_clear_approvals_stale_none(repo: TestRepo) {
|
|
repo.run_git(&["remote", "remove", "origin"]);
|
|
repo.write_project_config(r#"pre-merge = "cargo test""#);
|
|
repo.commit("Add config");
|
|
|
|
let mut approvals = Approvals::default();
|
|
approvals
|
|
.approve_command(
|
|
repo.project_id(),
|
|
"cargo test".to_string(),
|
|
repo.test_approvals_path(),
|
|
)
|
|
.unwrap();
|
|
|
|
snapshot_clear_approvals("clear_approvals_stale_none", &repo, &["--stale"]);
|
|
}
|
|
|
|
/// Without a project config there is no frame to compute staleness against,
|
|
/// so `clear --stale` errors like `add` — it must NOT treat every recorded
|
|
/// approval as stale and wipe the project's approvals.
|
|
#[rstest]
|
|
fn test_clear_approvals_stale_no_config(repo: TestRepo) {
|
|
repo.run_git(&["remote", "remove", "origin"]);
|
|
repo.commit("Initial commit");
|
|
|
|
let mut approvals = Approvals::default();
|
|
approvals
|
|
.approve_command(
|
|
repo.project_id(),
|
|
"orphan command".to_string(),
|
|
repo.test_approvals_path(),
|
|
)
|
|
.unwrap();
|
|
|
|
snapshot_clear_approvals("clear_approvals_stale_no_config", &repo, &["--stale"]);
|
|
|
|
// The recorded approval survives the failed clear.
|
|
let content = fs::read_to_string(repo.test_approvals_path()).unwrap();
|
|
assert!(content.contains("orphan command"));
|
|
}
|
|
|
|
/// `--stale` is scoped to the current project's config, so it cannot combine
|
|
/// with `--global`.
|
|
#[rstest]
|
|
fn test_clear_approvals_stale_global_conflict(repo: TestRepo) {
|
|
snapshot_clear_approvals(
|
|
"clear_approvals_stale_global_conflict",
|
|
&repo,
|
|
&["--stale", "--global"],
|
|
);
|
|
}
|
|
|
|
/// `clear` reads approvals from the legacy `[projects.X]` section in `config.toml`
|
|
/// when `approvals.toml` is absent, and clears them. Exercises the
|
|
/// `Approvals::load` fallback path documented in `src/config/approvals.rs`.
|
|
#[rstest]
|
|
fn test_clear_approvals_legacy_config_storage(repo: TestRepo, temp_home: TempDir) {
|
|
// Remove origin so project_identifier uses full canonical path
|
|
repo.run_git(&["remote", "remove", "origin"]);
|
|
|
|
// Get the canonical path for the project identifier (escaped for TOML)
|
|
let project_id_str = repo.project_id();
|
|
|
|
// Write approved commands as a sibling config.toml of the test approvals path.
|
|
// The fallback reads config.toml from the same directory as approvals.toml.
|
|
let config_path = repo.test_approvals_path().with_file_name("config.toml");
|
|
fs::write(
|
|
&config_path,
|
|
format!(
|
|
r#"worktree-path = "../{{{{ repo }}}}.{{{{ branch }}}}"
|
|
|
|
[projects.'{project_id_str}']
|
|
approved-commands = ["cargo build", "cargo test", "npm install"]
|
|
"#
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let settings = setup_snapshot_settings_with_home(&repo, &temp_home);
|
|
settings.bind(|| {
|
|
let mut cmd = wt_command();
|
|
repo.configure_wt_cmd(&mut cmd);
|
|
cmd.env("WORKTRUNK_CONFIG_PATH", &config_path);
|
|
cmd.args(["config", "approvals", "clear"])
|
|
.current_dir(repo.root_path());
|
|
set_temp_home_env(&mut cmd, temp_home.path());
|
|
|
|
assert_cmd_snapshot!("clear_approvals_legacy_config_storage", cmd);
|
|
});
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_clear_approvals_multiple_approvals(repo: TestRepo) {
|
|
// Remove origin so project_identifier uses the canonical worktree path —
|
|
// matches what `Repository::project_identifier` computes at runtime.
|
|
repo.run_git(&["remote", "remove", "origin"]);
|
|
repo.write_project_config(
|
|
r#"
|
|
pre-start = "echo 'first'"
|
|
post-start = "echo 'second'"
|
|
[pre-commit]
|
|
lint = "echo 'third'"
|
|
"#,
|
|
);
|
|
repo.commit("Add config with multiple commands");
|
|
|
|
// Manually approve all commands using the same project id wt will compute.
|
|
let project_id = repo.project_id();
|
|
let mut approvals = Approvals::default();
|
|
approvals
|
|
.approve_command(
|
|
project_id.clone(),
|
|
"echo 'first'".to_string(),
|
|
repo.test_approvals_path(),
|
|
)
|
|
.unwrap();
|
|
approvals
|
|
.approve_command(
|
|
project_id.clone(),
|
|
"echo 'second'".to_string(),
|
|
repo.test_approvals_path(),
|
|
)
|
|
.unwrap();
|
|
approvals
|
|
.approve_command(
|
|
project_id,
|
|
"echo 'third'".to_string(),
|
|
repo.test_approvals_path(),
|
|
)
|
|
.unwrap();
|
|
|
|
// Now clear approvals (should show count of 3)
|
|
snapshot_clear_approvals("clear_approvals_multiple_approvals", &repo, &[]);
|
|
}
|
|
|
|
// ============================================================================
|
|
// add-approvals additional coverage tests
|
|
// ============================================================================
|
|
|
|
#[rstest]
|
|
fn test_add_approvals_all_already_approved(repo: TestRepo) {
|
|
// Remove origin so project_identifier uses the canonical worktree path —
|
|
// matches what `Repository::project_identifier` computes at runtime.
|
|
repo.run_git(&["remote", "remove", "origin"]);
|
|
repo.commit("Initial commit");
|
|
repo.write_project_config(r#"pre-start = "echo 'test'""#);
|
|
repo.commit("Add config");
|
|
|
|
// Manually approve the command using the same project id wt will compute.
|
|
let mut approvals = Approvals::default();
|
|
approvals
|
|
.approve_command(
|
|
repo.project_id(),
|
|
"echo 'test'".to_string(),
|
|
repo.test_approvals_path(),
|
|
)
|
|
.unwrap();
|
|
|
|
// Try to add approvals - should show "all already approved"
|
|
snapshot_add_approvals("add_approvals_all_already_approved", &repo, &[]);
|
|
}
|
|
|
|
#[rstest]
|
|
fn test_add_approvals_project_config_no_commands(repo: TestRepo) {
|
|
// Create project config with only non-hook settings
|
|
repo.write_project_config(
|
|
r#"# Project config without any hook sections
|
|
[list]
|
|
url = "http://localhost:8080"
|
|
"#,
|
|
);
|
|
repo.commit("Add config without hooks");
|
|
|
|
// Try to add approvals - should show "no commands configured"
|
|
snapshot_add_approvals("add_approvals_no_commands", &repo, &[]);
|
|
}
|
|
|
|
// ============================================================================
|
|
// bare repository tests
|
|
// ============================================================================
|
|
|
|
/// Regression test for #1744: `wt config approvals add` should find project config
|
|
/// in a bare repo's primary worktree. `config create --project` should place it
|
|
/// there (not in the bare repo root), consistent with `ProjectConfig::load`.
|
|
#[test]
|
|
fn test_add_approvals_bare_repo_config_in_primary_worktree() {
|
|
let test = BareRepoTest::new();
|
|
let main_worktree = test.create_worktree("main", "main");
|
|
test.commit_in(&main_worktree, "Initial commit");
|
|
|
|
// Write project config in the primary worktree's .config/wt.toml
|
|
// This is where `config create --project` should place it for bare repos
|
|
let config_dir = main_worktree.join(".config");
|
|
std::fs::create_dir_all(&config_dir).unwrap();
|
|
std::fs::write(
|
|
config_dir.join("wt.toml"),
|
|
r#"pre-start = "echo 'hello'"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let settings = setup_temp_snapshot_settings(test.temp_path());
|
|
settings.bind(|| {
|
|
// Run `wt config approvals add --all` from the main worktree
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
cmd.current_dir(&main_worktree)
|
|
.args(["config", "approvals", "add", "--all"]);
|
|
assert_cmd_snapshot!("add_approvals_bare_repo_config_in_primary_worktree", cmd);
|
|
});
|
|
}
|
|
|
|
/// Test that `project_config_path` returns None (and config create errors)
|
|
/// when no linked worktrees exist in a bare repo.
|
|
#[test]
|
|
fn test_config_create_project_bare_repo_no_worktrees_errors() {
|
|
let test = BareRepoTest::new();
|
|
// Don't create any worktrees — no config location available
|
|
|
|
// Run `wt config create --project` from the bare repo root — should fail
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
cmd.current_dir(test.bare_repo_path())
|
|
.args(["config", "create", "--project"]);
|
|
let output = cmd.output().unwrap();
|
|
assert!(
|
|
!output.status.success(),
|
|
"wt config create --project should fail with no worktrees"
|
|
);
|
|
|
|
// Config should NOT be created at the bare repo root
|
|
let bare_root_config = test.bare_repo_path().join(".config").join("wt.toml");
|
|
assert!(
|
|
!bare_root_config.exists(),
|
|
"Config should NOT be created in bare repo root at {:?}",
|
|
bare_root_config
|
|
);
|
|
}
|
|
|
|
/// `config approvals add` and `hook show` should error in a bare repo with
|
|
/// no linked worktrees (project_config_path returns None).
|
|
#[test]
|
|
fn test_hook_commands_bare_repo_no_worktrees_errors() {
|
|
let test = BareRepoTest::new();
|
|
|
|
// config approvals add --all should fail
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
cmd.current_dir(test.bare_repo_path())
|
|
.args(["config", "approvals", "add", "--all"]);
|
|
let output = cmd.output().unwrap();
|
|
assert!(
|
|
!output.status.success(),
|
|
"config approvals add should fail with no worktrees"
|
|
);
|
|
|
|
// hook show should fail
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
cmd.current_dir(test.bare_repo_path())
|
|
.args(["hook", "show"]);
|
|
let output = cmd.output().unwrap();
|
|
assert!(
|
|
!output.status.success(),
|
|
"hook show should fail with no worktrees"
|
|
);
|
|
}
|
|
|
|
/// Regression test for #1744: `wt config create --project` in a bare repo
|
|
/// should create config in the primary worktree, not the bare repo root.
|
|
#[test]
|
|
fn test_config_create_project_bare_repo_uses_primary_worktree() {
|
|
let test = BareRepoTest::new();
|
|
let main_worktree = test.create_worktree("main", "main");
|
|
test.commit_in(&main_worktree, "Initial commit");
|
|
|
|
// Run `wt config create --project` from the bare repo root
|
|
let mut cmd = wt_command();
|
|
test.configure_wt_cmd(&mut cmd);
|
|
cmd.current_dir(test.bare_repo_path())
|
|
.args(["config", "create", "--project"]);
|
|
let output = cmd.output().unwrap();
|
|
assert!(
|
|
output.status.success(),
|
|
"wt config create --project failed:\nstderr: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
// Config should be in the primary worktree, NOT the bare repo root
|
|
let primary_config = main_worktree.join(".config").join("wt.toml");
|
|
let bare_root_config = test.bare_repo_path().join(".config").join("wt.toml");
|
|
assert!(
|
|
primary_config.exists(),
|
|
"Config should be created in primary worktree at {:?}",
|
|
primary_config
|
|
);
|
|
assert!(
|
|
!bare_root_config.exists(),
|
|
"Config should NOT be created in bare repo root at {:?}",
|
|
bare_root_config
|
|
);
|
|
}
|