Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions src/conflict-prediction.ts
Original file line number Diff line number Diff line change
@@ -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");
}
35 changes: 35 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <source>",
"Predict read-only whether merging <source> into the target would conflict (fail-closed) and exit",
)
.option("--conflict-target <target>", "Target revision for --predict-conflict (default HEAD)")
.option("--health", "Show MCP server and extension health inventory and exit")
.option("--settings <path>", "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")
Expand Down Expand Up @@ -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
Expand Down
141 changes: 141 additions & 0 deletions tests/integration/conflict-prediction.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>,
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<string, string>;

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");
}
});
});
Loading
Loading