refactor: split agents/merge.rs into mod.rs + squash.rs + conflicts.rs
The 1772-line merge.rs is split into: - conflicts.rs: try_resolve_conflicts + resolve_simple_conflicts + tests (351 lines) - squash.rs: run_squash_merge orchestrator + cleanup + run_merge_quality_gates + tests (1306 lines) - mod.rs: doc, types (MergeJobStatus, MergeJob, MergeReport, SquashMergeResult), re-exports (52 lines) Tests stay co-located. No behaviour change. All 20 merge tests pass; full suite green (2635 tests with --test-threads=1).
This commit is contained in:
@@ -0,0 +1,351 @@
|
|||||||
|
//! Merge conflict resolution helpers.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
pub(super) fn try_resolve_conflicts(worktree: &Path) -> Result<(bool, String), String> {
|
||||||
|
let mut log = String::new();
|
||||||
|
|
||||||
|
// List conflicted files.
|
||||||
|
let ls = Command::new("git")
|
||||||
|
.args(["diff", "--name-only", "--diff-filter=U"])
|
||||||
|
.current_dir(worktree)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to list conflicted files: {e}"))?;
|
||||||
|
|
||||||
|
let file_list = String::from_utf8_lossy(&ls.stdout);
|
||||||
|
let conflicted_files: Vec<&str> = file_list.lines().filter(|l| !l.is_empty()).collect();
|
||||||
|
|
||||||
|
if conflicted_files.is_empty() {
|
||||||
|
log.push_str("No conflicted files found (conflict may be index-only).\n");
|
||||||
|
return Ok((false, log));
|
||||||
|
}
|
||||||
|
|
||||||
|
log.push_str(&format!("Conflicted files ({}):\n", conflicted_files.len()));
|
||||||
|
for f in &conflicted_files {
|
||||||
|
log.push_str(&format!(" - {f}\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// First pass: check that all files can be resolved before touching any.
|
||||||
|
let mut resolutions: Vec<(&str, String)> = Vec::new();
|
||||||
|
for file in &conflicted_files {
|
||||||
|
let file_path = worktree.join(file);
|
||||||
|
let content = std::fs::read_to_string(&file_path)
|
||||||
|
.map_err(|e| format!("Failed to read conflicted file '{file}': {e}"))?;
|
||||||
|
|
||||||
|
match resolve_simple_conflicts(&content) {
|
||||||
|
Some(resolved) => {
|
||||||
|
log.push_str(&format!(" [auto-resolve] {file}\n"));
|
||||||
|
resolutions.push((file, resolved));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
log.push_str(&format!(" [COMPLEX — cannot auto-resolve] {file}\n"));
|
||||||
|
return Ok((false, log));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: write resolved content and stage.
|
||||||
|
for (file, resolved) in &resolutions {
|
||||||
|
let file_path = worktree.join(file);
|
||||||
|
std::fs::write(&file_path, resolved)
|
||||||
|
.map_err(|e| format!("Failed to write resolved file '{file}': {e}"))?;
|
||||||
|
|
||||||
|
let add = Command::new("git")
|
||||||
|
.args(["add", file])
|
||||||
|
.current_dir(worktree)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to stage resolved file '{file}': {e}"))?;
|
||||||
|
if !add.status.success() {
|
||||||
|
return Err(format!(
|
||||||
|
"git add failed for '{file}': {}",
|
||||||
|
String::from_utf8_lossy(&add.stderr)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((true, log))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_simple_conflicts(content: &str) -> Option<String> {
|
||||||
|
// Quick check: if there are no conflict markers at all, nothing to do.
|
||||||
|
if !content.contains("<<<<<<<") {
|
||||||
|
return Some(content.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result = String::new();
|
||||||
|
let mut lines = content.lines().peekable();
|
||||||
|
|
||||||
|
while let Some(line) = lines.next() {
|
||||||
|
if line.starts_with("<<<<<<<") {
|
||||||
|
// Collect the "ours" side (between <<<<<<< and =======).
|
||||||
|
let mut ours = Vec::new();
|
||||||
|
let mut found_separator = false;
|
||||||
|
for next_line in lines.by_ref() {
|
||||||
|
if next_line.starts_with("=======") {
|
||||||
|
found_separator = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ours.push(next_line);
|
||||||
|
}
|
||||||
|
if !found_separator {
|
||||||
|
return None; // Malformed conflict block.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect the "theirs" side (between ======= and >>>>>>>).
|
||||||
|
let mut theirs = Vec::new();
|
||||||
|
let mut found_end = false;
|
||||||
|
for next_line in lines.by_ref() {
|
||||||
|
if next_line.starts_with(">>>>>>>") {
|
||||||
|
found_end = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
theirs.push(next_line);
|
||||||
|
}
|
||||||
|
if !found_end {
|
||||||
|
return None; // Malformed conflict block.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both sides must be non-empty additions to be considered simple.
|
||||||
|
// If either side is empty, it means one side deleted something — complex.
|
||||||
|
if ours.is_empty() && theirs.is_empty() {
|
||||||
|
// Both empty — nothing to add, skip.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept both: ours first, then theirs.
|
||||||
|
for l in &ours {
|
||||||
|
result.push_str(l);
|
||||||
|
result.push('\n');
|
||||||
|
}
|
||||||
|
for l in &theirs {
|
||||||
|
result.push_str(l);
|
||||||
|
result.push('\n');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.push_str(line);
|
||||||
|
result.push('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve trailing newline consistency: if original ended without
|
||||||
|
// newline, strip the trailing one we added.
|
||||||
|
if !content.ends_with('\n') && result.ends_with('\n') {
|
||||||
|
result.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_simple_conflicts_no_markers() {
|
||||||
|
let input = "line 1\nline 2\nline 3\n";
|
||||||
|
let result = resolve_simple_conflicts(input);
|
||||||
|
assert_eq!(result, Some(input.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_simple_conflicts_additive() {
|
||||||
|
let input = "\
|
||||||
|
before
|
||||||
|
<<<<<<< HEAD
|
||||||
|
ours line 1
|
||||||
|
ours line 2
|
||||||
|
=======
|
||||||
|
theirs line 1
|
||||||
|
theirs line 2
|
||||||
|
>>>>>>> feature
|
||||||
|
after
|
||||||
|
";
|
||||||
|
let result = resolve_simple_conflicts(input).unwrap();
|
||||||
|
assert!(
|
||||||
|
!result.contains("<<<<<<<"),
|
||||||
|
"should not contain conflict markers"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!result.contains(">>>>>>>"),
|
||||||
|
"should not contain conflict markers"
|
||||||
|
);
|
||||||
|
assert!(result.contains("ours line 1"));
|
||||||
|
assert!(result.contains("ours line 2"));
|
||||||
|
assert!(result.contains("theirs line 1"));
|
||||||
|
assert!(result.contains("theirs line 2"));
|
||||||
|
assert!(result.contains("before"));
|
||||||
|
assert!(result.contains("after"));
|
||||||
|
// Ours comes before theirs
|
||||||
|
let ours_pos = result.find("ours line 1").unwrap();
|
||||||
|
let theirs_pos = result.find("theirs line 1").unwrap();
|
||||||
|
assert!(ours_pos < theirs_pos, "ours should come before theirs");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_simple_conflicts_multiple_blocks() {
|
||||||
|
let input = "\
|
||||||
|
header
|
||||||
|
<<<<<<< HEAD
|
||||||
|
ours block 1
|
||||||
|
=======
|
||||||
|
theirs block 1
|
||||||
|
>>>>>>> feature
|
||||||
|
middle
|
||||||
|
<<<<<<< HEAD
|
||||||
|
ours block 2
|
||||||
|
=======
|
||||||
|
theirs block 2
|
||||||
|
>>>>>>> feature
|
||||||
|
footer
|
||||||
|
";
|
||||||
|
let result = resolve_simple_conflicts(input).unwrap();
|
||||||
|
assert!(!result.contains("<<<<<<<"));
|
||||||
|
assert!(result.contains("ours block 1"));
|
||||||
|
assert!(result.contains("theirs block 1"));
|
||||||
|
assert!(result.contains("ours block 2"));
|
||||||
|
assert!(result.contains("theirs block 2"));
|
||||||
|
assert!(result.contains("header"));
|
||||||
|
assert!(result.contains("middle"));
|
||||||
|
assert!(result.contains("footer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_simple_conflicts_malformed_no_separator() {
|
||||||
|
let input = "\
|
||||||
|
<<<<<<< HEAD
|
||||||
|
ours
|
||||||
|
>>>>>>> feature
|
||||||
|
";
|
||||||
|
let result = resolve_simple_conflicts(input);
|
||||||
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"malformed conflict (no separator) should return None"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_simple_conflicts_malformed_no_end() {
|
||||||
|
let input = "\
|
||||||
|
<<<<<<< HEAD
|
||||||
|
ours
|
||||||
|
=======
|
||||||
|
theirs
|
||||||
|
";
|
||||||
|
let result = resolve_simple_conflicts(input);
|
||||||
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"malformed conflict (no end marker) should return None"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_simple_conflicts_preserves_no_trailing_newline() {
|
||||||
|
let input = "before\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\nafter";
|
||||||
|
let result = resolve_simple_conflicts(input).unwrap();
|
||||||
|
assert!(
|
||||||
|
!result.ends_with('\n'),
|
||||||
|
"should not add trailing newline if original lacks one"
|
||||||
|
);
|
||||||
|
assert!(result.ends_with("after"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_simple_conflicts_real_markers_additive_both_sides() {
|
||||||
|
// The most common real-world case: both branches add different content
|
||||||
|
// (e.g. different functions) to the same region of a file.
|
||||||
|
let input = "// shared code\n\
|
||||||
|
<<<<<<< HEAD\n\
|
||||||
|
fn master_fn() { println!(\"from master\"); }\n\
|
||||||
|
=======\n\
|
||||||
|
fn feature_fn() { println!(\"from feature\"); }\n\
|
||||||
|
>>>>>>> feature/story-42\n\
|
||||||
|
// end\n";
|
||||||
|
let result = resolve_simple_conflicts(input).unwrap();
|
||||||
|
assert!(!result.contains("<<<<<<<"), "no conflict markers in output");
|
||||||
|
assert!(!result.contains(">>>>>>>"), "no conflict markers in output");
|
||||||
|
assert!(!result.contains("======="), "no separator in output");
|
||||||
|
assert!(
|
||||||
|
result.contains("fn master_fn()"),
|
||||||
|
"master (ours) side must be preserved"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.contains("fn feature_fn()"),
|
||||||
|
"feature (theirs) side must be preserved"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.contains("// shared code"),
|
||||||
|
"context before conflict preserved"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.contains("// end"),
|
||||||
|
"context after conflict preserved"
|
||||||
|
);
|
||||||
|
// ours (master) must appear before theirs (feature)
|
||||||
|
assert!(
|
||||||
|
result.find("master_fn").unwrap() < result.find("feature_fn").unwrap(),
|
||||||
|
"master side must appear before feature side"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_simple_conflicts_real_markers_multiple_conflict_blocks() {
|
||||||
|
// Two separate conflict blocks in the same file — as happens when two
|
||||||
|
// feature branches both add imports AND test suites to the same file.
|
||||||
|
let input = "// imports\n\
|
||||||
|
<<<<<<< HEAD\n\
|
||||||
|
import { A } from './a';\n\
|
||||||
|
=======\n\
|
||||||
|
import { B } from './b';\n\
|
||||||
|
>>>>>>> feature/story-43\n\
|
||||||
|
// implementation\n\
|
||||||
|
<<<<<<< HEAD\n\
|
||||||
|
export function masterImpl() {}\n\
|
||||||
|
=======\n\
|
||||||
|
export function featureImpl() {}\n\
|
||||||
|
>>>>>>> feature/story-43\n";
|
||||||
|
let result = resolve_simple_conflicts(input).unwrap();
|
||||||
|
assert!(!result.contains("<<<<<<<"), "no conflict markers in output");
|
||||||
|
assert!(
|
||||||
|
result.contains("import { A }"),
|
||||||
|
"first block ours preserved"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.contains("import { B }"),
|
||||||
|
"first block theirs preserved"
|
||||||
|
);
|
||||||
|
assert!(result.contains("masterImpl"), "second block ours preserved");
|
||||||
|
assert!(
|
||||||
|
result.contains("featureImpl"),
|
||||||
|
"second block theirs preserved"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.contains("// imports"),
|
||||||
|
"surrounding context preserved"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.contains("// implementation"),
|
||||||
|
"surrounding context preserved"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_simple_conflicts_real_markers_one_side_empty() {
|
||||||
|
// Ours (master) has no content in the conflicted region; theirs (feature)
|
||||||
|
// adds new content. Resolution: keep theirs.
|
||||||
|
let input = "before\n\
|
||||||
|
<<<<<<< HEAD\n\
|
||||||
|
=======\n\
|
||||||
|
feature_addition\n\
|
||||||
|
>>>>>>> feature/story-44\n\
|
||||||
|
after\n";
|
||||||
|
let result = resolve_simple_conflicts(input).unwrap();
|
||||||
|
assert!(!result.contains("<<<<<<<"), "no conflict markers");
|
||||||
|
assert!(
|
||||||
|
result.contains("feature_addition"),
|
||||||
|
"non-empty side preserved"
|
||||||
|
);
|
||||||
|
assert!(result.contains("before"), "context preserved");
|
||||||
|
assert!(result.contains("after"), "context preserved");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
//! Merge operations — rebases agent work onto master and runs post-merge validation.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
mod conflicts;
|
||||||
|
mod squash;
|
||||||
|
|
||||||
|
pub(crate) use squash::{cleanup_merge_workspace, run_squash_merge};
|
||||||
|
|
||||||
|
/// Status of an async merge job.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub enum MergeJobStatus {
|
||||||
|
Running,
|
||||||
|
Completed(MergeReport),
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracks a background merge job started by `merge_agent_work`.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct MergeJob {
|
||||||
|
pub story_id: String,
|
||||||
|
pub status: MergeJobStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a mergemaster merge operation.
|
||||||
|
#[derive(Debug, Serialize, Clone)]
|
||||||
|
pub struct MergeReport {
|
||||||
|
pub story_id: String,
|
||||||
|
pub success: bool,
|
||||||
|
pub had_conflicts: bool,
|
||||||
|
/// `true` when conflicts were detected but automatically resolved.
|
||||||
|
pub conflicts_resolved: bool,
|
||||||
|
pub conflict_details: Option<String>,
|
||||||
|
pub gates_passed: bool,
|
||||||
|
pub gate_output: String,
|
||||||
|
pub worktree_cleaned_up: bool,
|
||||||
|
pub story_archived: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of a squash-merge operation.
|
||||||
|
pub(crate) struct SquashMergeResult {
|
||||||
|
pub(crate) success: bool,
|
||||||
|
pub(crate) had_conflicts: bool,
|
||||||
|
/// `true` when conflicts were detected but automatically resolved.
|
||||||
|
pub(crate) conflicts_resolved: bool,
|
||||||
|
pub(crate) conflict_details: Option<String>,
|
||||||
|
pub(crate) output: String,
|
||||||
|
/// Whether quality gates ran and passed. `false` when `success` is `false`
|
||||||
|
/// due to a gate failure; callers can use this to distinguish gate failures
|
||||||
|
/// from merge/commit/FF failures in the `MergeReport`.
|
||||||
|
pub(crate) gates_passed: bool,
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
//! Merge operations — rebases agent work onto master and runs post-merge validation.
|
//! Squash-merge orchestration: rebase agent work onto master and run post-merge gates.
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use serde::Serialize;
|
|
||||||
|
|
||||||
use crate::config::ProjectConfig;
|
use crate::config::ProjectConfig;
|
||||||
|
use super::conflicts::try_resolve_conflicts;
|
||||||
use super::gates::run_project_tests;
|
use super::super::gates::run_project_tests;
|
||||||
|
use super::{MergeReport, SquashMergeResult};
|
||||||
|
|
||||||
/// Global lock ensuring only one squash-merge runs at a time.
|
/// Global lock ensuring only one squash-merge runs at a time.
|
||||||
///
|
///
|
||||||
@@ -17,66 +17,6 @@ use super::gates::run_project_tests;
|
|||||||
/// causing `git cherry-pick merge-queue/…` to fail with "bad revision".
|
/// causing `git cherry-pick merge-queue/…` to fail with "bad revision".
|
||||||
static MERGE_LOCK: Mutex<()> = Mutex::new(());
|
static MERGE_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
/// Status of an async merge job.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub enum MergeJobStatus {
|
|
||||||
Running,
|
|
||||||
Completed(MergeReport),
|
|
||||||
Failed(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tracks a background merge job started by `merge_agent_work`.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct MergeJob {
|
|
||||||
pub story_id: String,
|
|
||||||
pub status: MergeJobStatus,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Result of a mergemaster merge operation.
|
|
||||||
#[derive(Debug, Serialize, Clone)]
|
|
||||||
pub struct MergeReport {
|
|
||||||
pub story_id: String,
|
|
||||||
pub success: bool,
|
|
||||||
pub had_conflicts: bool,
|
|
||||||
/// `true` when conflicts were detected but automatically resolved.
|
|
||||||
pub conflicts_resolved: bool,
|
|
||||||
pub conflict_details: Option<String>,
|
|
||||||
pub gates_passed: bool,
|
|
||||||
pub gate_output: String,
|
|
||||||
pub worktree_cleaned_up: bool,
|
|
||||||
pub story_archived: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Result of a squash-merge operation.
|
|
||||||
pub(crate) struct SquashMergeResult {
|
|
||||||
pub(crate) success: bool,
|
|
||||||
pub(crate) had_conflicts: bool,
|
|
||||||
/// `true` when conflicts were detected but automatically resolved.
|
|
||||||
pub(crate) conflicts_resolved: bool,
|
|
||||||
pub(crate) conflict_details: Option<String>,
|
|
||||||
pub(crate) output: String,
|
|
||||||
/// Whether quality gates ran and passed. `false` when `success` is `false`
|
|
||||||
/// due to a gate failure; callers can use this to distinguish gate failures
|
|
||||||
/// from merge/commit/FF failures in the `MergeReport`.
|
|
||||||
pub(crate) gates_passed: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Squash-merge a feature branch into the current branch using a temporary
|
|
||||||
/// merge-queue worktree for quality-gate isolation.
|
|
||||||
///
|
|
||||||
/// **Flow:**
|
|
||||||
/// 1. Create a temporary `merge-queue/{story_id}` branch at current HEAD.
|
|
||||||
/// 2. Create a temporary worktree for that branch.
|
|
||||||
/// 3. Run `git merge --squash` in the temporary worktree (not the main worktree).
|
|
||||||
/// 4. If conflicts arise, attempt automatic resolution for simple additive cases.
|
|
||||||
/// 5. If clean (or resolved), commit in the temp worktree.
|
|
||||||
/// 6. Run quality gates **in the merge worktree** before touching master.
|
|
||||||
/// 7. If gates pass: cherry-pick the squash commit onto master.
|
|
||||||
/// 8. Clean up the temporary worktree and branch.
|
|
||||||
///
|
|
||||||
/// Step 7 uses `git cherry-pick` instead of `git merge --ff-only` so that
|
|
||||||
/// concurrent filesystem-watcher commits on master (pipeline file moves) do
|
|
||||||
/// not block the merge.
|
|
||||||
pub(crate) fn run_squash_merge(
|
pub(crate) fn run_squash_merge(
|
||||||
project_root: &Path,
|
project_root: &Path,
|
||||||
branch: &str,
|
branch: &str,
|
||||||
@@ -473,8 +413,6 @@ pub(crate) fn run_squash_merge(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove the temporary merge worktree and branch. Best-effort — errors are
|
|
||||||
/// silently ignored because this is cleanup code.
|
|
||||||
pub(crate) fn cleanup_merge_workspace(
|
pub(crate) fn cleanup_merge_workspace(
|
||||||
project_root: &Path,
|
project_root: &Path,
|
||||||
merge_wt_path: &Path,
|
merge_wt_path: &Path,
|
||||||
@@ -497,165 +435,6 @@ pub(crate) fn cleanup_merge_workspace(
|
|||||||
.output();
|
.output();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attempt to automatically resolve merge conflicts in the given worktree.
|
|
||||||
///
|
|
||||||
/// Finds all conflicted files and tries [`resolve_simple_conflicts`] on each.
|
|
||||||
/// If **all** conflicts can be resolved, stages the resolved files and returns
|
|
||||||
/// `Ok((true, log))`. If any file has a complex conflict that cannot be
|
|
||||||
/// auto-resolved, returns `Ok((false, log))` without staging anything.
|
|
||||||
fn try_resolve_conflicts(worktree: &Path) -> Result<(bool, String), String> {
|
|
||||||
let mut log = String::new();
|
|
||||||
|
|
||||||
// List conflicted files.
|
|
||||||
let ls = Command::new("git")
|
|
||||||
.args(["diff", "--name-only", "--diff-filter=U"])
|
|
||||||
.current_dir(worktree)
|
|
||||||
.output()
|
|
||||||
.map_err(|e| format!("Failed to list conflicted files: {e}"))?;
|
|
||||||
|
|
||||||
let file_list = String::from_utf8_lossy(&ls.stdout);
|
|
||||||
let conflicted_files: Vec<&str> = file_list.lines().filter(|l| !l.is_empty()).collect();
|
|
||||||
|
|
||||||
if conflicted_files.is_empty() {
|
|
||||||
log.push_str("No conflicted files found (conflict may be index-only).\n");
|
|
||||||
return Ok((false, log));
|
|
||||||
}
|
|
||||||
|
|
||||||
log.push_str(&format!("Conflicted files ({}):\n", conflicted_files.len()));
|
|
||||||
for f in &conflicted_files {
|
|
||||||
log.push_str(&format!(" - {f}\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// First pass: check that all files can be resolved before touching any.
|
|
||||||
let mut resolutions: Vec<(&str, String)> = Vec::new();
|
|
||||||
for file in &conflicted_files {
|
|
||||||
let file_path = worktree.join(file);
|
|
||||||
let content = std::fs::read_to_string(&file_path)
|
|
||||||
.map_err(|e| format!("Failed to read conflicted file '{file}': {e}"))?;
|
|
||||||
|
|
||||||
match resolve_simple_conflicts(&content) {
|
|
||||||
Some(resolved) => {
|
|
||||||
log.push_str(&format!(" [auto-resolve] {file}\n"));
|
|
||||||
resolutions.push((file, resolved));
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
log.push_str(&format!(" [COMPLEX — cannot auto-resolve] {file}\n"));
|
|
||||||
return Ok((false, log));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second pass: write resolved content and stage.
|
|
||||||
for (file, resolved) in &resolutions {
|
|
||||||
let file_path = worktree.join(file);
|
|
||||||
std::fs::write(&file_path, resolved)
|
|
||||||
.map_err(|e| format!("Failed to write resolved file '{file}': {e}"))?;
|
|
||||||
|
|
||||||
let add = Command::new("git")
|
|
||||||
.args(["add", file])
|
|
||||||
.current_dir(worktree)
|
|
||||||
.output()
|
|
||||||
.map_err(|e| format!("Failed to stage resolved file '{file}': {e}"))?;
|
|
||||||
if !add.status.success() {
|
|
||||||
return Err(format!(
|
|
||||||
"git add failed for '{file}': {}",
|
|
||||||
String::from_utf8_lossy(&add.stderr)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((true, log))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Try to resolve simple additive merge conflicts in a file's content.
|
|
||||||
///
|
|
||||||
/// A conflict is considered "simple additive" when both sides add new content
|
|
||||||
/// at the same location without modifying existing lines. In that case we keep
|
|
||||||
/// both additions (ours first, then theirs).
|
|
||||||
///
|
|
||||||
/// Returns `Some(resolved)` if all conflict blocks in the file are simple, or
|
|
||||||
/// `None` if any block is too complex to auto-resolve.
|
|
||||||
fn resolve_simple_conflicts(content: &str) -> Option<String> {
|
|
||||||
// Quick check: if there are no conflict markers at all, nothing to do.
|
|
||||||
if !content.contains("<<<<<<<") {
|
|
||||||
return Some(content.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut result = String::new();
|
|
||||||
let mut lines = content.lines().peekable();
|
|
||||||
|
|
||||||
while let Some(line) = lines.next() {
|
|
||||||
if line.starts_with("<<<<<<<") {
|
|
||||||
// Collect the "ours" side (between <<<<<<< and =======).
|
|
||||||
let mut ours = Vec::new();
|
|
||||||
let mut found_separator = false;
|
|
||||||
for next_line in lines.by_ref() {
|
|
||||||
if next_line.starts_with("=======") {
|
|
||||||
found_separator = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
ours.push(next_line);
|
|
||||||
}
|
|
||||||
if !found_separator {
|
|
||||||
return None; // Malformed conflict block.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect the "theirs" side (between ======= and >>>>>>>).
|
|
||||||
let mut theirs = Vec::new();
|
|
||||||
let mut found_end = false;
|
|
||||||
for next_line in lines.by_ref() {
|
|
||||||
if next_line.starts_with(">>>>>>>") {
|
|
||||||
found_end = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
theirs.push(next_line);
|
|
||||||
}
|
|
||||||
if !found_end {
|
|
||||||
return None; // Malformed conflict block.
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both sides must be non-empty additions to be considered simple.
|
|
||||||
// If either side is empty, it means one side deleted something — complex.
|
|
||||||
if ours.is_empty() && theirs.is_empty() {
|
|
||||||
// Both empty — nothing to add, skip.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Accept both: ours first, then theirs.
|
|
||||||
for l in &ours {
|
|
||||||
result.push_str(l);
|
|
||||||
result.push('\n');
|
|
||||||
}
|
|
||||||
for l in &theirs {
|
|
||||||
result.push_str(l);
|
|
||||||
result.push('\n');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
result.push_str(line);
|
|
||||||
result.push('\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preserve trailing newline consistency: if original ended without
|
|
||||||
// newline, strip the trailing one we added.
|
|
||||||
if !content.ends_with('\n') && result.ends_with('\n') {
|
|
||||||
result.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run quality gates in the project root after a successful merge.
|
|
||||||
///
|
|
||||||
/// Runs quality gates in the merge workspace.
|
|
||||||
///
|
|
||||||
/// 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)`.
|
|
||||||
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;
|
||||||
@@ -733,223 +512,6 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── resolve_simple_conflicts unit tests ──────────────────────────────────
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_simple_conflicts_no_markers() {
|
|
||||||
let input = "line 1\nline 2\nline 3\n";
|
|
||||||
let result = resolve_simple_conflicts(input);
|
|
||||||
assert_eq!(result, Some(input.to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_simple_conflicts_additive() {
|
|
||||||
let input = "\
|
|
||||||
before
|
|
||||||
<<<<<<< HEAD
|
|
||||||
ours line 1
|
|
||||||
ours line 2
|
|
||||||
=======
|
|
||||||
theirs line 1
|
|
||||||
theirs line 2
|
|
||||||
>>>>>>> feature
|
|
||||||
after
|
|
||||||
";
|
|
||||||
let result = resolve_simple_conflicts(input).unwrap();
|
|
||||||
assert!(
|
|
||||||
!result.contains("<<<<<<<"),
|
|
||||||
"should not contain conflict markers"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!result.contains(">>>>>>>"),
|
|
||||||
"should not contain conflict markers"
|
|
||||||
);
|
|
||||||
assert!(result.contains("ours line 1"));
|
|
||||||
assert!(result.contains("ours line 2"));
|
|
||||||
assert!(result.contains("theirs line 1"));
|
|
||||||
assert!(result.contains("theirs line 2"));
|
|
||||||
assert!(result.contains("before"));
|
|
||||||
assert!(result.contains("after"));
|
|
||||||
// Ours comes before theirs
|
|
||||||
let ours_pos = result.find("ours line 1").unwrap();
|
|
||||||
let theirs_pos = result.find("theirs line 1").unwrap();
|
|
||||||
assert!(ours_pos < theirs_pos, "ours should come before theirs");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_simple_conflicts_multiple_blocks() {
|
|
||||||
let input = "\
|
|
||||||
header
|
|
||||||
<<<<<<< HEAD
|
|
||||||
ours block 1
|
|
||||||
=======
|
|
||||||
theirs block 1
|
|
||||||
>>>>>>> feature
|
|
||||||
middle
|
|
||||||
<<<<<<< HEAD
|
|
||||||
ours block 2
|
|
||||||
=======
|
|
||||||
theirs block 2
|
|
||||||
>>>>>>> feature
|
|
||||||
footer
|
|
||||||
";
|
|
||||||
let result = resolve_simple_conflicts(input).unwrap();
|
|
||||||
assert!(!result.contains("<<<<<<<"));
|
|
||||||
assert!(result.contains("ours block 1"));
|
|
||||||
assert!(result.contains("theirs block 1"));
|
|
||||||
assert!(result.contains("ours block 2"));
|
|
||||||
assert!(result.contains("theirs block 2"));
|
|
||||||
assert!(result.contains("header"));
|
|
||||||
assert!(result.contains("middle"));
|
|
||||||
assert!(result.contains("footer"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_simple_conflicts_malformed_no_separator() {
|
|
||||||
let input = "\
|
|
||||||
<<<<<<< HEAD
|
|
||||||
ours
|
|
||||||
>>>>>>> feature
|
|
||||||
";
|
|
||||||
let result = resolve_simple_conflicts(input);
|
|
||||||
assert!(
|
|
||||||
result.is_none(),
|
|
||||||
"malformed conflict (no separator) should return None"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_simple_conflicts_malformed_no_end() {
|
|
||||||
let input = "\
|
|
||||||
<<<<<<< HEAD
|
|
||||||
ours
|
|
||||||
=======
|
|
||||||
theirs
|
|
||||||
";
|
|
||||||
let result = resolve_simple_conflicts(input);
|
|
||||||
assert!(
|
|
||||||
result.is_none(),
|
|
||||||
"malformed conflict (no end marker) should return None"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_simple_conflicts_preserves_no_trailing_newline() {
|
|
||||||
let input = "before\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\nafter";
|
|
||||||
let result = resolve_simple_conflicts(input).unwrap();
|
|
||||||
assert!(
|
|
||||||
!result.ends_with('\n'),
|
|
||||||
"should not add trailing newline if original lacks one"
|
|
||||||
);
|
|
||||||
assert!(result.ends_with("after"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Additional resolve_simple_conflicts tests (real conflict markers) ────
|
|
||||||
//
|
|
||||||
// AC1: The mergemaster reads both sides of the conflict and produces a
|
|
||||||
// resolved file that preserves changes from both branches.
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_simple_conflicts_real_markers_additive_both_sides() {
|
|
||||||
// The most common real-world case: both branches add different content
|
|
||||||
// (e.g. different functions) to the same region of a file.
|
|
||||||
let input = "// shared code\n\
|
|
||||||
<<<<<<< HEAD\n\
|
|
||||||
fn master_fn() { println!(\"from master\"); }\n\
|
|
||||||
=======\n\
|
|
||||||
fn feature_fn() { println!(\"from feature\"); }\n\
|
|
||||||
>>>>>>> feature/story-42\n\
|
|
||||||
// end\n";
|
|
||||||
let result = resolve_simple_conflicts(input).unwrap();
|
|
||||||
assert!(!result.contains("<<<<<<<"), "no conflict markers in output");
|
|
||||||
assert!(!result.contains(">>>>>>>"), "no conflict markers in output");
|
|
||||||
assert!(!result.contains("======="), "no separator in output");
|
|
||||||
assert!(
|
|
||||||
result.contains("fn master_fn()"),
|
|
||||||
"master (ours) side must be preserved"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
result.contains("fn feature_fn()"),
|
|
||||||
"feature (theirs) side must be preserved"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
result.contains("// shared code"),
|
|
||||||
"context before conflict preserved"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
result.contains("// end"),
|
|
||||||
"context after conflict preserved"
|
|
||||||
);
|
|
||||||
// ours (master) must appear before theirs (feature)
|
|
||||||
assert!(
|
|
||||||
result.find("master_fn").unwrap() < result.find("feature_fn").unwrap(),
|
|
||||||
"master side must appear before feature side"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_simple_conflicts_real_markers_multiple_conflict_blocks() {
|
|
||||||
// Two separate conflict blocks in the same file — as happens when two
|
|
||||||
// feature branches both add imports AND test suites to the same file.
|
|
||||||
let input = "// imports\n\
|
|
||||||
<<<<<<< HEAD\n\
|
|
||||||
import { A } from './a';\n\
|
|
||||||
=======\n\
|
|
||||||
import { B } from './b';\n\
|
|
||||||
>>>>>>> feature/story-43\n\
|
|
||||||
// implementation\n\
|
|
||||||
<<<<<<< HEAD\n\
|
|
||||||
export function masterImpl() {}\n\
|
|
||||||
=======\n\
|
|
||||||
export function featureImpl() {}\n\
|
|
||||||
>>>>>>> feature/story-43\n";
|
|
||||||
let result = resolve_simple_conflicts(input).unwrap();
|
|
||||||
assert!(!result.contains("<<<<<<<"), "no conflict markers in output");
|
|
||||||
assert!(
|
|
||||||
result.contains("import { A }"),
|
|
||||||
"first block ours preserved"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
result.contains("import { B }"),
|
|
||||||
"first block theirs preserved"
|
|
||||||
);
|
|
||||||
assert!(result.contains("masterImpl"), "second block ours preserved");
|
|
||||||
assert!(
|
|
||||||
result.contains("featureImpl"),
|
|
||||||
"second block theirs preserved"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
result.contains("// imports"),
|
|
||||||
"surrounding context preserved"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
result.contains("// implementation"),
|
|
||||||
"surrounding context preserved"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_simple_conflicts_real_markers_one_side_empty() {
|
|
||||||
// Ours (master) has no content in the conflicted region; theirs (feature)
|
|
||||||
// adds new content. Resolution: keep theirs.
|
|
||||||
let input = "before\n\
|
|
||||||
<<<<<<< HEAD\n\
|
|
||||||
=======\n\
|
|
||||||
feature_addition\n\
|
|
||||||
>>>>>>> feature/story-44\n\
|
|
||||||
after\n";
|
|
||||||
let result = resolve_simple_conflicts(input).unwrap();
|
|
||||||
assert!(!result.contains("<<<<<<<"), "no conflict markers");
|
|
||||||
assert!(
|
|
||||||
result.contains("feature_addition"),
|
|
||||||
"non-empty side preserved"
|
|
||||||
);
|
|
||||||
assert!(result.contains("before"), "context preserved");
|
|
||||||
assert!(result.contains("after"), "context preserved");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── merge-queue squash-merge integration tests ──────────────────────────
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn squash_merge_uses_merge_queue_no_conflict_markers_on_master() {
|
async fn squash_merge_uses_merge_queue_no_conflict_markers_on_master() {
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -1126,9 +688,6 @@ after\n";
|
|||||||
assert!(!result.success, "merge of nonexistent branch should fail");
|
assert!(!result.success, "merge of nonexistent branch should fail");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verifies that `run_squash_merge` succeeds even when master has advanced
|
|
||||||
/// with unrelated commits after the merge-queue branch was created (the race
|
|
||||||
/// condition that previously caused fast-forward to fail).
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn squash_merge_succeeds_when_master_diverges() {
|
async fn squash_merge_succeeds_when_master_diverges() {
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -1233,8 +792,6 @@ after\n";
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bug 226: Verifies that `run_squash_merge` returns `success: false` when
|
|
||||||
/// the feature branch has no changes beyond what's already on master (empty diff).
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn squash_merge_empty_diff_fails() {
|
async fn squash_merge_empty_diff_fails() {
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -1285,8 +842,6 @@ after\n";
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bug 226: Verifies that `run_squash_merge` fails when the feature branch
|
|
||||||
/// only contains .huskies/ file moves with no real code changes.
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn squash_merge_md_only_changes_fails() {
|
async fn squash_merge_md_only_changes_fails() {
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -1338,11 +893,6 @@ after\n";
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── AC4: additive multi-branch conflict auto-resolution ────────────────
|
|
||||||
//
|
|
||||||
// Verifies that when two feature branches both add different code to the
|
|
||||||
// same region of a file (the most common conflict pattern in this project),
|
|
||||||
// the mergemaster auto-resolves the conflict and preserves both additions.
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn squash_merge_additive_conflict_both_additions_preserved() {
|
async fn squash_merge_additive_conflict_both_additions_preserved() {
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -1459,10 +1009,6 @@ after\n";
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── AC3: quality gates fail after conflict resolution ─────────────────
|
|
||||||
//
|
|
||||||
// Verifies that when conflicts are auto-resolved but the resulting code
|
|
||||||
// fails quality gates, the merge is reported as failed (not merged to master).
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn squash_merge_conflict_resolved_but_gates_fail_reported_as_failure() {
|
async fn squash_merge_conflict_resolved_but_gates_fail_reported_as_failure() {
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -1575,8 +1121,6 @@ after\n";
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verifies that stale merge_workspace directories from previous failed
|
|
||||||
/// merges are cleaned up before a new merge attempt.
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn squash_merge_cleans_up_stale_workspace() {
|
async fn squash_merge_cleans_up_stale_workspace() {
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -1628,12 +1172,6 @@ after\n";
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── story 216: merge worktree uses project.toml component setup ───────────
|
|
||||||
|
|
||||||
/// When the project has `[[component]]` entries in `.huskies/project.toml`,
|
|
||||||
/// `run_squash_merge` must run their setup commands in the merge worktree
|
|
||||||
/// before quality gates — matching the behaviour of `create_worktree`.
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
#[test]
|
||||||
fn squash_merge_runs_component_setup_from_project_toml() {
|
fn squash_merge_runs_component_setup_from_project_toml() {
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -1705,10 +1243,6 @@ after\n";
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// When there are no `[[component]]` entries in project.toml (or no
|
|
||||||
/// 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)]
|
|
||||||
#[test]
|
#[test]
|
||||||
fn squash_merge_succeeds_without_components_in_project_toml() {
|
fn squash_merge_succeeds_without_components_in_project_toml() {
|
||||||
use std::fs;
|
use std::fs;
|
||||||
Reference in New Issue
Block a user