story-kit: merge 216_story_merge_quality_gates_should_use_project_toml_components_and_script_test_instead_of_hardcoded_frontend_pnpm

This commit is contained in:
Dave
2026-02-26 19:30:26 +00:00
parent 8de3591a5c
commit 8f0624f012

View File

@@ -2803,42 +2803,39 @@ fn run_squash_merge(
}); });
} }
// ── Install frontend dependencies for quality gates ────────── // ── Run component setup from project.toml (same as worktree creation) ──────────
let frontend_dir = merge_wt_path.join("frontend"); {
if frontend_dir.exists() { let config = ProjectConfig::load(&merge_wt_path).unwrap_or_default();
// Ensure frontend/dist exists so RustEmbed (cargo clippy) doesn't fail if !config.component.is_empty() {
// even before pnpm build runs. all_output.push_str("=== component setup (merge worktree) ===\n");
let dist_dir = frontend_dir.join("dist"); }
let _ = std::fs::create_dir_all(&dist_dir); for component in &config.component {
let cmd_dir = merge_wt_path.join(&component.path);
all_output.push_str("=== pnpm install (merge worktree) ===\n"); for cmd in &component.setup {
let pnpm_install = Command::new("pnpm") all_output.push_str(&format!("--- {}: {cmd} ---\n", component.name));
.args(["install"]) match Command::new("sh")
.current_dir(&frontend_dir) .args(["-c", cmd])
.current_dir(&cmd_dir)
.output() .output()
.map_err(|e| format!("Failed to run pnpm install: {e}"))?; {
Ok(out) => {
let install_out = format!( all_output.push_str(&String::from_utf8_lossy(&out.stdout));
"{}{}", all_output.push_str(&String::from_utf8_lossy(&out.stderr));
String::from_utf8_lossy(&pnpm_install.stdout),
String::from_utf8_lossy(&pnpm_install.stderr)
);
all_output.push_str(&install_out);
all_output.push('\n'); all_output.push('\n');
if !out.status.success() {
if !pnpm_install.status.success() { all_output.push_str(&format!(
all_output.push_str( "=== setup warning: '{}' failed: {cmd} ===\n",
"=== pnpm install FAILED — aborting merge, master unchanged ===\n", component.name
); ));
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch); }
return Ok(SquashMergeResult { }
success: false, Err(e) => {
had_conflicts, all_output.push_str(&format!(
conflicts_resolved, "=== setup warning: failed to run '{cmd}': {e} ===\n"
conflict_details, ));
output: all_output, }
gates_passed: false, }
}); }
} }
} }
@@ -3114,17 +3111,34 @@ fn resolve_simple_conflicts(content: &str) -> Option<String> {
/// Run quality gates in the project root after a successful merge. /// Run quality gates in the project root after a successful merge.
/// ///
/// Runs: cargo clippy and tests if Cargo.toml is present, script/test if present, and pnpm gates /// Runs quality gates in the merge workspace.
/// if frontend/ exists. Sections are skipped when the corresponding project artefacts are absent. ///
/// When `script/test` is present it is the single source of truth and is the
/// only gate that runs — it is expected to cover the full suite (clippy, unit
/// tests, frontend tests, etc.). When `script/test` is absent the function
/// falls back to `cargo clippy` + `cargo nextest`/`cargo test` for Rust
/// projects. No hardcoded references to pnpm or frontend/ are used.
///
/// Returns `(gates_passed, combined_output)`. /// Returns `(gates_passed, combined_output)`.
fn run_merge_quality_gates(project_root: &Path) -> Result<(bool, String), String> { fn run_merge_quality_gates(project_root: &Path) -> Result<(bool, String), String> {
let mut all_output = String::new(); let mut all_output = String::new();
let mut all_passed = true; let mut all_passed = true;
let cargo_toml = project_root.join("Cargo.toml");
let script_test = project_root.join("script").join("test"); let script_test = project_root.join("script").join("test");
// ── cargo clippy (only when a Rust project is present) ──────── if script_test.exists() {
// Delegate entirely to script/test — it is the single source of truth
// for the full test suite (clippy, cargo tests, frontend builds, etc.).
let (success, output) = run_project_tests(project_root)?;
all_output.push_str(&output);
if !success {
all_passed = false;
}
return Ok((all_passed, all_output));
}
// No script/test — fall back to cargo gates for Rust projects.
let cargo_toml = project_root.join("Cargo.toml");
if cargo_toml.exists() { if cargo_toml.exists() {
let clippy = Command::new("cargo") let clippy = Command::new("cargo")
.args(["clippy", "--all-targets", "--all-features"]) .args(["clippy", "--all-targets", "--all-features"])
@@ -3144,13 +3158,7 @@ fn run_merge_quality_gates(project_root: &Path) -> Result<(bool, String), String
if !clippy.status.success() { if !clippy.status.success() {
all_passed = false; all_passed = false;
} }
}
// ── tests (script/test if available, else cargo nextest/test) ─
// Only run when there is something to run: a script/test entrypoint or a
// Rust project (Cargo.toml). Skipping avoids spurious failures in
// environments that have neither (e.g. test temp-dirs).
if script_test.exists() || cargo_toml.exists() {
let (test_success, test_out) = run_project_tests(project_root)?; let (test_success, test_out) = run_project_tests(project_root)?;
all_output.push_str(&test_out); all_output.push_str(&test_out);
if !test_success { if !test_success {
@@ -3158,55 +3166,6 @@ fn run_merge_quality_gates(project_root: &Path) -> Result<(bool, String), String
} }
} }
// ── pnpm build (if frontend/ directory exists) ────────────────
// pnpm test is handled by script/test when present; only run it here as
// a standalone fallback when there is no script/test.
let frontend_dir = project_root.join("frontend");
if frontend_dir.exists() {
all_output.push_str("=== pnpm build ===\n");
let pnpm_build = Command::new("pnpm")
.args(["run", "build"])
.current_dir(&frontend_dir)
.output()
.map_err(|e| format!("Failed to run pnpm build: {e}"))?;
let build_out = format!(
"{}{}",
String::from_utf8_lossy(&pnpm_build.stdout),
String::from_utf8_lossy(&pnpm_build.stderr)
);
all_output.push_str(&build_out);
all_output.push('\n');
if !pnpm_build.status.success() {
all_passed = false;
}
// Only run pnpm test separately when script/test is absent (it would
// already cover frontend tests in that case).
let script_test = project_root.join("script").join("test");
if !script_test.exists() {
all_output.push_str("=== pnpm test ===\n");
let pnpm_test = Command::new("pnpm")
.args(["test", "--run"])
.current_dir(&frontend_dir)
.output()
.map_err(|e| format!("Failed to run pnpm test: {e}"))?;
let pnpm_test_out = format!(
"{}{}",
String::from_utf8_lossy(&pnpm_test.stdout),
String::from_utf8_lossy(&pnpm_test.stderr)
);
all_output.push_str(&pnpm_test_out);
all_output.push('\n');
if !pnpm_test.status.success() {
all_passed = false;
}
}
}
Ok((all_passed, all_output)) Ok((all_passed, all_output))
} }
@@ -6202,15 +6161,14 @@ theirs
assert!(root.join(".story_kit/work/5_done/60_story_cleanup.md").exists()); assert!(root.join(".story_kit/work/5_done/60_story_cleanup.md").exists());
} }
// ── bug 154: merge worktree installs frontend deps ──────────────────── // ── story 216: merge worktree uses project.toml component setup ───────────
/// When the feature branch has a `frontend/` directory, `run_squash_merge` /// When the project has `[[component]]` entries in `.story_kit/project.toml`,
/// must run `pnpm install` in the merge worktree before quality gates. /// `run_squash_merge` must run their setup commands in the merge worktree
/// This test creates a repo with a `frontend/package.json` and verifies that /// before quality gates — matching the behaviour of `create_worktree`.
/// the merge output mentions the pnpm install step.
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn squash_merge_runs_pnpm_install_when_frontend_exists() { fn squash_merge_runs_component_setup_from_project_toml() {
use std::fs; use std::fs;
use tempfile::tempdir; use tempfile::tempdir;
@@ -6218,12 +6176,13 @@ theirs
let repo = tmp.path(); let repo = tmp.path();
init_git_repo(repo); init_git_repo(repo);
// Add a frontend/ directory with a minimal package.json on master. // Add a .story_kit/project.toml with a component whose setup writes a
let frontend = repo.join("frontend"); // sentinel file so we can confirm the command ran.
fs::create_dir_all(&frontend).unwrap(); let sk_dir = repo.join(".story_kit");
fs::create_dir_all(&sk_dir).unwrap();
fs::write( fs::write(
frontend.join("package.json"), sk_dir.join("project.toml"),
r#"{"name":"test","version":"0.0.0","private":true}"#, "[[component]]\nname = \"sentinel\"\npath = \".\"\nsetup = [\"touch setup_ran.txt\"]\n",
) )
.unwrap(); .unwrap();
Command::new("git") Command::new("git")
@@ -6232,14 +6191,14 @@ theirs
.output() .output()
.unwrap(); .unwrap();
Command::new("git") Command::new("git")
.args(["commit", "-m", "add frontend dir"]) .args(["commit", "-m", "add project.toml with component setup"])
.current_dir(repo) .current_dir(repo)
.output() .output()
.unwrap(); .unwrap();
// Create feature branch with a change. // Create feature branch with a change.
Command::new("git") Command::new("git")
.args(["checkout", "-b", "feature/story-154_test"]) .args(["checkout", "-b", "feature/story-216_setup_test"])
.current_dir(repo) .current_dir(repo)
.output() .output()
.unwrap(); .unwrap();
@@ -6263,21 +6222,28 @@ theirs
.unwrap(); .unwrap();
let result = let result =
run_squash_merge(repo, "feature/story-154_test", "154_test").unwrap(); run_squash_merge(repo, "feature/story-216_setup_test", "216_setup_test").unwrap();
// The output must mention pnpm install, proving the new code path ran. // The output must mention component setup, proving the new code path ran.
assert!( assert!(
result.output.contains("pnpm install"), result.output.contains("component setup"),
"merge output must mention pnpm install when frontend/ exists, got:\n{}", "merge output must mention component setup when project.toml has components, got:\n{}",
result.output
);
// The sentinel command must appear in the output.
assert!(
result.output.contains("sentinel"),
"merge output must name the component, got:\n{}",
result.output result.output
); );
} }
/// When `pnpm install` fails in the merge worktree (e.g. no lockfile), /// When there are no `[[component]]` entries in project.toml (or no
/// the merge must abort cleanly — success=false, workspace cleaned up. /// project.toml at all), `run_squash_merge` must succeed without trying to
/// run any setup. No hardcoded pnpm or frontend/ references should appear.
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn squash_merge_aborts_when_pnpm_install_fails() { fn squash_merge_succeeds_without_components_in_project_toml() {
use std::fs; use std::fs;
use tempfile::tempdir; use tempfile::tempdir;
@@ -6285,33 +6251,25 @@ theirs
let repo = tmp.path(); let repo = tmp.path();
init_git_repo(repo); init_git_repo(repo);
// Add a frontend/ directory with an invalid package.json that will // No .story_kit/project.toml — no component setup.
// cause pnpm install --frozen-lockfile to fail (no lockfile present). fs::write(repo.join("file.txt"), "initial").unwrap();
let frontend = repo.join("frontend");
fs::create_dir_all(&frontend).unwrap();
fs::write(
frontend.join("package.json"),
r#"{"name":"test","version":"0.0.0","dependencies":{"nonexistent-pkg-xyz":"99.99.99"}}"#,
)
.unwrap();
Command::new("git") Command::new("git")
.args(["add", "."]) .args(["add", "."])
.current_dir(repo) .current_dir(repo)
.output() .output()
.unwrap(); .unwrap();
Command::new("git") Command::new("git")
.args(["commit", "-m", "add frontend with bad deps"]) .args(["commit", "-m", "initial commit"])
.current_dir(repo) .current_dir(repo)
.output() .output()
.unwrap(); .unwrap();
// Feature branch with a change.
Command::new("git") Command::new("git")
.args(["checkout", "-b", "feature/story-154_fail"]) .args(["checkout", "-b", "feature/story-216_no_components"])
.current_dir(repo) .current_dir(repo)
.output() .output()
.unwrap(); .unwrap();
fs::write(repo.join("change.txt"), "feature").unwrap(); fs::write(repo.join("change.txt"), "change").unwrap();
Command::new("git") Command::new("git")
.args(["add", "."]) .args(["add", "."])
.current_dir(repo) .current_dir(repo)
@@ -6323,58 +6281,26 @@ theirs
.output() .output()
.unwrap(); .unwrap();
// Switch back to master, record HEAD.
Command::new("git") Command::new("git")
.args(["checkout", "master"]) .args(["checkout", "master"])
.current_dir(repo) .current_dir(repo)
.output() .output()
.unwrap(); .unwrap();
let head_before = String::from_utf8(
Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
let result = let result =
run_squash_merge(repo, "feature/story-154_fail", "154_fail").unwrap(); run_squash_merge(repo, "feature/story-216_no_components", "216_no_components")
.unwrap();
// pnpm install --frozen-lockfile should fail (no lockfile), merge aborted. // No pnpm or frontend references should appear in the output.
assert!( assert!(
!result.success, !result.output.contains("pnpm"),
"merge should fail when pnpm install fails" "output must not mention pnpm, got:\n{}",
result.output
); );
assert!( assert!(
result.output.contains("pnpm install"), !result.output.contains("frontend/"),
"output should mention pnpm install" "output must not mention frontend/, got:\n{}",
); result.output
// Master HEAD must not have moved.
let head_after = String::from_utf8(
Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string();
assert_eq!(
head_before, head_after,
"master HEAD must not advance when pnpm install fails (bug 154)"
);
// Merge workspace should be cleaned up.
assert!(
!repo.join(".story_kit/merge_workspace").exists(),
"merge workspace should be cleaned up after failure"
); );
} }