From 5b2130a9d59d001af68afdd02b566127c5b2ec47 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot <269191875+qwen-code-dev-bot@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:56:44 +0800 Subject: [PATCH] feat(cli): add a read-only, fail-closed --predict-conflict command for safe parallel-branch integration (#226) Isolation (#41, #50) lets parallel agents work in separate worktrees, but nothing told an operator whether integrating a parallel agent's branch back would conflict before attempting it, risking a half-applied, hard-to-review merge. Add an opt-in --predict-conflict [--conflict-target ] command that runs git merge-tree --write-tree (no working-tree mutation, no commit) to predict clean-vs-conflict and report a bounded, redacted list of conflicting paths. It fails closed on a dirty working tree, an unresolvable revision, or a merge-tree error, so a bad prediction can never lead to a silent bad merge. --- src/conflict-prediction.ts | 140 +++++++++++++++++ src/index.ts | 35 +++++ tests/integration/conflict-prediction.test.ts | 141 ++++++++++++++++++ tests/unit/conflict-prediction.test.ts | 141 ++++++++++++++++++ 4 files changed, 457 insertions(+) create mode 100644 src/conflict-prediction.ts create mode 100644 tests/integration/conflict-prediction.test.ts create mode 100644 tests/unit/conflict-prediction.test.ts diff --git a/src/conflict-prediction.ts b/src/conflict-prediction.ts new file mode 100644 index 0000000..73cc41b --- /dev/null +++ b/src/conflict-prediction.ts @@ -0,0 +1,140 @@ +// A read-only, fail-closed prediction of whether integrating one revision into +// another would conflict (roadmap #39, "conflict prediction"). It runs +// `git merge-tree --write-tree` — which computes a merge without touching the +// working tree or creating any commit — and reports clean-vs-conflict plus a +// bounded, redacted list of conflicting paths. It refuses to predict (fails +// closed) on a dirty working tree, an unresolvable revision, or a merge-tree +// error, so a bad prediction can never lead to a silent bad merge. + +import { execFileSync } from "node:child_process"; +import { redactHomePath } from "./permission-impact.js"; + +export const CONFLICT_PREDICTION_SCHEMA = "oh-my-cli.conflict-prediction"; +export const CONFLICT_PREDICTION_VERSION = 1; + +// Bound the number of conflicting paths reported so a pathological merge cannot +// inflate the report; paths beyond the bound are counted in `truncated`. +const MAX_CONFLICT_PATHS = 100; + +export interface ConflictPrediction { + schema: typeof CONFLICT_PREDICTION_SCHEMA; + v: typeof CONFLICT_PREDICTION_VERSION; + source: string; + target: string; + // True when the merge would apply cleanly (no conflicts). + clean: boolean; + // Bounded, redacted conflicting paths (home collapsed to ~); empty when clean. + conflicts: string[]; + // Count of conflicting paths beyond the bound (0 when none). + truncated: number; +} + +// Run a git command, capturing the exit status and stdout even on a non-zero exit +// (merge-tree signals "conflicts" with exit 1 and still prints useful output). +function gitCapture(workspace: string, args: string[]): { status: number; stdout: string } { + try { + const stdout = execFileSync("git", ["-C", workspace, ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 15_000, + maxBuffer: 4 << 20, + }); + return { status: 0, stdout }; + } catch (err) { + const e = err as { status?: number | null; stdout?: unknown }; + const stdout = typeof e.stdout === "string" ? e.stdout : ""; + return { status: typeof e.status === "number" ? e.status : 128, stdout }; + } +} + +// Predict whether merging `source` into `target` would conflict, read-only. Throws +// a redacted error (fail closed) on a dirty working tree, an unresolvable +// revision, or a merge-tree failure. +export function predictMergeConflict( + workspace: string, + source: string, + target: string, +): ConflictPrediction { + // Fail closed on a dirty working tree: the prediction is about committed + // branches, and uncommitted work could be affected by a subsequent integration. + const status = gitCapture(workspace, ["status", "--porcelain"]); + if (status.status !== 0) { + throw new Error("Conflict prediction error: cannot read working-tree state (fail closed)"); + } + if (status.stdout.trim() !== "") { + throw new Error( + "Conflict prediction error: working tree is dirty; commit or stash changes before predicting (fail closed)", + ); + } + + // Fail closed on an unresolvable revision. + for (const [label, rev] of [ + ["source", source], + ["target", target], + ] as const) { + const resolved = gitCapture(workspace, ["rev-parse", "--verify", "--quiet", `${rev}^{commit}`]); + if (resolved.status !== 0 || resolved.stdout.trim() === "") { + throw new Error(`Conflict prediction error: cannot resolve ${label} revision "${rev}" (fail closed)`); + } + } + + // Read-only merge computation. Exit 0 ⇒ clean; exit 1 ⇒ conflicts (the merged + // tree OID is printed first, then the conflicting paths with --name-only); any + // other status is an error. + const merge = gitCapture(workspace, [ + "merge-tree", + "--write-tree", + "--name-only", + target, + source, + ]); + if (merge.status === 0) { + return { + schema: CONFLICT_PREDICTION_SCHEMA, + v: CONFLICT_PREDICTION_VERSION, + source, + target, + clean: true, + conflicts: [], + truncated: 0, + }; + } + if (merge.status === 1) { + // Drop the merged-tree object-name line(s) (pure hex), keeping only paths. + const paths = merge.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line !== "" && !/^[0-9a-f]{40,}$/i.test(line)); + const bounded = paths.slice(0, MAX_CONFLICT_PATHS).map((p) => redactHomePath(p)); + return { + schema: CONFLICT_PREDICTION_SCHEMA, + v: CONFLICT_PREDICTION_VERSION, + source, + target, + clean: false, + conflicts: bounded, + truncated: Math.max(0, paths.length - bounded.length), + }; + } + throw new Error("Conflict prediction error: git merge-tree failed (fail closed)"); +} + +// A deterministic, human-readable rendering of the prediction. It contains only +// revision names and (redacted) conflicting paths — never file contents. +export function formatConflictPrediction(prediction: ConflictPrediction): string { + const lines: string[] = []; + lines.push(`Conflict prediction (${prediction.schema} v${prediction.v})`); + lines.push(` source: ${prediction.source}`); + lines.push(` target: ${prediction.target}`); + lines.push(` result: ${prediction.clean ? "CLEAN" : "CONFLICT"}`); + if (!prediction.clean) { + lines.push(` conflicts: ${prediction.conflicts.length}`); + for (const path of prediction.conflicts) { + lines.push(` ${path}`); + } + if (prediction.truncated > 0) { + lines.push(` truncated: ${prediction.truncated} more conflicting path${prediction.truncated === 1 ? "" : "s"} beyond the bound`); + } + } + return lines.join("\n"); +} diff --git a/src/index.ts b/src/index.ts index adf26e2..0f70a47 100644 --- a/src/index.ts +++ b/src/index.ts @@ -78,6 +78,7 @@ import { import { collectExtensionDiscovery, formatExtensionDiscovery } from "./extension-discovery.js"; import { collectExtensionCompat, formatExtensionCompat } from "./extension-compat.js"; import { collectTrustPosture, formatTrustPosture } from "./trust-posture.js"; +import { predictMergeConflict, formatConflictPrediction } from "./conflict-prediction.js"; import { readRecoveryCheckpoint, readEvidenceFile, @@ -292,6 +293,11 @@ program .option("--trust-workspace", "Persist trust for this workspace in the user trust store and exit") .option("--enforce-folder-trust", "Deny mutating tools when the workspace is untrusted (env: OMC_ENFORCE_FOLDER_TRUST=1)") .option("--trust-posture", "Show the effective, redacted workspace trust, sandbox, approval, and extension posture (read-only) and exit") + .option( + "--predict-conflict ", + "Predict read-only whether merging into the target would conflict (fail-closed) and exit", + ) + .option("--conflict-target ", "Target revision for --predict-conflict (default HEAD)") .option("--health", "Show MCP server and extension health inventory and exit") .option("--settings ", "Unified settings file for model config and --health (default ~/.oh-my-cli/settings.json)") .option("--effective-settings", "Show the effective, redacted, hierarchical settings snapshot (user + trusted project, validated; read-only) and exit") @@ -1477,6 +1483,35 @@ program process.exit(0); } + // Conflict-prediction mode: predict read-only whether merging a source + // revision into a target would conflict (conflict-prediction.ts). It runs + // `git merge-tree` (no working-tree mutation, no commit) and fails closed on + // a dirty tree, an unresolvable revision, or a merge-tree error. Exits 0 on a + // successful prediction (clean or conflict); exits 2 on a usage/state error. + if (opts.predictConflict) { + const format = String(opts.output ?? "text"); + if (format !== "text" && format !== "json") { + process.stderr.write(`Error: invalid output format "${format}"\n`); + process.exit(2); + } + const source = String(opts.predictConflict); + const target = opts.conflictTarget ? String(opts.conflictTarget) : "HEAD"; + let prediction; + try { + prediction = predictMergeConflict(opts.workspace, source, target); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`${msg}\n`); + process.exit(2); + } + if (format === "json") { + process.stdout.write(JSON.stringify(prediction) + "\n"); + } else { + process.stdout.write(formatConflictPrediction(prediction) + "\n"); + } + process.exit(0); + } + // Effective-settings mode: the one immutable, validated, hierarchical // settings snapshot — defaults < user settings < trusted project settings < // environment overrides < CLI overrides — with redacted provenance diff --git a/tests/integration/conflict-prediction.test.ts b/tests/integration/conflict-prediction.test.ts new file mode 100644 index 0000000..256bcbf --- /dev/null +++ b/tests/integration/conflict-prediction.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { execFileSync } from "node:child_process"; +import { spawn } from "node:child_process"; +import path from "node:path"; +import fs from "node:fs"; +import os from "node:os"; + +function runCli( + args: string[], + env: Record, + timeoutMs = 20_000, +): Promise<{ stdout: string; stderr: string; code: number | null }> { + return new Promise((resolve, reject) => { + const cliPath = path.resolve(import.meta.dirname, "../../dist/index.js"); + const proc = spawn("node", [cliPath, ...args], { + env: { ...process.env, ...env }, + timeout: timeoutMs, + }); + let stdout = ""; + let stderr = ""; + proc.stdout.on("data", (d) => { stdout += d; }); + proc.stderr.on("data", (d) => { stderr += d; }); + proc.on("close", (code) => resolve({ stdout, stderr, code })); + proc.on("error", reject); + }); +} + +function git(repo: string, ...args: string[]): void { + execFileSync("git", ["-C", repo, ...args], { stdio: ["ignore", "pipe", "ignore"] }); +} + +const tmpDirs: string[] = []; +function makeRepo(): string { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), "omc-conflict-int-")); + tmpDirs.push(repo); + execFileSync("git", ["init", "-q", "-b", "main", repo], { stdio: ["ignore", "pipe", "ignore"] }); + git(repo, "config", "user.email", "test@example.com"); + git(repo, "config", "user.name", "Test"); + fs.writeFileSync(path.join(repo, "a.txt"), "base\n"); + git(repo, "add", "a.txt"); + git(repo, "commit", "-q", "-m", "base"); + return repo; +} + +describe("Integration: conflict prediction (--predict-conflict)", () => { + let conflictRepo: string; + let cleanRepo: string; + let homeDir: string; + let env: Record; + + beforeAll(() => { + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "omc-conflict-int-home-")); + tmpDirs.push(homeDir); + env = { HOME: homeDir, OPENAI_API_KEY: "", OPENAI_BASE_URL: "", OPENAI_MODEL: "" }; + + conflictRepo = makeRepo(); + git(conflictRepo, "checkout", "-q", "-b", "feature"); + fs.writeFileSync(path.join(conflictRepo, "a.txt"), "feature change\n"); + git(conflictRepo, "add", "a.txt"); + git(conflictRepo, "commit", "-q", "-m", "feature"); + git(conflictRepo, "checkout", "-q", "main"); + fs.writeFileSync(path.join(conflictRepo, "a.txt"), "main change\n"); + git(conflictRepo, "add", "a.txt"); + git(conflictRepo, "commit", "-q", "-m", "main"); + + cleanRepo = makeRepo(); + git(cleanRepo, "checkout", "-q", "-b", "feature"); + fs.writeFileSync(path.join(cleanRepo, "b.txt"), "new file\n"); + git(cleanRepo, "add", "b.txt"); + git(cleanRepo, "commit", "-q", "-m", "feature"); + git(cleanRepo, "checkout", "-q", "main"); + }); + + afterAll(() => { + for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }); + }); + + it("predicts CONFLICT (JSON) for a divergent branch without mutating the repo", async () => { + const headBefore = execFileSync("git", ["-C", conflictRepo, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + const r = await runCli( + ["--predict-conflict", "feature", "--conflict-target", "main", "--workspace", conflictRepo, "--output", "json"], + env, + ); + expect(r.code).toBe(0); + const prediction = JSON.parse(r.stdout); + expect(prediction.schema).toBe("oh-my-cli.conflict-prediction"); + expect(prediction.clean).toBe(false); + expect(prediction.conflicts).toContain("a.txt"); + + // Read-only: HEAD and working tree unchanged. + const headAfter = execFileSync("git", ["-C", conflictRepo, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + expect(headAfter).toBe(headBefore); + const status = execFileSync("git", ["-C", conflictRepo, "status", "--porcelain"], { encoding: "utf8" }); + expect(status.trim()).toBe(""); + }); + + it("predicts CLEAN (text) for a non-conflicting branch", async () => { + const r = await runCli( + ["--predict-conflict", "feature", "--conflict-target", "main", "--workspace", cleanRepo], + env, + ); + expect(r.code).toBe(0); + expect(r.stdout).toContain("Conflict prediction (oh-my-cli.conflict-prediction v1)"); + expect(r.stdout).toContain("result: CLEAN"); + }); + + it("defaults the target to HEAD", async () => { + const r = await runCli( + ["--predict-conflict", "feature", "--workspace", cleanRepo, "--output", "json"], + env, + ); + expect(r.code).toBe(0); + const prediction = JSON.parse(r.stdout); + expect(prediction.target).toBe("HEAD"); + expect(prediction.clean).toBe(true); + }); + + it("exits 2 (fail closed) on an unresolvable revision", async () => { + const r = await runCli( + ["--predict-conflict", "no-such-branch", "--conflict-target", "main", "--workspace", conflictRepo], + env, + ); + expect(r.code).toBe(2); + expect(r.stderr).toContain("cannot resolve source revision"); + }); + + it("exits 2 on a dirty working tree", async () => { + fs.writeFileSync(path.join(cleanRepo, "a.txt"), "uncommitted\n"); + try { + const r = await runCli( + ["--predict-conflict", "feature", "--conflict-target", "main", "--workspace", cleanRepo], + env, + ); + expect(r.code).toBe(2); + expect(r.stderr).toContain("working tree is dirty"); + } finally { + // Restore the clean repo state for any later assertions. + git(cleanRepo, "checkout", "-q", "--", "a.txt"); + } + }); +}); diff --git a/tests/unit/conflict-prediction.test.ts b/tests/unit/conflict-prediction.test.ts new file mode 100644 index 0000000..db73b69 --- /dev/null +++ b/tests/unit/conflict-prediction.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, afterAll } from "vitest"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + predictMergeConflict, + formatConflictPrediction, + CONFLICT_PREDICTION_SCHEMA, + CONFLICT_PREDICTION_VERSION, +} from "../../src/conflict-prediction.js"; + +const tmpDirs: string[] = []; +afterAll(() => { + for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }); +}); + +function git(repo: string, ...args: string[]): void { + execFileSync("git", ["-C", repo, ...args], { stdio: ["ignore", "pipe", "ignore"] }); +} + +// Create a temp repo with one commit on `main` (a.txt = "base"). +function makeRepo(): string { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), "omc-conflict-")); + tmpDirs.push(repo); + execFileSync("git", ["init", "-q", "-b", "main", repo], { stdio: ["ignore", "pipe", "ignore"] }); + git(repo, "config", "user.email", "test@example.com"); + git(repo, "config", "user.name", "Test"); + fs.writeFileSync(path.join(repo, "a.txt"), "base\n"); + git(repo, "add", "a.txt"); + git(repo, "commit", "-q", "-m", "base"); + return repo; +} + +// A branch that edits a.txt differently from main → conflict on a.txt. +function makeConflictingBranch(repo: string): void { + git(repo, "checkout", "-q", "-b", "feature"); + fs.writeFileSync(path.join(repo, "a.txt"), "feature change\n"); + git(repo, "add", "a.txt"); + git(repo, "commit", "-q", "-m", "feature"); + git(repo, "checkout", "-q", "main"); + fs.writeFileSync(path.join(repo, "a.txt"), "main change\n"); + git(repo, "add", "a.txt"); + git(repo, "commit", "-q", "-m", "main"); +} + +// A branch that adds a new file → clean merge into main. +function makeCleanBranch(repo: string): void { + git(repo, "checkout", "-q", "-b", "feature"); + fs.writeFileSync(path.join(repo, "b.txt"), "new file\n"); + git(repo, "add", "b.txt"); + git(repo, "commit", "-q", "-m", "feature"); + git(repo, "checkout", "-q", "main"); +} + +describe("predictMergeConflict", () => { + it("reports CLEAN for a non-conflicting branch and does not mutate the repo", () => { + const repo = makeRepo(); + makeCleanBranch(repo); + const headBefore = execFileSync("git", ["-C", repo, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + + const prediction = predictMergeConflict(repo, "feature", "main"); + expect(prediction.schema).toBe(CONFLICT_PREDICTION_SCHEMA); + expect(prediction.v).toBe(CONFLICT_PREDICTION_VERSION); + expect(prediction.clean).toBe(true); + expect(prediction.conflicts).toEqual([]); + expect(prediction.truncated).toBe(0); + + // Read-only: HEAD and working tree unchanged. + const headAfter = execFileSync("git", ["-C", repo, "rev-parse", "HEAD"], { encoding: "utf8" }).trim(); + expect(headAfter).toBe(headBefore); + const status = execFileSync("git", ["-C", repo, "status", "--porcelain"], { encoding: "utf8" }); + expect(status.trim()).toBe(""); + }); + + it("reports CONFLICT with the conflicting path for a divergent branch", () => { + const repo = makeRepo(); + makeConflictingBranch(repo); + const prediction = predictMergeConflict(repo, "feature", "main"); + expect(prediction.clean).toBe(false); + expect(prediction.conflicts).toContain("a.txt"); + }); + + it("fails closed on an unresolvable source revision", () => { + const repo = makeRepo(); + expect(() => predictMergeConflict(repo, "no-such-branch", "main")).toThrow(/cannot resolve source revision/); + }); + + it("fails closed on an unresolvable target revision", () => { + const repo = makeRepo(); + expect(() => predictMergeConflict(repo, "main", "no-such-branch")).toThrow(/cannot resolve target revision/); + }); + + it("fails closed on a dirty working tree", () => { + const repo = makeRepo(); + makeCleanBranch(repo); + fs.writeFileSync(path.join(repo, "a.txt"), "uncommitted\n"); + expect(() => predictMergeConflict(repo, "feature", "main")).toThrow(/working tree is dirty/); + }); + + it("fails closed when the workspace is not a git repository", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omc-conflict-norepo-")); + tmpDirs.push(dir); + expect(() => predictMergeConflict(dir, "feature", "main")).toThrow(/fail closed/); + }); +}); + +describe("formatConflictPrediction", () => { + it("renders a clean prediction", () => { + const text = formatConflictPrediction({ + schema: CONFLICT_PREDICTION_SCHEMA, + v: CONFLICT_PREDICTION_VERSION, + source: "feature", + target: "main", + clean: true, + conflicts: [], + truncated: 0, + }); + expect(text).toContain(`Conflict prediction (${CONFLICT_PREDICTION_SCHEMA} v${CONFLICT_PREDICTION_VERSION})`); + expect(text).toContain("source: feature"); + expect(text).toContain("target: main"); + expect(text).toContain("result: CLEAN"); + }); + + it("renders a conflict prediction with paths and truncation", () => { + const text = formatConflictPrediction({ + schema: CONFLICT_PREDICTION_SCHEMA, + v: CONFLICT_PREDICTION_VERSION, + source: "feature", + target: "main", + clean: false, + conflicts: ["a.txt", "b.txt"], + truncated: 3, + }); + expect(text).toContain("result: CONFLICT"); + expect(text).toContain("conflicts: 2"); + expect(text).toContain("a.txt"); + expect(text).toContain("b.txt"); + expect(text).toContain("truncated: 3 more conflicting paths beyond the bound"); + }); +});