/*
warpgogol-m000196: regression tests for resolveOursConflicts — the
reconcile merge auto-resolution for cache-clone-only paths. Covers both
"ours" meanings: restore the clone HEAD version (path tracked in HEAD,
UU conflict) and keep the path ABSENT (gitignored RFC-0913 file arriving
tracked from the workpiece side, DU conflict — the case that failed real
reconciles with "pathspec did not match" before the fix).
- Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only.
- warpgogol-m000196: initial tests — real temp git repos, DU absent-in-HEAD and UU present-in-HEAD scenarios.
*/
import { test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { execSync } from "node:child_process";
import { tmpdir } from "node:os";
import { createDefaultIO } from "@warpgogol/werkstatt-engine/kernel";
import { resolveOursConflicts } from "../mission-materialization-commands.ts";
function git(dir: string, cmd: string): string {
return execSync(`git ${cmd}`, { cwd: dir, stdio: ["ignore", "pipe", "pipe"] })
.toString()
.trim();
}
function gitInit(dir: string): void {
execSync("git init -q", { cwd: dir, stdio: "pipe" });
execSync("git config user.email test@test.com", { cwd: dir, stdio: "pipe" });
execSync("git config user.name Test", { cwd: dir, stdio: "pipe" });
execSync("git config commit.gpgsign false", { cwd: dir, stdio: "pipe" });
}
function gitCommit(dir: string, msg: string): void {
execSync("git add -A", { cwd: dir, stdio: "pipe" });
execSync(`git commit -q -m ${JSON.stringify(msg)}`, { cwd: dir, stdio: "pipe" });
}
let tmpDir: string;
let cloneDir: string;
let workpieceDir: string;
const io = createDefaultIO().io;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "tmp-ours-conflicts-"));
cloneDir = join(tmpDir, "clone");
workpieceDir = join(tmpDir, "workpiece");
mkdirSync(cloneDir);
gitInit(cloneDir);
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
/**
* Shared scenario mirroring the real reconcile state: the merge BASE tracks a
* cache-clone-only file; the clone later untracks it (RFC-0913 — deleted in
* HEAD, gitignored); the workpiece modifies both the shared file and the
* clone-only file. Merge → shared.txt UU (both modified), clone-only file DU
* (deleted in HEAD, modified in FETCH_HEAD).
*/
function seedAndMerge(): void {
// Base commit in the clone — the merge-base both sides share (mirrors the
// real topology: the workpiece is materialized from the clone's history).
// Both cache-clone-only files are tracked in the base (historical drift).
writeFileSync(join(cloneDir, "shared.txt"), "base version\n");
writeFileSync(join(cloneDir, "behavior.snapshot.generated.yaml"), "routes: 1\n");
mkdirSync(join(cloneDir, "bordbuch"));
writeFileSync(join(cloneDir, "bordbuch", "status.generated.yaml"), "status: old\n");
gitCommit(cloneDir, "base");
// Workpiece: a clone of the base repo, then diverge — modify shared.txt +
// the clone-only files (the drift mission.validate auto-commits on the
// workpiece side).
execSync(`git clone -q ${JSON.stringify(cloneDir)} ${JSON.stringify(workpieceDir)}`, {
stdio: "pipe",
});
writeFileSync(join(workpieceDir, "shared.txt"), "workpiece version\n");
writeFileSync(join(workpieceDir, "behavior.snapshot.generated.yaml"), "routes: 123\n");
writeFileSync(join(workpieceDir, "bordbuch", "status.generated.yaml"), "status: ok\n");
gitCommit(workpieceDir, "workpiece: content + generated artifacts");
// Clone HEAD: operational edit on shared.txt (→ UU on merge) + untrack the
// clone-only files (RFC-0913, → DU on merge) + gitignore them.
writeFileSync(join(cloneDir, "shared.txt"), "clone operational edit\n");
execSync("git rm -q --cached behavior.snapshot.generated.yaml", { cwd: cloneDir, stdio: "pipe" });
execSync("git rm -q --cached bordbuch/status.generated.yaml", { cwd: cloneDir, stdio: "pipe" });
rmSync(join(cloneDir, "behavior.snapshot.generated.yaml"));
rmSync(join(cloneDir, "bordbuch", "status.generated.yaml"));
writeFileSync(
join(cloneDir, ".gitignore"),
"behavior.snapshot.generated.yaml\nbordbuch/status.generated.yaml\n",
);
gitCommit(cloneDir, "clone: operational edit + untrack forbidden generated files");
// Explicit-refspec fetch — mirrors the reconcile fetch
// (`git fetch `); a bare `git fetch origin` marks the
// head not-for-merge and "merging" it is a no-op.
const workpieceBranch = git(workpieceDir, "rev-parse --abbrev-ref HEAD");
git(cloneDir, `fetch ${JSON.stringify(workpieceDir)} ${workpieceBranch}`);
// Merge conflicts: shared.txt = UU (both modified), clone-only files = DU
// (deleted in HEAD, modified in FETCH_HEAD).
let mergeFailed = false;
try {
execSync("git merge --no-ff FETCH_HEAD -m merge", { cwd: cloneDir, stdio: "pipe" });
} catch {
mergeFailed = true;
}
expect(mergeFailed).toBe(true);
const status = git(cloneDir, "status --porcelain");
expect(status).toContain("UU shared.txt");
expect(status).toContain("DU behavior.snapshot.generated.yaml");
expect(status).toContain("DU bordbuch/status.generated.yaml");
}
test("ours-resolution keeps clone-only paths ABSENT when clone HEAD never tracked them", async () => {
seedAndMerge();
// The exact pre-fix failure: git checkout HEAD -- on a path absent from HEAD
// throws "pathspec did not match". resolveOursConflicts must instead git rm.
await resolveOursConflicts(
cloneDir,
["behavior.snapshot.generated.yaml", "bordbuch/status.generated.yaml"],
io,
);
const status = git(cloneDir, "status --porcelain");
// The clone-only conflicts are resolved (no DU left for them).
expect(status).not.toContain("DU behavior.snapshot.generated.yaml");
expect(status).not.toContain("DU bordbuch/status.generated.yaml");
// The worktree no longer carries the workpiece-side copies.
expect(existsSync(join(cloneDir, "behavior.snapshot.generated.yaml"))).toBe(false);
expect(existsSync(join(cloneDir, "bordbuch", "status.generated.yaml"))).toBe(false);
// shared.txt remains conflicted (untouched by the ours-resolution).
expect(status).toContain("UU shared.txt");
});
test("ours-resolution restores the clone HEAD version for paths tracked in HEAD", async () => {
seedAndMerge();
await resolveOursConflicts(cloneDir, ["shared.txt"], io);
const status = git(cloneDir, "status --porcelain");
expect(status).not.toContain("UU shared.txt");
expect(readFileSync(join(cloneDir, "shared.txt"), "utf8")).toBe("clone operational edit\n");
});
test("ours-resolution handles a mixed batch (absent + present) in one call", async () => {
seedAndMerge();
await resolveOursConflicts(cloneDir, ["behavior.snapshot.generated.yaml", "shared.txt"], io);
const status = git(cloneDir, "status --porcelain");
expect(status).not.toContain("DU behavior.snapshot.generated.yaml");
expect(status).not.toContain("UU shared.txt");
expect(existsSync(join(cloneDir, "behavior.snapshot.generated.yaml"))).toBe(false);
expect(readFileSync(join(cloneDir, "shared.txt"), "utf8")).toBe("clone operational edit\n");
});