huskies: merge 682_refactor_decompose_server_src_agents_merge_squash_rs_1346_lines
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,519 @@
|
|||||||
|
//! Squash-merge orchestration: rebase agent work onto master and run post-merge gates.
|
||||||
|
|
||||||
|
#![allow(unused_imports, dead_code)]
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use super::super::gates::run_project_tests;
|
||||||
|
use super::conflicts::try_resolve_conflicts;
|
||||||
|
use super::{MergeReport, SquashMergeResult};
|
||||||
|
use crate::config::ProjectConfig;
|
||||||
|
|
||||||
|
/// Global lock ensuring only one squash-merge runs at a time.
|
||||||
|
///
|
||||||
|
/// The merge pipeline uses a shared `.huskies/merge_workspace` directory and
|
||||||
|
/// temporary `merge-queue/{story_id}` branches. If two merges run concurrently,
|
||||||
|
/// the second call's initial cleanup destroys the first call's branch mid-flight,
|
||||||
|
/// causing `git cherry-pick merge-queue/…` to fail with "bad revision".
|
||||||
|
static MERGE_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
pub(crate) fn run_squash_merge(
|
||||||
|
project_root: &Path,
|
||||||
|
branch: &str,
|
||||||
|
story_id: &str,
|
||||||
|
) -> Result<SquashMergeResult, String> {
|
||||||
|
// Acquire the merge lock so concurrent calls don't clobber each other.
|
||||||
|
let _lock = MERGE_LOCK
|
||||||
|
.lock()
|
||||||
|
.map_err(|e| format!("Merge lock poisoned: {e}"))?;
|
||||||
|
|
||||||
|
// ── Pre-flight: verify the branch has commits ahead of base ──────────────
|
||||||
|
// A zero-commit branch produces an empty squash and a silent "nothing to
|
||||||
|
// commit" failure. Catch it early with a grep-able error before any merge
|
||||||
|
// work starts.
|
||||||
|
let base_branch = crate::config::ProjectConfig::load(project_root)
|
||||||
|
.ok()
|
||||||
|
.and_then(|c| c.base_branch.clone())
|
||||||
|
.unwrap_or_else(|| "master".to_string());
|
||||||
|
|
||||||
|
let ahead_out = Command::new("git")
|
||||||
|
.args(["rev-list", "--count", &format!("{base_branch}..{branch}")])
|
||||||
|
.current_dir(project_root)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to count commits ahead: {e}"))?;
|
||||||
|
|
||||||
|
if ahead_out.status.success() {
|
||||||
|
let ahead: u64 = String::from_utf8_lossy(&ahead_out.stdout)
|
||||||
|
.trim()
|
||||||
|
.parse()
|
||||||
|
.unwrap_or(1); // parse failure → don't false-positive; let merge proceed
|
||||||
|
if ahead == 0 {
|
||||||
|
return Err(format!(
|
||||||
|
"{story_id}: no commits to merge — feature branch '{branch}' \
|
||||||
|
has 0 commits ahead of '{base_branch}'"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut all_output = String::new();
|
||||||
|
let merge_branch = format!("merge-queue/{story_id}");
|
||||||
|
let merge_wt_path = project_root.join(".huskies").join("merge_workspace");
|
||||||
|
|
||||||
|
// Ensure we start clean: remove any leftover merge workspace.
|
||||||
|
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||||
|
|
||||||
|
// ── Create merge-queue branch at current HEAD ─────────────────
|
||||||
|
all_output.push_str(&format!(
|
||||||
|
"=== Creating merge-queue branch '{merge_branch}' ===\n"
|
||||||
|
));
|
||||||
|
let create_branch = Command::new("git")
|
||||||
|
.args(["branch", &merge_branch])
|
||||||
|
.current_dir(project_root)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to create merge-queue branch: {e}"))?;
|
||||||
|
if !create_branch.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&create_branch.stderr);
|
||||||
|
all_output.push_str(&format!("Branch creation failed: {stderr}\n"));
|
||||||
|
return Err(format!("Failed to create merge-queue branch: {stderr}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create temporary worktree ─────────────────────────────────
|
||||||
|
all_output.push_str("=== Creating temporary merge worktree ===\n");
|
||||||
|
let wt_str = merge_wt_path.to_string_lossy().to_string();
|
||||||
|
let create_wt = Command::new("git")
|
||||||
|
.args(["worktree", "add", &wt_str, &merge_branch])
|
||||||
|
.current_dir(project_root)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to create merge worktree: {e}"))?;
|
||||||
|
if !create_wt.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&create_wt.stderr);
|
||||||
|
all_output.push_str(&format!("Worktree creation failed: {stderr}\n"));
|
||||||
|
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||||
|
return Err(format!("Failed to create merge worktree: {stderr}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Squash-merge in the temporary worktree ────────────────────
|
||||||
|
all_output.push_str(&format!("=== git merge --squash {branch} ===\n"));
|
||||||
|
let merge = Command::new("git")
|
||||||
|
.args(["merge", "--squash", branch])
|
||||||
|
.current_dir(&merge_wt_path)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to run git merge: {e}"))?;
|
||||||
|
|
||||||
|
let merge_stdout = String::from_utf8_lossy(&merge.stdout).to_string();
|
||||||
|
let merge_stderr = String::from_utf8_lossy(&merge.stderr).to_string();
|
||||||
|
all_output.push_str(&merge_stdout);
|
||||||
|
all_output.push_str(&merge_stderr);
|
||||||
|
all_output.push('\n');
|
||||||
|
|
||||||
|
let mut had_conflicts = false;
|
||||||
|
let mut conflicts_resolved = false;
|
||||||
|
let mut conflict_details: Option<String> = None;
|
||||||
|
|
||||||
|
if !merge.status.success() {
|
||||||
|
had_conflicts = true;
|
||||||
|
all_output.push_str("=== Conflicts detected, attempting auto-resolution ===\n");
|
||||||
|
|
||||||
|
// Try to automatically resolve simple conflicts.
|
||||||
|
match try_resolve_conflicts(&merge_wt_path) {
|
||||||
|
Ok((resolved, resolution_log)) => {
|
||||||
|
all_output.push_str(&resolution_log);
|
||||||
|
if resolved {
|
||||||
|
conflicts_resolved = true;
|
||||||
|
all_output.push_str("=== All conflicts resolved automatically ===\n");
|
||||||
|
} else {
|
||||||
|
// Could not resolve — abort, clean up, and report.
|
||||||
|
let details = format!(
|
||||||
|
"Merge conflicts in branch '{branch}':\n{merge_stdout}{merge_stderr}\n{resolution_log}"
|
||||||
|
);
|
||||||
|
conflict_details = Some(details);
|
||||||
|
all_output.push_str("=== Unresolvable conflicts, aborting merge ===\n");
|
||||||
|
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||||
|
return Ok(SquashMergeResult {
|
||||||
|
success: false,
|
||||||
|
had_conflicts: true,
|
||||||
|
conflicts_resolved: false,
|
||||||
|
conflict_details,
|
||||||
|
output: all_output,
|
||||||
|
gates_passed: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
all_output.push_str(&format!("Auto-resolution error: {e}\n"));
|
||||||
|
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||||
|
return Ok(SquashMergeResult {
|
||||||
|
success: false,
|
||||||
|
had_conflicts: true,
|
||||||
|
conflicts_resolved: false,
|
||||||
|
conflict_details: Some(format!(
|
||||||
|
"Merge conflicts in branch '{branch}' (auto-resolution failed: {e}):\n{merge_stdout}{merge_stderr}"
|
||||||
|
)),
|
||||||
|
output: all_output,
|
||||||
|
gates_passed: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Commit in the temporary worktree ──────────────────────────
|
||||||
|
all_output.push_str("=== git commit ===\n");
|
||||||
|
let commit_msg = format!("huskies: merge {story_id}");
|
||||||
|
let commit = Command::new("git")
|
||||||
|
.args(["commit", "-m", &commit_msg])
|
||||||
|
.current_dir(&merge_wt_path)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to run git commit: {e}"))?;
|
||||||
|
|
||||||
|
let commit_stdout = String::from_utf8_lossy(&commit.stdout).to_string();
|
||||||
|
let commit_stderr = String::from_utf8_lossy(&commit.stderr).to_string();
|
||||||
|
all_output.push_str(&commit_stdout);
|
||||||
|
all_output.push_str(&commit_stderr);
|
||||||
|
all_output.push('\n');
|
||||||
|
|
||||||
|
if !commit.status.success() {
|
||||||
|
// Bug 226: "nothing to commit" means the feature branch has no changes
|
||||||
|
// beyond what's already on master. This must NOT be treated as success
|
||||||
|
// — it means the code was never actually merged.
|
||||||
|
if commit_stderr.contains("nothing to commit")
|
||||||
|
|| commit_stdout.contains("nothing to commit")
|
||||||
|
{
|
||||||
|
all_output.push_str(
|
||||||
|
"=== Nothing to commit — feature branch has no changes beyond master ===\n",
|
||||||
|
);
|
||||||
|
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||||
|
return Ok(SquashMergeResult {
|
||||||
|
success: false,
|
||||||
|
had_conflicts,
|
||||||
|
conflicts_resolved,
|
||||||
|
conflict_details: Some(
|
||||||
|
"Squash-merge resulted in an empty diff — the feature branch has no \
|
||||||
|
code changes to merge into master."
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
output: all_output,
|
||||||
|
gates_passed: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||||
|
return Ok(SquashMergeResult {
|
||||||
|
success: false,
|
||||||
|
had_conflicts,
|
||||||
|
conflicts_resolved,
|
||||||
|
conflict_details,
|
||||||
|
output: all_output,
|
||||||
|
gates_passed: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bug 226: Verify the commit contains real code changes ─────
|
||||||
|
// If the merge only brought in .huskies/ files (pipeline file moves),
|
||||||
|
// there are no actual code changes to land on master. Abort.
|
||||||
|
{
|
||||||
|
let diff_check = Command::new("git")
|
||||||
|
.args(["diff", "--name-only", "HEAD~1..HEAD"])
|
||||||
|
.current_dir(&merge_wt_path)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to check merge diff: {e}"))?;
|
||||||
|
let changed_files = String::from_utf8_lossy(&diff_check.stdout);
|
||||||
|
let has_code_changes = changed_files
|
||||||
|
.lines()
|
||||||
|
.any(|f| !f.starts_with(".huskies/work/"));
|
||||||
|
if !has_code_changes {
|
||||||
|
all_output.push_str(
|
||||||
|
"=== Merge commit contains only .huskies/ file moves, no code changes ===\n",
|
||||||
|
);
|
||||||
|
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||||
|
return Ok(SquashMergeResult {
|
||||||
|
success: false,
|
||||||
|
had_conflicts,
|
||||||
|
conflicts_resolved,
|
||||||
|
conflict_details: Some(
|
||||||
|
"Feature branch has no code changes outside .huskies/ — only \
|
||||||
|
pipeline file moves were found."
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
output: all_output,
|
||||||
|
gates_passed: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Run component setup from project.toml (same as worktree creation) ──────────
|
||||||
|
{
|
||||||
|
let config = ProjectConfig::load(&merge_wt_path).unwrap_or_default();
|
||||||
|
if !config.component.is_empty() {
|
||||||
|
all_output.push_str("=== component setup (merge worktree) ===\n");
|
||||||
|
}
|
||||||
|
for component in &config.component {
|
||||||
|
let cmd_dir = merge_wt_path.join(&component.path);
|
||||||
|
for cmd in &component.setup {
|
||||||
|
all_output.push_str(&format!("--- {}: {cmd} ---\n", component.name));
|
||||||
|
match Command::new("sh")
|
||||||
|
.args(["-c", cmd])
|
||||||
|
.current_dir(&cmd_dir)
|
||||||
|
.output()
|
||||||
|
{
|
||||||
|
Ok(out) => {
|
||||||
|
all_output.push_str(&String::from_utf8_lossy(&out.stdout));
|
||||||
|
all_output.push_str(&String::from_utf8_lossy(&out.stderr));
|
||||||
|
all_output.push('\n');
|
||||||
|
if !out.status.success() {
|
||||||
|
all_output.push_str(&format!(
|
||||||
|
"=== setup warning: '{}' failed: {cmd} ===\n",
|
||||||
|
component.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
all_output.push_str(&format!(
|
||||||
|
"=== setup warning: failed to run '{cmd}': {e} ===\n"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quality gates in merge workspace (BEFORE fast-forward) ────
|
||||||
|
// Run gates in the merge worktree so that failures abort before master moves.
|
||||||
|
all_output.push_str("=== Running quality gates before fast-forward ===\n");
|
||||||
|
match run_merge_quality_gates(&merge_wt_path) {
|
||||||
|
Ok((true, gate_out)) => {
|
||||||
|
all_output.push_str(&gate_out);
|
||||||
|
all_output.push('\n');
|
||||||
|
all_output.push_str("=== Quality gates passed ===\n");
|
||||||
|
}
|
||||||
|
Ok((false, gate_out)) => {
|
||||||
|
all_output.push_str(&gate_out);
|
||||||
|
all_output.push('\n');
|
||||||
|
all_output.push_str(
|
||||||
|
"=== Quality gates FAILED — aborting fast-forward, master unchanged ===\n",
|
||||||
|
);
|
||||||
|
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||||
|
return Ok(SquashMergeResult {
|
||||||
|
success: false,
|
||||||
|
had_conflicts,
|
||||||
|
conflicts_resolved,
|
||||||
|
conflict_details,
|
||||||
|
output: all_output,
|
||||||
|
gates_passed: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
all_output.push_str(&format!("Gate check error: {e}\n"));
|
||||||
|
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||||
|
return Ok(SquashMergeResult {
|
||||||
|
success: false,
|
||||||
|
had_conflicts,
|
||||||
|
conflicts_resolved,
|
||||||
|
conflict_details,
|
||||||
|
output: all_output,
|
||||||
|
gates_passed: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cherry-pick the squash commit onto master ──────────────────
|
||||||
|
// We cherry-pick instead of fast-forward so that concurrent filesystem
|
||||||
|
// watcher commits on master (e.g. pipeline file moves) don't block the
|
||||||
|
// merge. Cherry-pick applies the diff of the squash commit cleanly on
|
||||||
|
// top of master's current HEAD.
|
||||||
|
all_output.push_str(&format!(
|
||||||
|
"=== Cherry-picking squash commit from {merge_branch} onto master ===\n"
|
||||||
|
));
|
||||||
|
let cp = Command::new("git")
|
||||||
|
.args(["cherry-pick", &merge_branch])
|
||||||
|
.current_dir(project_root)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to cherry-pick merge-queue commit: {e}"))?;
|
||||||
|
|
||||||
|
let cp_stdout = String::from_utf8_lossy(&cp.stdout).to_string();
|
||||||
|
let cp_stderr = String::from_utf8_lossy(&cp.stderr).to_string();
|
||||||
|
all_output.push_str(&cp_stdout);
|
||||||
|
all_output.push_str(&cp_stderr);
|
||||||
|
all_output.push('\n');
|
||||||
|
|
||||||
|
if !cp.status.success() {
|
||||||
|
// Abort the cherry-pick so master is left clean.
|
||||||
|
let _ = Command::new("git")
|
||||||
|
.args(["cherry-pick", "--abort"])
|
||||||
|
.current_dir(project_root)
|
||||||
|
.output();
|
||||||
|
all_output.push_str("=== Cherry-pick failed — aborting, master unchanged ===\n");
|
||||||
|
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||||
|
return Ok(SquashMergeResult {
|
||||||
|
success: false,
|
||||||
|
had_conflicts,
|
||||||
|
conflicts_resolved,
|
||||||
|
conflict_details: Some(format!(
|
||||||
|
"Cherry-pick of squash commit failed (conflict with master?):\n{cp_stderr}"
|
||||||
|
)),
|
||||||
|
output: all_output,
|
||||||
|
gates_passed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Verify code landed on the correct branch ──────────────────
|
||||||
|
// Guard against the cherry-pick silently landing on the wrong branch
|
||||||
|
// (e.g. a merge-queue branch from a concurrent merge). If the current
|
||||||
|
// branch is not the base branch, or the HEAD commit has no code diff,
|
||||||
|
// treat the merge as failed so the story stays in the merge stage.
|
||||||
|
let current_branch = Command::new("git")
|
||||||
|
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||||
|
.current_dir(project_root)
|
||||||
|
.output()
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let base_branch = crate::config::ProjectConfig::load(project_root)
|
||||||
|
.ok()
|
||||||
|
.and_then(|c| c.base_branch.clone())
|
||||||
|
.unwrap_or_else(|| "master".to_string());
|
||||||
|
|
||||||
|
if current_branch != base_branch {
|
||||||
|
all_output.push_str(&format!(
|
||||||
|
"=== VERIFICATION FAILED: expected branch '{base_branch}' but HEAD is on \
|
||||||
|
'{current_branch}'. Cherry-pick landed on wrong branch. ===\n"
|
||||||
|
));
|
||||||
|
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||||
|
return Ok(SquashMergeResult {
|
||||||
|
success: false,
|
||||||
|
had_conflicts,
|
||||||
|
conflicts_resolved,
|
||||||
|
conflict_details: Some(format!(
|
||||||
|
"Cherry-pick landed on '{current_branch}' instead of '{base_branch}'"
|
||||||
|
)),
|
||||||
|
output: all_output,
|
||||||
|
gates_passed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify HEAD commit has actual code changes (not an empty cherry-pick).
|
||||||
|
// Exclude .huskies/work/ (pipeline file moves) but keep .huskies/project.toml
|
||||||
|
// and other config files which are legitimate deliverables.
|
||||||
|
let diff_stat = Command::new("git")
|
||||||
|
.args([
|
||||||
|
"diff",
|
||||||
|
"--stat",
|
||||||
|
"HEAD~1..HEAD",
|
||||||
|
"--",
|
||||||
|
".",
|
||||||
|
":(exclude).huskies/work",
|
||||||
|
])
|
||||||
|
.current_dir(project_root)
|
||||||
|
.output()
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if diff_stat.is_empty() {
|
||||||
|
all_output.push_str(
|
||||||
|
"=== VERIFICATION FAILED: cherry-pick produced no code changes on master. ===\n",
|
||||||
|
);
|
||||||
|
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||||
|
return Ok(SquashMergeResult {
|
||||||
|
success: false,
|
||||||
|
had_conflicts,
|
||||||
|
conflicts_resolved,
|
||||||
|
conflict_details: Some(
|
||||||
|
"Cherry-pick commit contains no code changes (empty diff)".to_string(),
|
||||||
|
),
|
||||||
|
output: all_output,
|
||||||
|
gates_passed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
all_output.push_str(&format!(
|
||||||
|
"=== Verified: cherry-pick landed on '{base_branch}' with code changes ===\n"
|
||||||
|
));
|
||||||
|
|
||||||
|
// ── Clean up ──────────────────────────────────────────────────
|
||||||
|
cleanup_merge_workspace(project_root, &merge_wt_path, &merge_branch);
|
||||||
|
all_output.push_str("=== Merge-queue cleanup complete ===\n");
|
||||||
|
|
||||||
|
Ok(SquashMergeResult {
|
||||||
|
success: true,
|
||||||
|
had_conflicts,
|
||||||
|
conflicts_resolved,
|
||||||
|
conflict_details,
|
||||||
|
output: all_output,
|
||||||
|
gates_passed: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn cleanup_merge_workspace(
|
||||||
|
project_root: &Path,
|
||||||
|
merge_wt_path: &Path,
|
||||||
|
merge_branch: &str,
|
||||||
|
) {
|
||||||
|
let wt_str = merge_wt_path.to_string_lossy().to_string();
|
||||||
|
let _ = Command::new("git")
|
||||||
|
.args(["worktree", "remove", "--force", &wt_str])
|
||||||
|
.current_dir(project_root)
|
||||||
|
.output();
|
||||||
|
// If the directory still exists (e.g. it was a plain directory from a
|
||||||
|
// previous failed run, not a registered git worktree), remove it so
|
||||||
|
// the next `git worktree add` can succeed.
|
||||||
|
if merge_wt_path.exists() {
|
||||||
|
let _ = std::fs::remove_dir_all(merge_wt_path);
|
||||||
|
}
|
||||||
|
let _ = Command::new("git")
|
||||||
|
.args(["branch", "-D", merge_branch])
|
||||||
|
.current_dir(project_root)
|
||||||
|
.output();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_merge_quality_gates(project_root: &Path) -> Result<(bool, String), String> {
|
||||||
|
let mut all_output = String::new();
|
||||||
|
let mut all_passed = true;
|
||||||
|
|
||||||
|
let script_test = project_root.join("script").join("test");
|
||||||
|
|
||||||
|
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() {
|
||||||
|
let clippy = Command::new("cargo")
|
||||||
|
.args(["clippy", "--all-targets", "--all-features"])
|
||||||
|
.current_dir(project_root)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to run cargo clippy: {e}"))?;
|
||||||
|
|
||||||
|
all_output.push_str("=== cargo clippy ===\n");
|
||||||
|
let clippy_out = format!(
|
||||||
|
"{}{}",
|
||||||
|
String::from_utf8_lossy(&clippy.stdout),
|
||||||
|
String::from_utf8_lossy(&clippy.stderr)
|
||||||
|
);
|
||||||
|
all_output.push_str(&clippy_out);
|
||||||
|
all_output.push('\n');
|
||||||
|
|
||||||
|
if !clippy.status.success() {
|
||||||
|
all_passed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (test_success, test_out) = run_project_tests(project_root)?;
|
||||||
|
all_output.push_str(&test_out);
|
||||||
|
if !test_success {
|
||||||
|
all_passed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((all_passed, all_output))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests_advanced;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests_basic;
|
||||||
@@ -0,0 +1,487 @@
|
|||||||
|
use super::*;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn init_git_repo(repo: &std::path::Path) {
|
||||||
|
Command::new("git")
|
||||||
|
.args(["init"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["config", "user.email", "test@test.com"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["config", "user.name", "Test"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "--allow-empty", "-m", "init"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn squash_merge_md_only_changes_fails() {
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
init_git_repo(repo);
|
||||||
|
|
||||||
|
// Create a feature branch that only moves a .huskies/ file.
|
||||||
|
Command::new("git")
|
||||||
|
.args(["checkout", "-b", "feature/story-md_only_test"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let sk_dir = repo.join(".huskies/work/2_current");
|
||||||
|
fs::create_dir_all(&sk_dir).unwrap();
|
||||||
|
fs::write(sk_dir.join("md_only_test.md"), "---\nname: Test\n---\n").unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "."])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "move story file"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["checkout", "master"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = run_squash_merge(repo, "feature/story-md_only_test", "md_only_test").unwrap();
|
||||||
|
|
||||||
|
// The squash merge will commit the .huskies/ file, but should fail because
|
||||||
|
// there are no code changes outside .huskies/.
|
||||||
|
assert!(
|
||||||
|
!result.success,
|
||||||
|
"merge with only .huskies/ changes must fail: {}",
|
||||||
|
result.output
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup should still happen.
|
||||||
|
assert!(
|
||||||
|
!repo.join(".huskies/merge_workspace").exists(),
|
||||||
|
"merge workspace should be cleaned up"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn squash_merge_additive_conflict_both_additions_preserved() {
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
init_git_repo(repo);
|
||||||
|
|
||||||
|
// Initial file with a shared base.
|
||||||
|
fs::write(repo.join("module.rs"), "// module\npub fn existing() {}\n").unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "."])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "initial module"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Feature branch: appends feature_fn to the file.
|
||||||
|
Command::new("git")
|
||||||
|
.args(["checkout", "-b", "feature/story-238_additive"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
repo.join("module.rs"),
|
||||||
|
"// module\npub fn existing() {}\npub fn feature_fn() {}\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "."])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "add feature_fn"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Simulate another branch already merged into master: appends master_fn.
|
||||||
|
Command::new("git")
|
||||||
|
.args(["checkout", "master"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
repo.join("module.rs"),
|
||||||
|
"// module\npub fn existing() {}\npub fn master_fn() {}\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "."])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "add master_fn (another branch merged)"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Squash-merge the feature branch — conflicts because both appended to the same location.
|
||||||
|
let result = run_squash_merge(repo, "feature/story-238_additive", "238_additive").unwrap();
|
||||||
|
|
||||||
|
// Conflict must be detected and auto-resolved.
|
||||||
|
assert!(result.had_conflicts, "additive conflict should be detected");
|
||||||
|
assert!(
|
||||||
|
result.conflicts_resolved,
|
||||||
|
"additive conflict must be auto-resolved; output:\n{}",
|
||||||
|
result.output
|
||||||
|
);
|
||||||
|
|
||||||
|
// Master must contain both additions without conflict markers.
|
||||||
|
let content = fs::read_to_string(repo.join("module.rs")).unwrap();
|
||||||
|
assert!(
|
||||||
|
!content.contains("<<<<<<<"),
|
||||||
|
"master must not contain conflict markers"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!content.contains(">>>>>>>"),
|
||||||
|
"master must not contain conflict markers"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
content.contains("feature_fn"),
|
||||||
|
"feature branch addition must be preserved on master"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
content.contains("master_fn"),
|
||||||
|
"master branch addition must be preserved on master"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
content.contains("existing"),
|
||||||
|
"original function must be preserved"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup: no leftover merge-queue branch or workspace.
|
||||||
|
let branches = Command::new("git")
|
||||||
|
.args(["branch", "--list", "merge-queue/*"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
String::from_utf8_lossy(&branches.stdout).trim().is_empty(),
|
||||||
|
"merge-queue branch must be cleaned up"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!repo.join(".huskies/merge_workspace").exists(),
|
||||||
|
"merge workspace must be cleaned up"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn squash_merge_conflict_resolved_but_gates_fail_reported_as_failure() {
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
init_git_repo(repo);
|
||||||
|
|
||||||
|
// Add a script/test that always fails (quality gate). This must be on
|
||||||
|
// master before the feature branch forks so it doesn't cause its own conflict.
|
||||||
|
let script_dir = repo.join("script");
|
||||||
|
fs::create_dir_all(&script_dir).unwrap();
|
||||||
|
fs::write(script_dir.join("test"), "#!/bin/sh\nexit 1\n").unwrap();
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(
|
||||||
|
script_dir.join("test"),
|
||||||
|
std::fs::Permissions::from_mode(0o755),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
fs::write(repo.join("code.txt"), "// base\n").unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "."])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "initial with failing script/test"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Feature branch: appends feature content (creates future conflict point).
|
||||||
|
Command::new("git")
|
||||||
|
.args(["checkout", "-b", "feature/story-238_gates_fail"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
fs::write(repo.join("code.txt"), "// base\nfeature_addition\n").unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "."])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "feature addition"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Master: append different content at same location (creates conflict).
|
||||||
|
Command::new("git")
|
||||||
|
.args(["checkout", "master"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
fs::write(repo.join("code.txt"), "// base\nmaster_addition\n").unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "."])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "master addition"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Squash-merge: conflict detected → auto-resolved → quality gates run → fail.
|
||||||
|
let result = run_squash_merge(repo, "feature/story-238_gates_fail", "238_gates_fail").unwrap();
|
||||||
|
|
||||||
|
assert!(result.had_conflicts, "conflict must be detected");
|
||||||
|
assert!(
|
||||||
|
result.conflicts_resolved,
|
||||||
|
"additive conflict must be auto-resolved"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!result.gates_passed,
|
||||||
|
"quality gates must fail (script/test exits 1)"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!result.success,
|
||||||
|
"merge must be reported as failed when gates fail"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!result.output.is_empty(),
|
||||||
|
"output must contain gate failure details"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Master must NOT have been updated (cherry-pick was blocked by gate failure).
|
||||||
|
let content = fs::read_to_string(repo.join("code.txt")).unwrap();
|
||||||
|
assert!(
|
||||||
|
!content.contains("<<<<<<<"),
|
||||||
|
"master must not contain conflict markers"
|
||||||
|
);
|
||||||
|
// master_addition was the last commit on master; feature_addition must NOT be there.
|
||||||
|
assert!(
|
||||||
|
!content.contains("feature_addition"),
|
||||||
|
"feature code must not land on master when gates fail"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cleanup must still happen.
|
||||||
|
assert!(
|
||||||
|
!repo.join(".huskies/merge_workspace").exists(),
|
||||||
|
"merge workspace must be cleaned up even on gate failure"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn squash_merge_cleans_up_stale_workspace() {
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
init_git_repo(repo);
|
||||||
|
|
||||||
|
// Create a feature branch with a file.
|
||||||
|
Command::new("git")
|
||||||
|
.args(["checkout", "-b", "feature/story-stale_test"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
fs::write(repo.join("stale.txt"), "content\n").unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "."])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "feature: stale test"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["checkout", "master"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Simulate a stale merge workspace left from a previous failed merge.
|
||||||
|
let stale_ws = repo.join(".huskies/merge_workspace");
|
||||||
|
fs::create_dir_all(&stale_ws).unwrap();
|
||||||
|
fs::write(stale_ws.join("leftover.txt"), "stale").unwrap();
|
||||||
|
|
||||||
|
// Run the merge — it should clean up the stale workspace first.
|
||||||
|
let result = run_squash_merge(repo, "feature/story-stale_test", "stale_test").unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
result.success,
|
||||||
|
"merge should succeed after cleaning up stale workspace: {}",
|
||||||
|
result.output
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!stale_ws.exists(),
|
||||||
|
"stale merge workspace should be cleaned up"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn squash_merge_runs_component_setup_from_project_toml() {
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
init_git_repo(repo);
|
||||||
|
|
||||||
|
// Add a .huskies/project.toml with a component whose setup writes a
|
||||||
|
// sentinel file so we can confirm the command ran.
|
||||||
|
let sk_dir = repo.join(".huskies");
|
||||||
|
fs::create_dir_all(&sk_dir).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk_dir.join("project.toml"),
|
||||||
|
"[[component]]\nname = \"sentinel\"\npath = \".\"\nsetup = [\"touch setup_ran.txt\"]\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "."])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "add project.toml with component setup"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Create feature branch with a change.
|
||||||
|
Command::new("git")
|
||||||
|
.args(["checkout", "-b", "feature/story-216_setup_test"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
fs::write(repo.join("feature.txt"), "change").unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "."])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "feature work"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Switch back to master.
|
||||||
|
Command::new("git")
|
||||||
|
.args(["checkout", "master"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = run_squash_merge(repo, "feature/story-216_setup_test", "216_setup_test").unwrap();
|
||||||
|
|
||||||
|
// The output must mention component setup, proving the new code path ran.
|
||||||
|
assert!(
|
||||||
|
result.output.contains("component setup"),
|
||||||
|
"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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn squash_merge_succeeds_without_components_in_project_toml() {
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
init_git_repo(repo);
|
||||||
|
|
||||||
|
// No .huskies/project.toml — no component setup.
|
||||||
|
fs::write(repo.join("file.txt"), "initial").unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "."])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "initial commit"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
Command::new("git")
|
||||||
|
.args(["checkout", "-b", "feature/story-216_no_components"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
fs::write(repo.join("change.txt"), "change").unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "."])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "feature"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
Command::new("git")
|
||||||
|
.args(["checkout", "master"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result =
|
||||||
|
run_squash_merge(repo, "feature/story-216_no_components", "216_no_components").unwrap();
|
||||||
|
|
||||||
|
// No pnpm or frontend references should appear in the output.
|
||||||
|
assert!(
|
||||||
|
!result.output.contains("pnpm"),
|
||||||
|
"output must not mention pnpm, got:\n{}",
|
||||||
|
result.output
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!result.output.contains("frontend/"),
|
||||||
|
"output must not mention frontend/, got:\n{}",
|
||||||
|
result.output
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
use super::*;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn init_git_repo(repo: &std::path::Path) {
|
||||||
|
Command::new("git")
|
||||||
|
.args(["init"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["config", "user.email", "test@test.com"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["config", "user.name", "Test"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "--allow-empty", "-m", "init"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn squash_merge_uses_merge_queue_no_conflict_markers_on_master() {
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
init_git_repo(repo);
|
||||||
|
|
||||||
|
// Create a file that will be conflicted on master.
|
||||||
|
fs::write(repo.join("shared.txt"), "line 1\nline 2\n").unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "."])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "initial shared file"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Create a feature branch that modifies the file.
|
||||||
|
Command::new("git")
|
||||||
|
.args(["checkout", "-b", "feature/story-conflict_test"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
repo.join("shared.txt"),
|
||||||
|
"line 1\nline 2\nfeature addition\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "."])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "feature: add line"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Switch to master and make a conflicting change.
|
||||||
|
Command::new("git")
|
||||||
|
.args(["checkout", "master"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
fs::write(repo.join("shared.txt"), "line 1\nline 2\nmaster addition\n").unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "."])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "master: add line"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Run the squash merge.
|
||||||
|
let result = run_squash_merge(repo, "feature/story-conflict_test", "conflict_test").unwrap();
|
||||||
|
|
||||||
|
// Master should NEVER contain conflict markers, regardless of outcome.
|
||||||
|
let master_content = fs::read_to_string(repo.join("shared.txt")).unwrap();
|
||||||
|
assert!(
|
||||||
|
!master_content.contains("<<<<<<<"),
|
||||||
|
"master must never contain conflict markers, got:\n{master_content}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!master_content.contains(">>>>>>>"),
|
||||||
|
"master must never contain conflict markers, got:\n{master_content}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The merge should have had conflicts.
|
||||||
|
assert!(result.had_conflicts, "should detect conflicts");
|
||||||
|
|
||||||
|
// Conflicts should have been auto-resolved (both are simple additions).
|
||||||
|
if result.conflicts_resolved {
|
||||||
|
assert!(result.success, "auto-resolved merge should succeed");
|
||||||
|
assert!(
|
||||||
|
master_content.contains("master addition"),
|
||||||
|
"master side should be present"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
master_content.contains("feature addition"),
|
||||||
|
"feature side should be present"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no leftover merge-queue branch.
|
||||||
|
let branches = Command::new("git")
|
||||||
|
.args(["branch", "--list", "merge-queue/*"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let branch_list = String::from_utf8_lossy(&branches.stdout);
|
||||||
|
assert!(
|
||||||
|
branch_list.trim().is_empty(),
|
||||||
|
"merge-queue branch should be cleaned up, got: {branch_list}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify no leftover merge workspace directory.
|
||||||
|
assert!(
|
||||||
|
!repo.join(".huskies/merge_workspace").exists(),
|
||||||
|
"merge workspace should be cleaned up"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn squash_merge_clean_merge_succeeds() {
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
init_git_repo(repo);
|
||||||
|
|
||||||
|
// Create feature branch with a new file.
|
||||||
|
Command::new("git")
|
||||||
|
.args(["checkout", "-b", "feature/story-clean_test"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
fs::write(repo.join("new_file.txt"), "new content").unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "."])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "add new file"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Switch back to master.
|
||||||
|
Command::new("git")
|
||||||
|
.args(["checkout", "master"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = run_squash_merge(repo, "feature/story-clean_test", "clean_test").unwrap();
|
||||||
|
|
||||||
|
assert!(result.success, "clean merge should succeed");
|
||||||
|
assert!(
|
||||||
|
!result.had_conflicts,
|
||||||
|
"clean merge should have no conflicts"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!result.conflicts_resolved,
|
||||||
|
"no conflicts means nothing to resolve"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
repo.join("new_file.txt").exists(),
|
||||||
|
"merged file should exist on master"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn squash_merge_nonexistent_branch_fails() {
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
init_git_repo(repo);
|
||||||
|
|
||||||
|
let result = run_squash_merge(repo, "feature/story-nope", "nope").unwrap();
|
||||||
|
|
||||||
|
assert!(!result.success, "merge of nonexistent branch should fail");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn squash_merge_succeeds_when_master_diverges() {
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
init_git_repo(repo);
|
||||||
|
|
||||||
|
// Create an initial file on master.
|
||||||
|
fs::write(repo.join("base.txt"), "base content\n").unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "."])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "initial"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Create a feature branch with a new file (clean merge, no conflicts).
|
||||||
|
Command::new("git")
|
||||||
|
.args(["checkout", "-b", "feature/story-diverge_test"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
fs::write(repo.join("feature.txt"), "feature content\n").unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "."])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "feature: add file"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Switch back to master and simulate a filesystem watcher commit
|
||||||
|
// (e.g. a pipeline file move) that advances master beyond the point
|
||||||
|
// where the merge-queue branch will be created.
|
||||||
|
Command::new("git")
|
||||||
|
.args(["checkout", "master"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let sk_dir = repo.join(".huskies/work/4_merge");
|
||||||
|
fs::create_dir_all(&sk_dir).unwrap();
|
||||||
|
fs::write(sk_dir.join("diverge_test.md"), "---\nname: test\n---\n").unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "."])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "huskies: queue diverge_test for merge"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Run the squash merge. With the old fast-forward approach, this
|
||||||
|
// would fail because master diverged. With cherry-pick, it succeeds.
|
||||||
|
let result = run_squash_merge(repo, "feature/story-diverge_test", "diverge_test").unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
result.success,
|
||||||
|
"squash merge should succeed despite diverged master: {}",
|
||||||
|
result.output
|
||||||
|
);
|
||||||
|
assert!(!result.had_conflicts, "no conflicts expected");
|
||||||
|
|
||||||
|
// Verify the feature file landed on master.
|
||||||
|
assert!(
|
||||||
|
repo.join("feature.txt").exists(),
|
||||||
|
"feature file should be on master after cherry-pick"
|
||||||
|
);
|
||||||
|
let feature_content = fs::read_to_string(repo.join("feature.txt")).unwrap();
|
||||||
|
assert_eq!(feature_content, "feature content\n");
|
||||||
|
|
||||||
|
// Verify the watcher commit's file is still present.
|
||||||
|
assert!(
|
||||||
|
sk_dir.join("diverge_test.md").exists(),
|
||||||
|
"watcher-committed file should still be on master"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify cleanup: no merge-queue branch, no merge workspace.
|
||||||
|
let branches = Command::new("git")
|
||||||
|
.args(["branch", "--list", "merge-queue/*"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let branch_list = String::from_utf8_lossy(&branches.stdout);
|
||||||
|
assert!(
|
||||||
|
branch_list.trim().is_empty(),
|
||||||
|
"merge-queue branch should be cleaned up, got: {branch_list}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!repo.join(".huskies/merge_workspace").exists(),
|
||||||
|
"merge workspace should be cleaned up"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn squash_merge_empty_diff_fails() {
|
||||||
|
use std::fs;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
init_git_repo(repo);
|
||||||
|
|
||||||
|
// Create a file on master.
|
||||||
|
fs::write(repo.join("code.txt"), "content\n").unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["add", "."])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["commit", "-m", "add code"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Create a feature branch with NO additional changes (empty diff).
|
||||||
|
Command::new("git")
|
||||||
|
.args(["checkout", "-b", "feature/story-empty_test"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
Command::new("git")
|
||||||
|
.args(["checkout", "master"])
|
||||||
|
.current_dir(repo)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = run_squash_merge(repo, "feature/story-empty_test", "empty_test");
|
||||||
|
|
||||||
|
// Bug 226 / 675: a zero-commit branch must not be treated as success.
|
||||||
|
// The pre-flight check (bug 675) returns Err for zero commits ahead;
|
||||||
|
// the older code path returned Ok(SquashMergeResult { success: false }).
|
||||||
|
// Either form is a failure — just not success.
|
||||||
|
match result {
|
||||||
|
Ok(r) => assert!(
|
||||||
|
!r.success,
|
||||||
|
"empty diff merge must fail, not silently succeed: {}",
|
||||||
|
r.output
|
||||||
|
),
|
||||||
|
Err(e) => assert!(
|
||||||
|
e.contains("no commits to merge") || e.contains("nothing to commit"),
|
||||||
|
"unexpected error: {e}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup should still happen (no workspace was created for the Err path).
|
||||||
|
assert!(
|
||||||
|
!repo.join(".huskies/merge_workspace").exists(),
|
||||||
|
"merge workspace should be cleaned up"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user