diff --git a/src/index.ts b/src/index.ts index 0f70a47..d29a63a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -79,6 +79,7 @@ import { collectExtensionDiscovery, formatExtensionDiscovery } from "./extension import { collectExtensionCompat, formatExtensionCompat } from "./extension-compat.js"; import { collectTrustPosture, formatTrustPosture } from "./trust-posture.js"; import { predictMergeConflict, formatConflictPrediction } from "./conflict-prediction.js"; +import { integrateBranch, formatIntegrationResult } from "./selective-integration.js"; import { readRecoveryCheckpoint, readEvidenceFile, @@ -298,6 +299,11 @@ program "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( + "--integrate ", + "Reviewably integrate into the current branch (fail-closed, commit-identity-preserving merge) and exit", + ) + .option("--integrate-dry-run", "With --integrate, show the preview without performing the merge") .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") @@ -1512,6 +1518,37 @@ program process.exit(0); } + // Selective-integration mode: reviewably integrate a source branch into the + // current branch (selective-integration.ts). It reuses conflict prediction + // (#226) to refuse a conflicting merge, shows a bounded/redacted preview, and + // performs a non-fast-forward merge that preserves commit identity. Fails + // closed on a detached HEAD, dirty tree, unresolvable revision, predicted + // conflict, or failed merge. Exits 0 on success; exits 2 on a usage/state + // error. + if (opts.integrate) { + const format = String(opts.output ?? "text"); + if (format !== "text" && format !== "json") { + process.stderr.write(`Error: invalid output format "${format}"\n`); + process.exit(2); + } + let result; + try { + result = integrateBranch(opts.workspace, String(opts.integrate), { + dryRun: Boolean(opts.integrateDryRun), + }); + } 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(result) + "\n"); + } else { + process.stdout.write(formatIntegrationResult(result) + "\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/src/selective-integration.ts b/src/selective-integration.ts new file mode 100644 index 0000000..1a07a86 --- /dev/null +++ b/src/selective-integration.ts @@ -0,0 +1,197 @@ +// A governed, reviewable, fail-closed integration of a parallel agent's branch +// into the current branch (roadmap #39, "selective integration"). It reuses the +// conflict prediction from #226 to refuse to integrate when the merge would +// conflict, shows a bounded, redacted preview (changed paths + commit list) of +// what the integration brings, and otherwise performs a non-fast-forward merge +// that preserves the source's commit identity. It fails closed on a dirty working +// tree, an unresolvable revision, a detached HEAD, a predicted conflict, or a +// failed merge — it never discards changes or auto-resolves conflicts. + +import { execFileSync } from "node:child_process"; +import { redactHomePath, redactSecrets } from "./permission-impact.js"; +import { predictMergeConflict } from "./conflict-prediction.js"; + +export const SELECTIVE_INTEGRATION_SCHEMA = "oh-my-cli.selective-integration"; +export const SELECTIVE_INTEGRATION_VERSION = 1; + +// Bounds that keep the preview free of high-cardinality output. +const MAX_PREVIEW_PATHS = 100; +const MAX_PREVIEW_COMMITS = 50; + +export interface IntegrationCommit { + // Abbreviated commit SHA (identity-preserving merge keeps the full commit). + sha: string; + subject: string; +} + +export interface IntegrationPreview { + changedPaths: string[]; + truncatedPaths: number; + commits: IntegrationCommit[]; + truncatedCommits: number; +} + +export interface IntegrationResult { + schema: typeof SELECTIVE_INTEGRATION_SCHEMA; + v: typeof SELECTIVE_INTEGRATION_VERSION; + source: string; + target: string; + // True when a merge commit was created; false when there was nothing to + // integrate (the source was already contained in the target). + integrated: boolean; + // Resulting HEAD SHA after the operation (null only when nothing was integrated + // and the head could not be read). + head: string | null; + preview: IntegrationPreview; +} + +export interface IntegrateOptions { + // When true, compute and return the preview without performing the merge. + dryRun?: boolean; +} + +// Run a git command, capturing the exit status and stdout even on a non-zero exit. +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 }; + } +} + +// The current branch name; fails closed on a detached HEAD (the integration needs +// a named target branch to merge into). +function currentBranch(workspace: string): string { + const result = gitCapture(workspace, ["rev-parse", "--abbrev-ref", "HEAD"]); + const branch = result.stdout.trim(); + if (result.status !== 0 || branch === "" || branch === "HEAD") { + throw new Error("Selective integration error: not on a branch (detached HEAD); check out the target branch (fail closed)"); + } + return branch; +} + +function buildPreview(workspace: string, source: string, target: string): IntegrationPreview { + const diff = gitCapture(workspace, ["diff", "--name-only", `${target}...${source}`]); + const paths = diff.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line !== ""); + const changedPaths = paths.slice(0, MAX_PREVIEW_PATHS).map((p) => redactHomePath(p)); + + const log = gitCapture(workspace, ["log", "--format=%H%x09%s", `${target}..${source}`]); + const commitLines = log.stdout.split("\n").filter((line) => line.trim() !== ""); + const commits: IntegrationCommit[] = commitLines.slice(0, MAX_PREVIEW_COMMITS).map((line) => { + const tab = line.indexOf("\t"); + const sha = tab >= 0 ? line.slice(0, tab) : line; + const subject = tab >= 0 ? line.slice(tab + 1) : ""; + return { sha: sha.slice(0, 12), subject: redactSecrets(subject).text }; + }); + + return { + changedPaths, + truncatedPaths: Math.max(0, paths.length - changedPaths.length), + commits, + truncatedCommits: Math.max(0, commitLines.length - commits.length), + }; +} + +// Integrate `source` into the current branch. Fails closed (throws a redacted +// error) on a detached HEAD, a dirty tree, an unresolvable revision, a predicted +// conflict, or a failed merge. With `dryRun`, returns the preview without merging. +export function integrateBranch( + workspace: string, + source: string, + opts: IntegrateOptions = {}, +): IntegrationResult { + const target = currentBranch(workspace); + + // Fail closed on a dirty tree, an unresolvable revision, or a predicted + // conflict (predictMergeConflict checks all three and throws on any). + const prediction = predictMergeConflict(workspace, source, target); + if (!prediction.clean) { + throw new Error( + `Selective integration error: predicted conflict on ${prediction.conflicts.length} path(s) ` + + `(${prediction.conflicts.slice(0, 5).join(", ")}${prediction.conflicts.length > 5 ? ", …" : ""}); ` + + "refusing to integrate (fail closed)", + ); + } + + const preview = buildPreview(workspace, source, target); + if (opts.dryRun) { + const head = gitCapture(workspace, ["rev-parse", "HEAD"]).stdout.trim() || null; + return { + schema: SELECTIVE_INTEGRATION_SCHEMA, + v: SELECTIVE_INTEGRATION_VERSION, + source, + target, + integrated: false, + head, + preview, + }; + } + + // Nothing to integrate: the source is already contained in the target. + if (preview.commits.length === 0) { + const head = gitCapture(workspace, ["rev-parse", "HEAD"]).stdout.trim() || null; + return { + schema: SELECTIVE_INTEGRATION_SCHEMA, + v: SELECTIVE_INTEGRATION_VERSION, + source, + target, + integrated: false, + head, + preview, + }; + } + + // Non-fast-forward merge preserves the source's commit identity. + const merge = gitCapture(workspace, ["merge", "--no-ff", "--no-edit", source]); + if (merge.status !== 0) { + throw new Error("Selective integration error: merge failed (fail closed)"); + } + const head = gitCapture(workspace, ["rev-parse", "HEAD"]).stdout.trim() || null; + return { + schema: SELECTIVE_INTEGRATION_SCHEMA, + v: SELECTIVE_INTEGRATION_VERSION, + source, + target, + integrated: true, + head, + preview, + }; +} + +// A deterministic, human-readable rendering of the integration result. It contains +// only revision names, redacted paths, and commit subjects — never file contents. +export function formatIntegrationResult(result: IntegrationResult): string { + const lines: string[] = []; + lines.push(`Selective integration (${result.schema} v${result.v})`); + lines.push(` source: ${result.source}`); + lines.push(` target: ${result.target}`); + lines.push(` result: ${result.integrated ? "INTEGRATED" : "NOT INTEGRATED (nothing to merge or dry run)"}`); + if (result.head) lines.push(` head: ${result.head}`); + lines.push(` preview:`); + lines.push(` changed paths: ${result.preview.changedPaths.length}`); + for (const path of result.preview.changedPaths) { + lines.push(` ${path}`); + } + if (result.preview.truncatedPaths > 0) { + lines.push(` … ${result.preview.truncatedPaths} more path(s) beyond the bound`); + } + lines.push(` commits: ${result.preview.commits.length}`); + for (const commit of result.preview.commits) { + lines.push(` ${commit.sha} ${commit.subject}`); + } + if (result.preview.truncatedCommits > 0) { + lines.push(` … ${result.preview.truncatedCommits} more commit(s) beyond the bound`); + } + return lines.join("\n"); +} diff --git a/tests/integration/selective-integration.test.ts b/tests/integration/selective-integration.test.ts new file mode 100644 index 0000000..d2d50a6 --- /dev/null +++ b/tests/integration/selective-integration.test.ts @@ -0,0 +1,148 @@ +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"] }); +} + +function gitOut(repo: string, ...args: string[]): string { + return execFileSync("git", ["-C", repo, ...args], { encoding: "utf8" }).trim(); +} + +const tmpDirs: string[] = []; +function makeRepo(): string { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), "omc-integrate-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: selective integration (--integrate)", () => { + let cleanRepo: string; + let conflictRepo: string; + let homeDir: string; + let env: Record; + + beforeAll(() => { + homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "omc-integrate-int-home-")); + tmpDirs.push(homeDir); + env = { HOME: homeDir, OPENAI_API_KEY: "", OPENAI_BASE_URL: "", OPENAI_MODEL: "" }; + + // Clean case: feature adds a new file (no conflict), repo left on main. + cleanRepo = makeRepo(); + git(cleanRepo, "checkout", "-q", "-b", "feature"); + fs.writeFileSync(path.join(cleanRepo, "b.txt"), "feature work\n"); + git(cleanRepo, "add", "b.txt"); + git(cleanRepo, "commit", "-q", "-m", "add b"); + git(cleanRepo, "checkout", "-q", "main"); + + // Conflict case: feature and main both edit a.txt differently, repo on main. + 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"); + }); + + afterAll(() => { + for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }); + }); + + it("integrates a clean branch (JSON), preserving commit identity via a merge commit", async () => { + const headBefore = gitOut(cleanRepo, "rev-parse", "HEAD"); + const r = await runCli( + ["--integrate", "feature", "--workspace", cleanRepo, "--output", "json"], + env, + ); + expect(r.code).toBe(0); + const result = JSON.parse(r.stdout); + expect(result.schema).toBe("oh-my-cli.selective-integration"); + expect(result.integrated).toBe(true); + expect(result.target).toBe("main"); + expect(result.preview.changedPaths).toContain("b.txt"); + + // A merge commit was created (HEAD advanced and has two parents). + const headAfter = gitOut(cleanRepo, "rev-parse", "HEAD"); + expect(headAfter).not.toBe(headBefore); + const parents = gitOut(cleanRepo, "rev-list", "--parents", "-n", "1", "HEAD").split(" "); + expect(parents.length).toBe(3); + expect(fs.existsSync(path.join(cleanRepo, "b.txt"))).toBe(true); + }); + + it("refuses a conflicting branch (fail closed, exit 2) without merging", async () => { + const headBefore = gitOut(conflictRepo, "rev-parse", "HEAD"); + const r = await runCli( + ["--integrate", "feature", "--workspace", conflictRepo, "--output", "json"], + env, + ); + expect(r.code).toBe(2); + expect(r.stderr).toContain("predicted conflict"); + // The target was not modified. + expect(gitOut(conflictRepo, "rev-parse", "HEAD")).toBe(headBefore); + }); + + it("dry run shows a preview without merging (text)", async () => { + // Use a fresh clean repo so the merge from the first test doesn't interfere. + const repo = makeRepo(); + git(repo, "checkout", "-q", "-b", "feature"); + fs.writeFileSync(path.join(repo, "b.txt"), "x\n"); + git(repo, "add", "b.txt"); + git(repo, "commit", "-q", "-m", "add b"); + git(repo, "checkout", "-q", "main"); + const headBefore = gitOut(repo, "rev-parse", "HEAD"); + + const r = await runCli( + ["--integrate", "feature", "--integrate-dry-run", "--workspace", repo], + env, + ); + expect(r.code).toBe(0); + expect(r.stdout).toContain("Selective integration (oh-my-cli.selective-integration v1)"); + expect(r.stdout).toContain("NOT INTEGRATED"); + expect(r.stdout).toContain("b.txt"); + // No merge happened. + expect(gitOut(repo, "rev-parse", "HEAD")).toBe(headBefore); + expect(fs.existsSync(path.join(repo, "b.txt"))).toBe(false); + }); + + it("exits 2 on an unresolvable source revision", async () => { + const r = await runCli( + ["--integrate", "no-such-branch", "--workspace", cleanRepo], + env, + ); + expect(r.code).toBe(2); + expect(r.stderr).toContain("cannot resolve source revision"); + }); +}); diff --git a/tests/unit/selective-integration.test.ts b/tests/unit/selective-integration.test.ts new file mode 100644 index 0000000..99f6348 --- /dev/null +++ b/tests/unit/selective-integration.test.ts @@ -0,0 +1,167 @@ +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 { + integrateBranch, + formatIntegrationResult, + SELECTIVE_INTEGRATION_SCHEMA, + SELECTIVE_INTEGRATION_VERSION, +} from "../../src/selective-integration.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"] }); +} + +function gitOut(repo: string, ...args: string[]): string { + return execFileSync("git", ["-C", repo, ...args], { encoding: "utf8" }).trim(); +} + +// A temp repo with one commit on `main` (a.txt = "base"). +function makeRepo(): string { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), "omc-integrate-")); + 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("integrateBranch", () => { + it("integrates a clean branch with a non-fast-forward merge preserving commit identity", () => { + const repo = makeRepo(); + git(repo, "checkout", "-q", "-b", "feature"); + fs.writeFileSync(path.join(repo, "b.txt"), "feature work\n"); + git(repo, "add", "b.txt"); + git(repo, "commit", "-q", "-m", "add b"); + const featureSha = gitOut(repo, "rev-parse", "HEAD"); + git(repo, "checkout", "-q", "main"); + const mainHeadBefore = gitOut(repo, "rev-parse", "HEAD"); + + const result = integrateBranch(repo, "feature"); + expect(result.schema).toBe(SELECTIVE_INTEGRATION_SCHEMA); + expect(result.v).toBe(SELECTIVE_INTEGRATION_VERSION); + expect(result.integrated).toBe(true); + expect(result.source).toBe("feature"); + expect(result.target).toBe("main"); + expect(result.head).not.toBe(mainHeadBefore); + // The preview listed the new path and the feature commit. + expect(result.preview.changedPaths).toContain("b.txt"); + expect(result.preview.commits.map((c) => c.sha)).toContain(featureSha.slice(0, 12)); + // Non-fast-forward: HEAD is a merge commit with two parents. + const parents = gitOut(repo, "rev-list", "--parents", "-n", "1", "HEAD").split(" "); + expect(parents.length).toBe(3); // commit + 2 parents + // The feature commit is preserved as an ancestor of HEAD. + git(repo, "merge-base", "--is-ancestor", featureSha, "HEAD"); + // The integrated file is present in the working tree. + expect(fs.existsSync(path.join(repo, "b.txt"))).toBe(true); + }); + + it("refuses to integrate a conflicting branch (fail closed)", () => { + const repo = makeRepo(); + 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"); + const headBefore = gitOut(repo, "rev-parse", "HEAD"); + + expect(() => integrateBranch(repo, "feature")).toThrow(/predicted conflict/); + // The target was not modified. + expect(gitOut(repo, "rev-parse", "HEAD")).toBe(headBefore); + }); + + it("fails closed on a dirty working tree", () => { + const repo = makeRepo(); + git(repo, "checkout", "-q", "-b", "feature"); + fs.writeFileSync(path.join(repo, "b.txt"), "x\n"); + git(repo, "add", "b.txt"); + git(repo, "commit", "-q", "-m", "feature"); + git(repo, "checkout", "-q", "main"); + fs.writeFileSync(path.join(repo, "a.txt"), "uncommitted\n"); + expect(() => integrateBranch(repo, "feature")).toThrow(/working tree is dirty/); + }); + + it("fails closed on an unresolvable source revision", () => { + const repo = makeRepo(); + expect(() => integrateBranch(repo, "no-such-branch")).toThrow(/cannot resolve source revision/); + }); + + it("fails closed on a detached HEAD", () => { + const repo = makeRepo(); + git(repo, "checkout", "-q", "-b", "feature"); + fs.writeFileSync(path.join(repo, "b.txt"), "x\n"); + git(repo, "add", "b.txt"); + git(repo, "commit", "-q", "-m", "feature"); + git(repo, "checkout", "-q", "--detach", "main"); + expect(() => integrateBranch(repo, "feature")).toThrow(/detached HEAD/); + }); + + it("dry run returns a preview without merging", () => { + const repo = makeRepo(); + git(repo, "checkout", "-q", "-b", "feature"); + fs.writeFileSync(path.join(repo, "b.txt"), "x\n"); + git(repo, "add", "b.txt"); + git(repo, "commit", "-q", "-m", "add b"); + git(repo, "checkout", "-q", "main"); + const headBefore = gitOut(repo, "rev-parse", "HEAD"); + + const result = integrateBranch(repo, "feature", { dryRun: true }); + expect(result.integrated).toBe(false); + expect(result.preview.changedPaths).toContain("b.txt"); + // No merge happened. + expect(gitOut(repo, "rev-parse", "HEAD")).toBe(headBefore); + expect(fs.existsSync(path.join(repo, "b.txt"))).toBe(false); + }); + + it("reports nothing to integrate when the source is already contained", () => { + const repo = makeRepo(); + git(repo, "checkout", "-q", "-b", "feature"); // feature at base, no new commits + git(repo, "checkout", "-q", "main"); + fs.writeFileSync(path.join(repo, "c.txt"), "main advances\n"); + git(repo, "add", "c.txt"); + git(repo, "commit", "-q", "-m", "main advance"); + + const result = integrateBranch(repo, "feature"); + expect(result.integrated).toBe(false); + expect(result.preview.commits).toEqual([]); + }); +}); + +describe("formatIntegrationResult", () => { + it("renders an integrated result with preview", () => { + const text = formatIntegrationResult({ + schema: SELECTIVE_INTEGRATION_SCHEMA, + v: SELECTIVE_INTEGRATION_VERSION, + source: "feature", + target: "main", + integrated: true, + head: "abc123", + preview: { + changedPaths: ["b.txt"], + truncatedPaths: 0, + commits: [{ sha: "def456", subject: "add b" }], + truncatedCommits: 0, + }, + }); + expect(text).toContain(`Selective integration (${SELECTIVE_INTEGRATION_SCHEMA} v${SELECTIVE_INTEGRATION_VERSION})`); + expect(text).toContain("source: feature"); + expect(text).toContain("target: main"); + expect(text).toContain("result: INTEGRATED"); + expect(text).toContain("head: abc123"); + expect(text).toContain("b.txt"); + expect(text).toContain("def456 add b"); + }); +});