diff --git a/Cargo.lock b/Cargo.lock index 3912b3f..9c736f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,6 +91,16 @@ dependencies = [ "serde", ] +[[package]] +name = "fsc-merge" +version = "0.1.0" +dependencies = [ + "fsc-core", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "fsc-scope" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 40a8c5f..29cf203 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ sha2 = "0.10" tempfile = "3" fsc-core = { path = "crates/fsc-core" } fsc-scope = { path = "crates/fsc-scope" } +fsc-merge = { path = "crates/fsc-merge" } [workspace.lints.clippy] all = { level = "deny", priority = -1 } diff --git a/crates/fsc-merge/Cargo.toml b/crates/fsc-merge/Cargo.toml new file mode 100644 index 0000000..e6daca5 --- /dev/null +++ b/crates/fsc-merge/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "fsc-merge" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +fsc-core.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[lints] +workspace = true diff --git a/crates/fsc-merge/src/conflict.rs b/crates/fsc-merge/src/conflict.rs new file mode 100644 index 0000000..4a4e2a1 --- /dev/null +++ b/crates/fsc-merge/src/conflict.rs @@ -0,0 +1,106 @@ +//! Git conflict-marker parsing. Behaviour matches `utils/conflictParser.ts` line for line, +//! including that the separator must be exactly `=======` (a CRLF file leaves a trailing `\r`, +//! which the TS version also fails to recognise). + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConflictChunk { + /// Line offset of the `<<<<<<<` line (0-based). + pub start_line: usize, + /// Content from HEAD (the canonical / target side). + pub ours: String, + /// Content from the incoming (agent) side. + pub theirs: String, +} + +fn is_ours(line: &str) -> bool { + marker(line, '<') +} + +fn is_theirs(line: &str) -> bool { + marker(line, '>') +} + +fn is_separator(line: &str) -> bool { + line == "=======" +} + +/// Seven `c` followed by a whitespace character. +fn marker(line: &str, c: char) -> bool { + let mut it = line.chars(); + for _ in 0..7 { + if it.next() != Some(c) { + return false; + } + } + it.next().is_some_and(char::is_whitespace) +} + +/// Parse conflict chunks. Empty when the content has no markers. +pub fn parse_conflict_markers(content: &str) -> Vec { + let lines: Vec<&str> = content.split('\n').collect(); + let mut chunks = vec![]; + let mut i = 0; + while i < lines.len() { + if !is_ours(lines[i]) { + i += 1; + continue; + } + let start_line = i; + let (mut ours, mut theirs) = (vec![], vec![]); + let mut in_ours = true; + i += 1; + while i < lines.len() { + if is_separator(lines[i]) { + in_ours = false; + i += 1; + continue; + } + if is_theirs(lines[i]) { + break; + } + if in_ours { + ours.push(lines[i]); + } else { + theirs.push(lines[i]); + } + i += 1; + } + chunks.push(ConflictChunk { + start_line, + ours: ours.join("\n"), + theirs: theirs.join("\n"), + }); + i += 1; + } + chunks +} + +/// True when the HEAD side has non-whitespace content, i.e. keep-incoming would discard real work. +pub fn has_contentful_canonical(chunk: &ConflictChunk) -> bool { + !chunk.ours.trim().is_empty() +} + +/// Resolve every conflict by keeping the incoming side. +pub fn resolve_keep_incoming(content: &str) -> String { + let lines: Vec<&str> = content.split('\n').collect(); + let mut out: Vec<&str> = vec![]; + let mut i = 0; + while i < lines.len() { + if !is_ours(lines[i]) { + out.push(lines[i]); + i += 1; + continue; + } + i += 1; + while i < lines.len() && !is_separator(lines[i]) { + i += 1; + } + i += 1; + while i < lines.len() && !is_theirs(lines[i]) { + out.push(lines[i]); + i += 1; + } + i += 1; + } + out.join("\n") +} diff --git a/crates/fsc-merge/src/git.rs b/crates/fsc-merge/src/git.rs new file mode 100644 index 0000000..433bcb4 --- /dev/null +++ b/crates/fsc-merge/src/git.rs @@ -0,0 +1,39 @@ +use std::path::Path; +use std::process::{Command, Stdio}; + +#[derive(Debug, Clone)] +pub struct GitOutput { + pub stdout: String, + pub stderr: String, + pub exit_code: i32, +} + +impl GitOutput { + pub fn ok(&self) -> bool { + self.exit_code == 0 + } +} + +/// Run `git ` in `cwd`. Never prompts and never opens an editor. +/// A spawn failure is reported as exit code 1 with the OS error in `stderr`. +pub fn git(args: &[&str], cwd: &Path) -> GitOutput { + let out = Command::new("git") + .args(args) + .current_dir(cwd) + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_EDITOR", "true") + .stdin(Stdio::null()) + .output(); + match out { + Ok(o) => GitOutput { + stdout: String::from_utf8_lossy(&o.stdout).into_owned(), + stderr: String::from_utf8_lossy(&o.stderr).into_owned(), + exit_code: o.status.code().unwrap_or(1), + }, + Err(e) => GitOutput { + stdout: String::new(), + stderr: e.to_string(), + exit_code: 1, + }, + } +} diff --git a/crates/fsc-merge/src/lib.rs b/crates/fsc-merge/src/lib.rs new file mode 100644 index 0000000..bccdd7f --- /dev/null +++ b/crates/fsc-merge/src/lib.rs @@ -0,0 +1,13 @@ +//! Serialized commit merging with tiered conflict resolution +//! (port of `packages/core/src/execution/sequencer.ts` and `utils/conflictParser.ts`). + +pub mod conflict; +pub mod git; +pub mod sequencer; + +pub use conflict::{ + has_contentful_canonical, parse_conflict_markers, resolve_keep_incoming, ConflictChunk, +}; +pub use sequencer::{ + CommitSequencer, MergeHistory, MergeHistoryEntry, MergeInput, SequencerResult, +}; diff --git a/crates/fsc-merge/src/sequencer.rs b/crates/fsc-merge/src/sequencer.rs new file mode 100644 index 0000000..2a13735 --- /dev/null +++ b/crates/fsc-merge/src/sequencer.rs @@ -0,0 +1,310 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::conflict::{has_contentful_canonical, parse_conflict_markers, resolve_keep_incoming}; +use crate::git::git; + +/// One finished task whose commit should land on the target branch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MergeInput { + pub work_unit_id: String, + pub commit_hash: Option, + pub branch: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SequencerResult { + pub task_id: String, + pub success: bool, + pub commit_hash: Option, + pub error: Option, + /// Tier that produced the merge (1 clean, 2 keep-incoming); `None` when nothing was merged. + pub resolved_tier: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MergeHistoryEntry { + pub tier: u8, + pub success: bool, + pub timestamp: u64, +} + +pub type MergeHistory = BTreeMap>; + +const HISTORY_PER_FILE: usize = 20; + +/// Cherry-picks task commits one by one onto the target branch. +/// +/// Tier 1: clean cherry-pick. Tier 2: keep the incoming side, only if the target side of every +/// conflict is blank. Tiers 3 (AI resolve) and 4 (rewrite) are not implemented, as in the TS +/// reference: a conflict that tier 2 cannot resolve aborts the cherry-pick and is reported. +pub struct CommitSequencer { + repo: PathBuf, + target_branch: String, + results: Vec, + history_path: PathBuf, +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +impl CommitSequencer { + pub fn new(repo: impl Into, target_branch: impl Into) -> Self { + let repo = repo.into(); + let history_path = repo.join(".fsc").join("merge-history.json"); + Self { + repo, + target_branch: target_branch.into(), + results: vec![], + history_path, + } + } + + pub fn results(&self) -> &[SequencerResult] { + &self.results + } + + fn push(&mut self, r: SequencerResult) -> SequencerResult { + self.results.push(r.clone()); + r + } + + /// Cherry-pick one task commit onto the target branch. + pub fn apply(&mut self, input: &MergeInput) -> SequencerResult { + let id = input.work_unit_id.clone(); + let fail = |hash: Option, error: String| SequencerResult { + task_id: id.clone(), + success: false, + commit_hash: hash, + error: Some(error), + resolved_tier: None, + }; + + let Some(hash) = input.commit_hash.clone() else { + return self.push(SequencerResult { + task_id: id, + success: true, + commit_hash: None, + error: Some("no changes".into()), + resolved_tier: None, + }); + }; + + match self.pre_merge_check(&hash) { + PreMerge::Ok => {} + PreMerge::AlreadyMerged => { + return self.push(SequencerResult { + task_id: id, + success: true, + commit_hash: Some(hash), + error: Some("already merged".into()), + resolved_tier: None, + }); + } + PreMerge::Blocked(msg) => return self.push(fail(None, msg)), + } + + git(&["checkout", &self.target_branch], &self.repo); + + // Tier 1: clean cherry-pick. + let pick = git(&["cherry-pick", &hash], &self.repo); + if pick.ok() { + let new_hash = self.head(); + return self.push(SequencerResult { + task_id: id, + success: true, + commit_hash: Some(new_hash), + error: None, + resolved_tier: Some(1), + }); + } + + let conflicts = self.conflicted_files(); + if conflicts.is_empty() { + git(&["cherry-pick", "--abort"], &self.repo); + let msg: String = pick.stderr.chars().take(200).collect(); + return self.push(fail( + Some(hash), + format!("cherry-pick failed (not a conflict): {msg}"), + )); + } + + // Tier 2: keep incoming, guarded by "canonical side is blank". + let blockers = self.tier2_keep_incoming(&conflicts); + if blockers.is_empty() { + git(&["add", "-A"], &self.repo); + git(&["cherry-pick", "--continue", "--no-edit"], &self.repo); + let new_hash = self.head(); + self.record_history(&conflicts, 2, true); + return self.push(SequencerResult { + task_id: id, + success: true, + commit_hash: Some(new_hash), + error: None, + resolved_tier: Some(2), + }); + } + + self.record_history(&conflicts, 2, false); + git(&["cherry-pick", "--abort"], &self.repo); + self.push(fail( + Some(hash), + format!("MERGE_CONFLICT (tier2 blocked): {}", blockers.join(", ")), + )) + } + + /// Apply every input that has a commit, ordered by work-unit id. + pub fn apply_all(&mut self, inputs: &[MergeInput]) -> Vec { + let mut sorted: Vec<&MergeInput> = + inputs.iter().filter(|r| r.commit_hash.is_some()).collect(); + sorted.sort_by(|a, b| a.work_unit_id.cmp(&b.work_unit_id)); + sorted.into_iter().map(|r| self.apply(r)).collect() + } + + /// Lowest tier worth trying for `file`: a tier is skipped once it failed at least twice + /// and never succeeded. + pub fn effective_start_tier(&self, file: &str) -> u8 { + let history = self.load_history(); + let Some(entries) = history.get(file).filter(|e| !e.is_empty()) else { + return 1; + }; + for tier in 1..=4u8 { + let of_tier = entries.iter().filter(|e| e.tier == tier); + let successes = of_tier.clone().filter(|e| e.success).count(); + let failures = of_tier.filter(|e| !e.success).count(); + if failures >= 2 && successes == 0 { + continue; + } + return tier; + } + 4 + } + + /// Human-readable recovery steps for a conflict the sequencer could not resolve. + pub fn conflict_guide(&self, task_id: &str, commit_hash: &str, branch: &str) -> String { + [ + String::new(), + format!("MERGE CONFLICT: {task_id}"), + " To resolve:".into(), + format!(" cd {}", self.repo.display()), + format!(" git cherry-pick {commit_hash}"), + " # fix conflicts".into(), + " git add -A && git cherry-pick --continue".into(), + " To skip:".into(), + " git cherry-pick --abort".into(), + " To review:".into(), + format!(" git diff {}...{branch}", self.target_branch), + String::new(), + ] + .join("\n") + } + + fn head(&self) -> String { + git(&["rev-parse", "HEAD"], &self.repo) + .stdout + .trim() + .to_string() + } + + fn conflicted_files(&self) -> Vec { + git(&["diff", "--name-only", "--diff-filter=U"], &self.repo) + .stdout + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(String::from) + .collect() + } + + /// Returns the files that block tier 2 (target side has real content). + fn tier2_keep_incoming(&self, files: &[String]) -> Vec { + let mut blockers = vec![]; + for file in files { + let path = self.repo.join(file); + let Ok(content) = fs::read_to_string(&path) else { + continue; + }; + if parse_conflict_markers(&content) + .iter() + .any(has_contentful_canonical) + { + blockers.push(file.clone()); + continue; + } + // Best effort, as in the reference: a failed write leaves markers, which the + // continue step would then commit; the caller verifies via the returned hash. + let _ = fs::write(&path, resolve_keep_incoming(&content)); + } + blockers + } + + fn pre_merge_check(&self, hash: &str) -> PreMerge { + // `.fsc/` is our own runtime state (merge-history.json); it must not make the tree dirty. + let status = git( + &["status", "--porcelain", "--", ".", ":(exclude).fsc"], + &self.repo, + ); + if !status.stdout.trim().is_empty() { + return PreMerge::Blocked("working tree is dirty".into()); + } + if !git(&["cat-file", "-t", hash], &self.repo).ok() { + return PreMerge::Blocked(format!("commit {hash} does not exist")); + } + if git(&["merge-base", "--is-ancestor", hash, "HEAD"], &self.repo).ok() { + return PreMerge::AlreadyMerged; + } + PreMerge::Ok + } + + fn load_history(&self) -> MergeHistory { + fs::read_to_string(&self.history_path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() + } + + fn record_history(&self, files: &[String], tier: u8, success: bool) { + let mut history = self.load_history(); + let entry = MergeHistoryEntry { + tier, + success, + timestamp: now_ms(), + }; + for file in files { + let list = history.entry(file.clone()).or_default(); + list.push(entry.clone()); + if list.len() > HISTORY_PER_FILE { + let drop = list.len() - HISTORY_PER_FILE; + list.drain(..drop); + } + } + self.save_history(&history); + } + + fn save_history(&self, history: &MergeHistory) { + if let Some(dir) = self.history_path.parent() { + let _ = fs::create_dir_all(dir); + } + if let Ok(json) = serde_json::to_string_pretty(history) { + let _ = fs::write(&self.history_path, json); + } + } + + pub fn repo(&self) -> &Path { + &self.repo + } +} + +enum PreMerge { + Ok, + AlreadyMerged, + Blocked(String), +} diff --git a/crates/fsc-merge/tests/conflict.rs b/crates/fsc-merge/tests/conflict.rs new file mode 100644 index 0000000..5e3b989 --- /dev/null +++ b/crates/fsc-merge/tests/conflict.rs @@ -0,0 +1,83 @@ +//! Conflict-marker parser: the same cases as the TS `conflictParser` tests plus edge cases. + +use fsc_merge::{ + has_contentful_canonical, parse_conflict_markers, resolve_keep_incoming, ConflictChunk, +}; + +fn sample() -> String { + [ + "before", + "<<<<<<< HEAD", + "ours line", + "=======", + "theirs line", + ">>>>>>> branch", + "after", + ] + .join("\n") +} + +#[test] +fn parse_extracts_ours_and_theirs() { + let chunks = parse_conflict_markers(&sample()); + assert_eq!( + chunks, + vec![ConflictChunk { + start_line: 1, + ours: "ours line".into(), + theirs: "theirs line".into() + }] + ); +} + +#[test] +fn no_markers_means_no_chunks() { + assert!(parse_conflict_markers("plain\ntext\n").is_empty()); +} + +#[test] +fn contentful_canonical_ignores_blank_sides() { + let c = |ours: &str| ConflictChunk { + start_line: 0, + ours: ours.into(), + theirs: "x".into(), + }; + assert!(has_contentful_canonical(&c("real code"))); + assert!(!has_contentful_canonical(&c(" \n "))); + assert!(!has_contentful_canonical(&c(""))); +} + +#[test] +fn keep_incoming_drops_ours_and_markers() { + assert_eq!( + resolve_keep_incoming(&sample()), + "before\ntheirs line\nafter" + ); +} + +#[test] +fn multiple_chunks_resolve_independently() { + let s = [ + "a", + "<<<<<<< HEAD", + "o1", + "=======", + "t1", + ">>>>>>> b", + "m", + "<<<<<<< HEAD", + "=======", + "t2", + ">>>>>>> b", + "z", + ] + .join("\n"); + assert_eq!(parse_conflict_markers(&s).len(), 2); + assert_eq!(resolve_keep_incoming(&s), "a\nt1\nm\nt2\nz"); +} + +#[test] +fn marker_needs_trailing_whitespace_and_exactly_seven() { + assert!(parse_conflict_markers("<<<<<<<\nx\n=======\ny\n>>>>>>>").is_empty()); + assert!(parse_conflict_markers("<<<<<<<< HEAD\nx\n=======\ny\n>>>>>>> b").is_empty()); +} diff --git a/crates/fsc-merge/tests/golden.rs b/crates/fsc-merge/tests/golden.rs new file mode 100644 index 0000000..ab97c46 --- /dev/null +++ b/crates/fsc-merge/tests/golden.rs @@ -0,0 +1,232 @@ +//! Golden tests: the Rust sequencer must produce the same outcomes as the TypeScript one on +//! real git repositories. Regenerate with `bun script/gen-merge-golden.ts` (only when the TS +//! reference changes on purpose). + +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +use fsc_merge::git::git; +use fsc_merge::{CommitSequencer, MergeInput, SequencerResult}; +use serde::Deserialize; +use serde_json::Value; + +#[derive(Deserialize)] +struct Scenario { + name: String, + target: String, + #[serde(default)] + mode: String, + steps: Vec, + applies: Vec, + #[serde(default)] + inspect: Vec, + #[serde(default)] + history_files: Vec, + #[serde(default)] + start_tier_files: Vec, + expected: Expected, +} + +#[derive(Deserialize)] +struct Apply { + id: String, + hash: Option, + branch: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Expected { + results: Vec, + files: BTreeMap>, + history: BTreeMap>, + start_tiers: BTreeMap, + porcelain_clean: bool, + cherry_pick_in_progress: bool, +} + +#[derive(Deserialize, Debug, PartialEq)] +#[serde(rename_all = "camelCase")] +struct ExpectedResult { + task_id: String, + success: bool, + resolved_tier: Option, + error: Option, + has_commit_hash: bool, +} + +#[derive(Deserialize, Debug, PartialEq)] +struct HistEntry { + tier: u8, + success: bool, +} + +fn sub(s: &str, vars: &BTreeMap) -> String { + let mut out = s.to_string(); + for (k, v) in vars { + out = out.replace(&format!("${{{k}}}"), v); + } + out +} + +fn run(repo: &Path, args: &[String]) -> String { + let refs: Vec<&str> = args.iter().map(String::as_str).collect(); + let o = git(&refs, repo); + assert!(o.ok(), "git {args:?} failed: {}", o.stderr); + o.stdout +} + +fn normalize(err: &str) -> String { + let mut out = String::new(); + let mut run_len = 0; + let mut buf = String::new(); + for c in err.chars() { + if c.is_ascii_hexdigit() && c.is_ascii_lowercase() || c.is_ascii_digit() { + run_len += 1; + buf.push(c); + continue; + } + flush(&mut out, &mut buf, &mut run_len); + out.push(c); + } + flush(&mut out, &mut buf, &mut run_len); + out +} + +fn flush(out: &mut String, buf: &mut String, run_len: &mut usize) { + if *run_len == 40 { + out.push_str(""); + } else { + out.push_str(buf); + } + buf.clear(); + *run_len = 0; +} + +#[test] +fn sequencer_matches_typescript() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/golden/merge.json"); + let scenarios: Vec = + serde_json::from_str(&fs::read_to_string(path).expect("golden")).expect("json"); + assert!(!scenarios.is_empty()); + + for sc in scenarios { + let tmp = tempfile::tempdir().expect("tmp"); + let repo = tmp.path(); + let s = |a: &[&str]| a.iter().map(|x| x.to_string()).collect::>(); + run(repo, &s(&["init", "-q"])); + run(repo, &s(&["checkout", "-q", "-b", "main"])); + for (k, v) in [ + ("user.name", "t"), + ("user.email", "t@t"), + ("commit.gpgsign", "false"), + ("core.autocrlf", "false"), + ] { + run(repo, &s(&["config", k, v])); + } + + let mut vars = BTreeMap::new(); + for step in &sc.steps { + if let Some(args) = step.get("git").and_then(Value::as_array) { + let args: Vec = args + .iter() + .map(|a| sub(a.as_str().expect("str"), &vars)) + .collect(); + run(repo, &args); + } else if let Some(w) = step.get("write").and_then(Value::as_array) { + let p = repo.join(w[0].as_str().expect("path")); + fs::create_dir_all(p.parent().expect("parent")).expect("mkdir"); + fs::write(p, w[1].as_str().expect("content")).expect("write"); + } else if let Some(name) = step.get("rev").and_then(Value::as_str) { + vars.insert( + name.to_string(), + run(repo, &s(&["rev-parse", "HEAD"])).trim().to_string(), + ); + } + } + + let inputs: Vec = sc + .applies + .iter() + .map(|a| MergeInput { + work_unit_id: a.id.clone(), + commit_hash: a.hash.as_ref().map(|h| sub(h, &vars)), + branch: a.branch.clone(), + }) + .collect(); + let mut seq = CommitSequencer::new(repo, sc.target.clone()); + let results: Vec = if sc.mode == "all" { + seq.apply_all(&inputs) + } else { + inputs.iter().map(|i| seq.apply(i)).collect() + }; + + let got: Vec = results + .iter() + .map(|r| ExpectedResult { + task_id: r.task_id.clone(), + success: r.success, + resolved_tier: r.resolved_tier, + error: r.error.as_deref().map(normalize), + has_commit_hash: r.commit_hash.is_some(), + }) + .collect(); + assert_eq!(got, sc.expected.results, "scenario {}: results", sc.name); + + for f in &sc.inspect { + let actual = fs::read_to_string(repo.join(f)).ok(); + assert_eq!( + &actual, &sc.expected.files[f], + "scenario {}: file {f}", + sc.name + ); + } + + let hist_json = fs::read_to_string(repo.join(".fsc/merge-history.json")) + .unwrap_or_else(|_| "{}".into()); + let hist: BTreeMap> = + serde_json::from_str(&hist_json).expect("history json"); + for f in &sc.history_files { + let actual: Vec = hist + .get(f) + .map(|v| { + v.iter() + .map(|e| HistEntry { + tier: e["tier"].as_u64().expect("tier") as u8, + success: e["success"].as_bool().expect("success"), + }) + .collect() + }) + .unwrap_or_default(); + assert_eq!( + actual, sc.expected.history[f], + "scenario {}: history {f}", + sc.name + ); + } + + for f in &sc.start_tier_files { + assert_eq!( + seq.effective_start_tier(f), + sc.expected.start_tiers[f], + "scenario {}: start tier {f}", + sc.name + ); + } + + let porcelain = run(repo, &s(&["status", "--porcelain", "--untracked-files=no"])); + assert_eq!( + porcelain.trim().is_empty(), + sc.expected.porcelain_clean, + "scenario {}: clean tree", + sc.name + ); + assert_eq!( + repo.join(".git/CHERRY_PICK_HEAD").exists(), + sc.expected.cherry_pick_in_progress, + "scenario {}: cherry-pick state", + sc.name + ); + } +} diff --git a/crates/fsc-merge/tests/golden/merge.json b/crates/fsc-merge/tests/golden/merge.json new file mode 100644 index 0000000..e1f4810 --- /dev/null +++ b/crates/fsc-merge/tests/golden/merge.json @@ -0,0 +1,938 @@ +[ + { + "name": "tier1_clean", + "target": "main", + "steps": [ + { + "write": [ + "shared.txt", + "line1\nline2\nline3\n" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "initial" + ] + }, + { + "git": [ + "checkout", + "-q", + "-b", + "agent" + ] + }, + { + "write": [ + "new-file.txt", + "no conflict" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "task" + ] + }, + { + "rev": "h" + }, + { + "git": [ + "checkout", + "-q", + "main" + ] + } + ], + "applies": [ + { + "id": "t1", + "hash": "${h}", + "branch": "agent" + } + ], + "inspect": [ + "new-file.txt", + "shared.txt" + ], + "expected": { + "results": [ + { + "taskId": "t1", + "success": true, + "resolvedTier": 1, + "error": null, + "hasCommitHash": true + } + ], + "files": { + "new-file.txt": "no conflict", + "shared.txt": "line1\nline2\nline3\n" + }, + "history": {}, + "startTiers": {}, + "porcelainClean": true, + "cherryPickInProgress": false + } + }, + { + "name": "no_commit_hash", + "target": "main", + "steps": [ + { + "write": [ + "a.txt", + "a" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "initial" + ] + } + ], + "applies": [ + { + "id": "t1", + "hash": null, + "branch": "agent" + } + ], + "inspect": [ + "a.txt" + ], + "expected": { + "results": [ + { + "taskId": "t1", + "success": true, + "resolvedTier": null, + "error": "no changes", + "hasCommitHash": false + } + ], + "files": { + "a.txt": "a" + }, + "history": {}, + "startTiers": {}, + "porcelainClean": true, + "cherryPickInProgress": false + } + }, + { + "name": "already_merged", + "target": "main", + "steps": [ + { + "write": [ + "a.txt", + "a" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "initial" + ] + }, + { + "rev": "h" + } + ], + "applies": [ + { + "id": "t1", + "hash": "${h}", + "branch": "agent" + } + ], + "inspect": [ + "a.txt" + ], + "expected": { + "results": [ + { + "taskId": "t1", + "success": true, + "resolvedTier": null, + "error": "already merged", + "hasCommitHash": true + } + ], + "files": { + "a.txt": "a" + }, + "history": {}, + "startTiers": {}, + "porcelainClean": true, + "cherryPickInProgress": false + } + }, + { + "name": "missing_commit", + "target": "main", + "steps": [ + { + "write": [ + "a.txt", + "a" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "initial" + ] + } + ], + "applies": [ + { + "id": "t1", + "hash": "0123456789abcdef0123456789abcdef01234567", + "branch": "agent" + } + ], + "inspect": [ + "a.txt" + ], + "expected": { + "results": [ + { + "taskId": "t1", + "success": false, + "resolvedTier": null, + "error": "commit does not exist", + "hasCommitHash": false + } + ], + "files": { + "a.txt": "a" + }, + "history": {}, + "startTiers": {}, + "porcelainClean": true, + "cherryPickInProgress": false + } + }, + { + "name": "dirty_tree", + "target": "main", + "steps": [ + { + "write": [ + "a.txt", + "a" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "initial" + ] + }, + { + "git": [ + "checkout", + "-q", + "-b", + "agent" + ] + }, + { + "write": [ + "new-file.txt", + "x" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "task" + ] + }, + { + "rev": "h" + }, + { + "git": [ + "checkout", + "-q", + "main" + ] + }, + { + "write": [ + "dirty.txt", + "untracked" + ] + } + ], + "applies": [ + { + "id": "t1", + "hash": "${h}", + "branch": "agent" + } + ], + "inspect": [ + "dirty.txt" + ], + "expected": { + "results": [ + { + "taskId": "t1", + "success": false, + "resolvedTier": null, + "error": "working tree is dirty", + "hasCommitHash": false + } + ], + "files": { + "dirty.txt": "untracked" + }, + "history": {}, + "startTiers": {}, + "porcelainClean": true, + "cherryPickInProgress": false + } + }, + { + "name": "tier2_blocked_contentful_canonical", + "target": "main", + "steps": [ + { + "write": [ + "conflict.txt", + "original content\n" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "base" + ] + }, + { + "rev": "base" + }, + { + "write": [ + "conflict.txt", + "main's important change\n" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "main change" + ] + }, + { + "git": [ + "checkout", + "-q", + "-b", + "agent", + "${base}" + ] + }, + { + "write": [ + "conflict.txt", + "agent's different change\n" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "agent change" + ] + }, + { + "rev": "h" + }, + { + "git": [ + "checkout", + "-q", + "main" + ] + } + ], + "applies": [ + { + "id": "t1", + "hash": "${h}", + "branch": "agent" + } + ], + "inspect": [ + "conflict.txt" + ], + "history_files": [ + "conflict.txt" + ], + "start_tier_files": [ + "conflict.txt" + ], + "expected": { + "results": [ + { + "taskId": "t1", + "success": false, + "resolvedTier": null, + "error": "MERGE_CONFLICT (tier2 blocked): conflict.txt", + "hasCommitHash": true + } + ], + "files": { + "conflict.txt": "main's important change\n" + }, + "history": { + "conflict.txt": [ + { + "tier": 2, + "success": false + } + ] + }, + "startTiers": { + "conflict.txt": 1 + }, + "porcelainClean": true, + "cherryPickInProgress": false + } + }, + { + "name": "tier2_resolved_empty_canonical", + "target": "main", + "steps": [ + { + "write": [ + "f.txt", + "a\nb\nc\n" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "base" + ] + }, + { + "git": [ + "checkout", + "-q", + "-b", + "agent" + ] + }, + { + "write": [ + "f.txt", + "a\nB2\nc\n" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "agent modifies b" + ] + }, + { + "rev": "h" + }, + { + "git": [ + "checkout", + "-q", + "main" + ] + }, + { + "write": [ + "f.txt", + "a\nc\n" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "main deletes b" + ] + } + ], + "applies": [ + { + "id": "t1", + "hash": "${h}", + "branch": "agent" + } + ], + "inspect": [ + "f.txt" + ], + "history_files": [ + "f.txt" + ], + "expected": { + "results": [ + { + "taskId": "t1", + "success": true, + "resolvedTier": 2, + "error": null, + "hasCommitHash": true + } + ], + "files": { + "f.txt": "a\nB2\nc\n" + }, + "history": { + "f.txt": [ + { + "tier": 2, + "success": true + } + ] + }, + "startTiers": {}, + "porcelainClean": true, + "cherryPickInProgress": false + } + }, + { + "name": "mixed_conflicts_blocked_twice", + "target": "main", + "steps": [ + { + "write": [ + "f1.txt", + "a\nb\nc\n" + ] + }, + { + "write": [ + "f2.txt", + "a\nb\nc\n" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "base" + ] + }, + { + "git": [ + "checkout", + "-q", + "-b", + "agent" + ] + }, + { + "write": [ + "f1.txt", + "a\nB2\nc\n" + ] + }, + { + "write": [ + "f2.txt", + "a\nX2\nc\n" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "agent" + ] + }, + { + "rev": "h" + }, + { + "git": [ + "checkout", + "-q", + "main" + ] + }, + { + "write": [ + "f1.txt", + "a\nc\n" + ] + }, + { + "write": [ + "f2.txt", + "a\nb-main\nc\n" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "main" + ] + } + ], + "applies": [ + { + "id": "t1", + "hash": "${h}", + "branch": "agent" + }, + { + "id": "t2", + "hash": "${h}", + "branch": "agent" + } + ], + "inspect": [ + "f1.txt", + "f2.txt" + ], + "history_files": [ + "f1.txt", + "f2.txt" + ], + "start_tier_files": [ + "f1.txt", + "f2.txt" + ], + "expected": { + "results": [ + { + "taskId": "t1", + "success": false, + "resolvedTier": null, + "error": "MERGE_CONFLICT (tier2 blocked): f2.txt", + "hasCommitHash": true + }, + { + "taskId": "t2", + "success": false, + "resolvedTier": null, + "error": "MERGE_CONFLICT (tier2 blocked): f2.txt", + "hasCommitHash": true + } + ], + "files": { + "f1.txt": "a\nc\n", + "f2.txt": "a\nb-main\nc\n" + }, + "history": { + "f1.txt": [ + { + "tier": 2, + "success": false + }, + { + "tier": 2, + "success": false + } + ], + "f2.txt": [ + { + "tier": 2, + "success": false + }, + { + "tier": 2, + "success": false + } + ] + }, + "startTiers": { + "f1.txt": 1, + "f2.txt": 1 + }, + "porcelainClean": true, + "cherryPickInProgress": false + } + }, + { + "name": "apply_all_sorts_and_filters", + "target": "main", + "mode": "all", + "steps": [ + { + "write": [ + "base.txt", + "base" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "base" + ] + }, + { + "git": [ + "checkout", + "-q", + "-b", + "agent-b" + ] + }, + { + "write": [ + "b.txt", + "b" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "b" + ] + }, + { + "rev": "hb" + }, + { + "git": [ + "checkout", + "-q", + "main" + ] + }, + { + "git": [ + "checkout", + "-q", + "-b", + "agent-a" + ] + }, + { + "write": [ + "a.txt", + "a" + ] + }, + { + "git": [ + "add", + "-A" + ] + }, + { + "git": [ + "commit", + "-q", + "-m", + "a" + ] + }, + { + "rev": "ha" + }, + { + "git": [ + "checkout", + "-q", + "main" + ] + } + ], + "applies": [ + { + "id": "b", + "hash": "${hb}", + "branch": "agent-b" + }, + { + "id": "z-no-commit", + "hash": null, + "branch": "none" + }, + { + "id": "a", + "hash": "${ha}", + "branch": "agent-a" + } + ], + "inspect": [ + "a.txt", + "b.txt" + ], + "expected": { + "results": [ + { + "taskId": "a", + "success": true, + "resolvedTier": 1, + "error": null, + "hasCommitHash": true + }, + { + "taskId": "b", + "success": true, + "resolvedTier": 1, + "error": null, + "hasCommitHash": true + } + ], + "files": { + "a.txt": "a", + "b.txt": "b" + }, + "history": {}, + "startTiers": {}, + "porcelainClean": true, + "cherryPickInProgress": false + } + } +] diff --git a/crates/fsc-merge/tests/scenarios.json b/crates/fsc-merge/tests/scenarios.json new file mode 100644 index 0000000..876b544 --- /dev/null +++ b/crates/fsc-merge/tests/scenarios.json @@ -0,0 +1,171 @@ +[ + { + "name": "tier1_clean", + "target": "main", + "steps": [ + { "write": ["shared.txt", "line1\nline2\nline3\n"] }, + { "git": ["add", "-A"] }, + { "git": ["commit", "-q", "-m", "initial"] }, + { "git": ["checkout", "-q", "-b", "agent"] }, + { "write": ["new-file.txt", "no conflict"] }, + { "git": ["add", "-A"] }, + { "git": ["commit", "-q", "-m", "task"] }, + { "rev": "h" }, + { "git": ["checkout", "-q", "main"] } + ], + "applies": [{ "id": "t1", "hash": "${h}", "branch": "agent" }], + "inspect": ["new-file.txt", "shared.txt"] + }, + { + "name": "no_commit_hash", + "target": "main", + "steps": [ + { "write": ["a.txt", "a"] }, + { "git": ["add", "-A"] }, + { "git": ["commit", "-q", "-m", "initial"] } + ], + "applies": [{ "id": "t1", "hash": null, "branch": "agent" }], + "inspect": ["a.txt"] + }, + { + "name": "already_merged", + "target": "main", + "steps": [ + { "write": ["a.txt", "a"] }, + { "git": ["add", "-A"] }, + { "git": ["commit", "-q", "-m", "initial"] }, + { "rev": "h" } + ], + "applies": [{ "id": "t1", "hash": "${h}", "branch": "agent" }], + "inspect": ["a.txt"] + }, + { + "name": "missing_commit", + "target": "main", + "steps": [ + { "write": ["a.txt", "a"] }, + { "git": ["add", "-A"] }, + { "git": ["commit", "-q", "-m", "initial"] } + ], + "applies": [{ "id": "t1", "hash": "0123456789abcdef0123456789abcdef01234567", "branch": "agent" }], + "inspect": ["a.txt"] + }, + { + "name": "dirty_tree", + "target": "main", + "steps": [ + { "write": ["a.txt", "a"] }, + { "git": ["add", "-A"] }, + { "git": ["commit", "-q", "-m", "initial"] }, + { "git": ["checkout", "-q", "-b", "agent"] }, + { "write": ["new-file.txt", "x"] }, + { "git": ["add", "-A"] }, + { "git": ["commit", "-q", "-m", "task"] }, + { "rev": "h" }, + { "git": ["checkout", "-q", "main"] }, + { "write": ["dirty.txt", "untracked"] } + ], + "applies": [{ "id": "t1", "hash": "${h}", "branch": "agent" }], + "inspect": ["dirty.txt"] + }, + { + "name": "tier2_blocked_contentful_canonical", + "target": "main", + "steps": [ + { "write": ["conflict.txt", "original content\n"] }, + { "git": ["add", "-A"] }, + { "git": ["commit", "-q", "-m", "base"] }, + { "rev": "base" }, + { "write": ["conflict.txt", "main's important change\n"] }, + { "git": ["add", "-A"] }, + { "git": ["commit", "-q", "-m", "main change"] }, + { "git": ["checkout", "-q", "-b", "agent", "${base}"] }, + { "write": ["conflict.txt", "agent's different change\n"] }, + { "git": ["add", "-A"] }, + { "git": ["commit", "-q", "-m", "agent change"] }, + { "rev": "h" }, + { "git": ["checkout", "-q", "main"] } + ], + "applies": [{ "id": "t1", "hash": "${h}", "branch": "agent" }], + "inspect": ["conflict.txt"], + "history_files": ["conflict.txt"], + "start_tier_files": ["conflict.txt"] + }, + { + "name": "tier2_resolved_empty_canonical", + "target": "main", + "steps": [ + { "write": ["f.txt", "a\nb\nc\n"] }, + { "git": ["add", "-A"] }, + { "git": ["commit", "-q", "-m", "base"] }, + { "git": ["checkout", "-q", "-b", "agent"] }, + { "write": ["f.txt", "a\nB2\nc\n"] }, + { "git": ["add", "-A"] }, + { "git": ["commit", "-q", "-m", "agent modifies b"] }, + { "rev": "h" }, + { "git": ["checkout", "-q", "main"] }, + { "write": ["f.txt", "a\nc\n"] }, + { "git": ["add", "-A"] }, + { "git": ["commit", "-q", "-m", "main deletes b"] } + ], + "applies": [{ "id": "t1", "hash": "${h}", "branch": "agent" }], + "inspect": ["f.txt"], + "history_files": ["f.txt"] + }, + { + "name": "mixed_conflicts_blocked_twice", + "target": "main", + "steps": [ + { "write": ["f1.txt", "a\nb\nc\n"] }, + { "write": ["f2.txt", "a\nb\nc\n"] }, + { "git": ["add", "-A"] }, + { "git": ["commit", "-q", "-m", "base"] }, + { "git": ["checkout", "-q", "-b", "agent"] }, + { "write": ["f1.txt", "a\nB2\nc\n"] }, + { "write": ["f2.txt", "a\nX2\nc\n"] }, + { "git": ["add", "-A"] }, + { "git": ["commit", "-q", "-m", "agent"] }, + { "rev": "h" }, + { "git": ["checkout", "-q", "main"] }, + { "write": ["f1.txt", "a\nc\n"] }, + { "write": ["f2.txt", "a\nb-main\nc\n"] }, + { "git": ["add", "-A"] }, + { "git": ["commit", "-q", "-m", "main"] } + ], + "applies": [ + { "id": "t1", "hash": "${h}", "branch": "agent" }, + { "id": "t2", "hash": "${h}", "branch": "agent" } + ], + "inspect": ["f1.txt", "f2.txt"], + "history_files": ["f1.txt", "f2.txt"], + "start_tier_files": ["f1.txt", "f2.txt"] + }, + { + "name": "apply_all_sorts_and_filters", + "target": "main", + "mode": "all", + "steps": [ + { "write": ["base.txt", "base"] }, + { "git": ["add", "-A"] }, + { "git": ["commit", "-q", "-m", "base"] }, + { "git": ["checkout", "-q", "-b", "agent-b"] }, + { "write": ["b.txt", "b"] }, + { "git": ["add", "-A"] }, + { "git": ["commit", "-q", "-m", "b"] }, + { "rev": "hb" }, + { "git": ["checkout", "-q", "main"] }, + { "git": ["checkout", "-q", "-b", "agent-a"] }, + { "write": ["a.txt", "a"] }, + { "git": ["add", "-A"] }, + { "git": ["commit", "-q", "-m", "a"] }, + { "rev": "ha" }, + { "git": ["checkout", "-q", "main"] } + ], + "applies": [ + { "id": "b", "hash": "${hb}", "branch": "agent-b" }, + { "id": "z-no-commit", "hash": null, "branch": "none" }, + { "id": "a", "hash": "${ha}", "branch": "agent-a" } + ], + "inspect": ["a.txt", "b.txt"] + } +] diff --git a/packages/core/package.json b/packages/core/package.json index b65092b..a511c75 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -16,7 +16,7 @@ }, "scripts": { "dev": "bun --watch src/index.ts", - "test": "bun test" + "test": "bun test --timeout 30000" }, "keywords": [ "code-analysis", diff --git a/packages/core/src/execution/sequencer.ts b/packages/core/src/execution/sequencer.ts index d220a17..02d4c1e 100644 --- a/packages/core/src/execution/sequencer.ts +++ b/packages/core/src/execution/sequencer.ts @@ -239,7 +239,8 @@ export class CommitSequencer { // ── Safety Checks ────────────────────────────────────────────── private preMergeChecks(commitHash: string): string | null { - const status = gitExec(["status", "--porcelain"], this.repoPath); + // .fsc/ is our own runtime state (merge-history.json); it must not make the tree "dirty" + const status = gitExec(["status", "--porcelain", "--", ".", ":(exclude).fsc"], this.repoPath); if (status.stdout.trim()) return "working tree is dirty"; const exists = gitExec(["cat-file", "-t", commitHash], this.repoPath); diff --git a/packages/core/test/execution/sequencer.merge.test.ts b/packages/core/test/execution/sequencer.merge.test.ts index 28a9ef0..e987816 100644 --- a/packages/core/test/execution/sequencer.merge.test.ts +++ b/packages/core/test/execution/sequencer.merge.test.ts @@ -150,3 +150,30 @@ describe("proseDetector", () => { expect(looksLikeProse("")).toBe(false); }); }); + +describe("CommitSequencer — runtime state does not dirty the tree", () => { + test("a recorded conflict does not block the next commit", async () => { + const repo = path.resolve(os.tmpdir(), `fsc-dirty-test-${Date.now()}`); + fs.mkdirSync(repo, { recursive: true }); + const g = (...a: string[]) => gitExec(a, repo); + g("init"); g("checkout", "-b", "main"); + g("config", "user.name", "t"); g("config", "user.email", "t@t"); + fs.writeFileSync(path.join(repo, "c.txt"), "orig\n"); + g("add", "-A"); g("commit", "-m", "base"); + const base = g("rev-parse", "HEAD").stdout.trim(); + fs.writeFileSync(path.join(repo, "c.txt"), "main\n"); + g("add", "-A"); g("commit", "-m", "main"); + g("checkout", "-b", "agent", base); + fs.writeFileSync(path.join(repo, "c.txt"), "agent\n"); + g("add", "-A"); g("commit", "-m", "agent"); + const h = g("rev-parse", "HEAD").stdout.trim(); + g("checkout", "main"); + + const seq = new CommitSequencer(repo, "main"); + const first = await seq.apply(makeExecResult("t1", h, "agent")); + expect(first.error).toContain("MERGE_CONFLICT"); + const second = await seq.apply(makeExecResult("t2", h, "agent")); + expect(second.error).not.toContain("working tree is dirty"); + fs.rmSync(repo, { recursive: true, force: true }); + }); +}); diff --git a/script/gen-merge-golden.ts b/script/gen-merge-golden.ts new file mode 100644 index 0000000..9a91ccc --- /dev/null +++ b/script/gen-merge-golden.ts @@ -0,0 +1,83 @@ +// Regenerates crates/fsc-merge/tests/golden/merge.json from the TypeScript CommitSequencer. +// The Rust port (crates/fsc-merge) must reproduce these outcomes exactly. +// +// bun script/gen-merge-golden.ts +// +// Scenarios live in crates/fsc-merge/tests/scenarios.json and are interpreted by both sides. + +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from "fs"; +import { join, dirname } from "path"; +import { tmpdir } from "os"; +import { CommitSequencer } from "../packages/core/src/execution/sequencer"; +import { gitExec } from "../packages/core/src/execution/gitExec"; +import type { ExecutionResult } from "../packages/core/src/execution/types"; + +const SCEN = "crates/fsc-merge/tests/scenarios.json"; +const OUT = "crates/fsc-merge/tests/golden/merge.json"; + +const scenarios = JSON.parse(readFileSync(SCEN, "utf-8")); +const sub = (s: string, vars: Record) => s.replace(/\$\{(\w+)\}/g, (_, k) => vars[k]); +const norm = (s: string | undefined) => (s === undefined ? null : s.replace(/[0-9a-f]{40}/g, "")); + +const out: any[] = []; +for (const sc of scenarios) { + const repo = mkdtempSync(join(tmpdir(), "fsc-golden-")); + const git = (args: string[]) => { + const r = gitExec(args, repo); + if (r.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed in ${sc.name}: ${r.stderr}`); + return r; + }; + git(["init", "-q"]); + git(["checkout", "-q", "-b", "main"]); + for (const [k, v] of [["user.name", "t"], ["user.email", "t@t"], ["commit.gpgsign", "false"], ["core.autocrlf", "false"]]) { + git(["config", k, v]); + } + const vars: Record = {}; + for (const step of sc.steps) { + if (step.git) git(step.git.map((a: string) => sub(a, vars))); + else if (step.write) { + const p = join(repo, step.write[0]); + mkdirSync(dirname(p), { recursive: true }); + writeFileSync(p, step.write[1]); + } else if (step.rev) vars[step.rev] = git(["rev-parse", "HEAD"]).stdout.trim(); + } + + const execs: ExecutionResult[] = sc.applies.map((a: any) => ({ + workUnitId: a.id, status: "success", commitHash: a.hash === null ? null : sub(a.hash, vars), + branch: a.branch, worktreePath: "", startedAt: 0, completedAt: 0, agent: { type: "test" }, filesChanged: [], logs: [], + })); + const seq = new CommitSequencer(repo, sc.target); + const origLog = console.log; + console.log = () => {}; // the sequencer prints a conflict banner + let results: any[] = []; + if (sc.mode === "all") results = await seq.applyAll(execs); + else for (const e of execs) results.push(await seq.apply(e)); + console.log = origLog; + + const files: Record = {}; + for (const f of sc.inspect ?? []) files[f] = existsSync(join(repo, f)) ? readFileSync(join(repo, f), "utf-8") : null; + const histPath = join(repo, ".fsc", "merge-history.json"); + const rawHist = existsSync(histPath) ? JSON.parse(readFileSync(histPath, "utf-8")) : {}; + const history: Record = {}; + for (const f of sc.history_files ?? []) history[f] = (rawHist[f] ?? []).map((e: any) => ({ tier: e.tier, success: e.success })); + const startTiers: Record = {}; + for (const f of sc.start_tier_files ?? []) startTiers[f] = seq.getEffectiveStartTier(f); + + out.push({ + ...sc, + expected: { + results: results.map((r) => ({ + taskId: r.taskId, success: r.success, resolvedTier: r.resolvedTier ?? null, + error: norm(r.error), hasCommitHash: !!r.commitHash, + })), + files, + history, + startTiers, + porcelainClean: git(["status", "--porcelain", "--untracked-files=no"]).stdout.trim() === "", + cherryPickInProgress: existsSync(join(repo, ".git", "CHERRY_PICK_HEAD")), + }, + }); + rmSync(repo, { recursive: true, force: true }); +} +writeFileSync(OUT, JSON.stringify(out, null, 2) + "\n"); +console.log(`wrote ${out.length} scenarios`);