704 lines
28 KiB
TypeScript
704 lines
28 KiB
TypeScript
// apps/desktop/git/__tests__/git-sys-real.test.ts
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { promises as fsp } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { execFile, execFileSync } from 'node:child_process';
|
|
import { promisify } from 'node:util';
|
|
import { sysClone, sysFetch, sysPush, sysAheadBehind, mapSysError } from '../git-sys';
|
|
import {
|
|
sysMergeNoCommit,
|
|
sysListUnresolved,
|
|
readMergeHead,
|
|
sysShowStageBlob,
|
|
sysRestoreOurs,
|
|
sysStageFile,
|
|
sysFinalizeMerge,
|
|
sysAbortMerge,
|
|
sysReadHead,
|
|
} from '../worktree-merge';
|
|
import { mkTempDir } from './test-helpers';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
// Synchronous availability probe at module load. We can't use the async
|
|
// isSystemGitAvailable() because vitest's it.skipIf() reads its predicate at
|
|
// test-collection time, before any beforeEach hook has run.
|
|
let systemGitAvailable: boolean;
|
|
try {
|
|
execFileSync('git', ['--version'], { stdio: 'ignore', timeout: 5000 });
|
|
systemGitAvailable = true;
|
|
} catch {
|
|
systemGitAvailable = false;
|
|
}
|
|
|
|
describe('git-sys real (gated on system git)', () => {
|
|
let temp: { dir: string; dispose: () => Promise<void> };
|
|
|
|
beforeEach(async () => {
|
|
temp = await mkTempDir();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (temp) await temp.dispose();
|
|
});
|
|
|
|
it.skipIf(!systemGitAvailable)('clones a local bare remote', async () => {
|
|
const remoteDir = join(temp.dir, 'remote.git');
|
|
const sourceDir = join(temp.dir, 'source');
|
|
const cloneDir = join(temp.dir, 'clone');
|
|
|
|
// Set up: bare remote, source repo with one commit, push source → remote.
|
|
await execFileAsync('git', ['init', '--bare', '-b', 'main', remoteDir]);
|
|
await fsp.mkdir(sourceDir, { recursive: true });
|
|
await execFileAsync('git', ['init', '-b', 'main', sourceDir]);
|
|
await fsp.writeFile(join(sourceDir, 'README.md'), '# test\n');
|
|
await execFileAsync('git', ['add', '.'], { cwd: sourceDir });
|
|
await execFileAsync(
|
|
'git',
|
|
['-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'init'],
|
|
{ cwd: sourceDir },
|
|
);
|
|
await execFileAsync('git', ['remote', 'add', 'origin', remoteDir], { cwd: sourceDir });
|
|
await execFileAsync('git', ['push', 'origin', 'main'], { cwd: sourceDir });
|
|
|
|
// Now clone via sysClone.
|
|
await sysClone({ url: remoteDir, dest: cloneDir });
|
|
|
|
// Verify the clone has the README.
|
|
const content = await fsp.readFile(join(cloneDir, 'README.md'), 'utf-8');
|
|
expect(content).toBe('# test\n');
|
|
});
|
|
|
|
it.skipIf(!systemGitAvailable)('fetch updates remote-tracking refs', async () => {
|
|
const remoteDir = join(temp.dir, 'remote.git');
|
|
const aDir = join(temp.dir, 'a');
|
|
const bDir = join(temp.dir, 'b');
|
|
|
|
await execFileAsync('git', ['init', '--bare', '-b', 'main', remoteDir]);
|
|
// a: clone, commit, push
|
|
await execFileAsync('git', ['clone', remoteDir, aDir]);
|
|
await execFileAsync('git', ['-C', aDir, 'checkout', '-b', 'main']);
|
|
await fsp.writeFile(join(aDir, 'one.txt'), '1');
|
|
await execFileAsync('git', ['-C', aDir, 'add', '.']);
|
|
await execFileAsync(
|
|
'git',
|
|
['-C', aDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'one'],
|
|
{},
|
|
);
|
|
await execFileAsync('git', ['-C', aDir, 'push', '-u', 'origin', 'main']);
|
|
|
|
// b: clone the same remote (now has main with one.txt)
|
|
await execFileAsync('git', ['clone', remoteDir, bDir]);
|
|
|
|
// a commits another file and pushes
|
|
await fsp.writeFile(join(aDir, 'two.txt'), '2');
|
|
await execFileAsync('git', ['-C', aDir, 'add', '.']);
|
|
await execFileAsync(
|
|
'git',
|
|
['-C', aDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'two'],
|
|
{},
|
|
);
|
|
await execFileAsync('git', ['-C', aDir, 'push']);
|
|
|
|
// b's ahead/behind before fetch should be 0/0 (b doesn't know about the new commit yet).
|
|
const before = await sysAheadBehind({ cwd: bDir, branch: 'main' });
|
|
expect(before).toEqual({ ahead: 0, behind: 0 });
|
|
|
|
// Fetch updates b's remote-tracking ref.
|
|
await sysFetch({ cwd: bDir });
|
|
const after = await sysAheadBehind({ cwd: bDir, branch: 'main' });
|
|
expect(after).toEqual({ ahead: 0, behind: 1 });
|
|
});
|
|
|
|
it.skipIf(!systemGitAvailable)('push to local bare remote succeeds', async () => {
|
|
const remoteDir = join(temp.dir, 'remote.git');
|
|
const cloneDir = join(temp.dir, 'clone');
|
|
|
|
await execFileAsync('git', ['init', '--bare', '-b', 'main', remoteDir]);
|
|
await execFileAsync('git', ['clone', remoteDir, cloneDir]);
|
|
await execFileAsync('git', ['-C', cloneDir, 'checkout', '-b', 'main']);
|
|
await fsp.writeFile(join(cloneDir, 'a.txt'), 'a');
|
|
await execFileAsync('git', ['-C', cloneDir, 'add', '.']);
|
|
await execFileAsync(
|
|
'git',
|
|
['-C', cloneDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'a'],
|
|
{},
|
|
);
|
|
|
|
await sysPush({ cwd: cloneDir, branch: 'main' });
|
|
|
|
// Verify the bare remote has main pointing at the clone's commit.
|
|
const { stdout: remoteHead } = await execFileAsync('git', [
|
|
'-C',
|
|
remoteDir,
|
|
'rev-parse',
|
|
'main',
|
|
]);
|
|
const { stdout: cloneHead } = await execFileAsync('git', ['-C', cloneDir, 'rev-parse', 'HEAD']);
|
|
expect(remoteHead.trim()).toBe(cloneHead.trim());
|
|
});
|
|
|
|
it.skipIf(!systemGitAvailable)('push non-fast-forward is rejected', async () => {
|
|
const remoteDir = join(temp.dir, 'remote.git');
|
|
const aDir = join(temp.dir, 'a');
|
|
const bDir = join(temp.dir, 'b');
|
|
|
|
await execFileAsync('git', ['init', '--bare', '-b', 'main', remoteDir]);
|
|
// a: seed remote with one commit
|
|
await execFileAsync('git', ['clone', remoteDir, aDir]);
|
|
await execFileAsync('git', ['-C', aDir, 'checkout', '-b', 'main']);
|
|
await fsp.writeFile(join(aDir, 'one.txt'), '1');
|
|
await execFileAsync('git', ['-C', aDir, 'add', '.']);
|
|
await execFileAsync(
|
|
'git',
|
|
['-C', aDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'one'],
|
|
{},
|
|
);
|
|
await execFileAsync('git', ['-C', aDir, 'push', '-u', 'origin', 'main']);
|
|
|
|
// b: clone, then a pushes a 2nd commit
|
|
await execFileAsync('git', ['clone', remoteDir, bDir]);
|
|
await fsp.writeFile(join(aDir, 'two.txt'), '2');
|
|
await execFileAsync('git', ['-C', aDir, 'add', '.']);
|
|
await execFileAsync(
|
|
'git',
|
|
['-C', aDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'two'],
|
|
{},
|
|
);
|
|
await execFileAsync('git', ['-C', aDir, 'push']);
|
|
|
|
// b makes a divergent commit and tries to push → rejected.
|
|
await fsp.writeFile(join(bDir, 'b.txt'), 'b');
|
|
await execFileAsync('git', ['-C', bDir, 'add', '.']);
|
|
await execFileAsync(
|
|
'git',
|
|
['-C', bDir, '-c', 'user.name=t', '-c', 'user.email=t@e.com', 'commit', '-m', 'b'],
|
|
{},
|
|
);
|
|
await expect(sysPush({ cwd: bDir, branch: 'main' })).rejects.toMatchObject({
|
|
name: 'GitError',
|
|
code: 'push-rejected',
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase 7a: worktree-merge real-git spike tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('worktree-merge real-git spike (gated on system git)', () => {
|
|
let temp: { dir: string; dispose: () => Promise<void> };
|
|
|
|
beforeEach(async () => {
|
|
temp = await mkTempDir();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (temp) await temp.dispose();
|
|
});
|
|
|
|
/**
|
|
* Helper: create a repo with two divergent branches, each modifying the
|
|
* tracked .op file (and optionally a README.md side file).
|
|
*/
|
|
async function setupDivergentRepo(opts: {
|
|
withReadme?: boolean;
|
|
readmeConflict?: boolean;
|
|
}): Promise<{ repoDir: string; gitdir: string }> {
|
|
const repoDir = join(temp.dir, 'repo');
|
|
await fsp.mkdir(repoDir, { recursive: true });
|
|
|
|
const g = (...args: string[]) => execFileAsync('git', args, { cwd: repoDir });
|
|
const gc = (...args: string[]) =>
|
|
execFileAsync('git', ['-c', 'user.name=t', '-c', 'user.email=t@e.com', ...args], {
|
|
cwd: repoDir,
|
|
});
|
|
|
|
await g('init', '-b', 'main');
|
|
// sysMergeNoCommit reads identity during git's internal bookkeeping;
|
|
// make tests self-sufficient for machines without global git config.
|
|
await g('config', 'user.name', 'Test');
|
|
await g('config', 'user.email', 'test@test.com');
|
|
await fsp.writeFile(
|
|
join(repoDir, 'design.op'),
|
|
JSON.stringify({ version: '1.0.0', children: [{ id: 'base' }] }),
|
|
);
|
|
if (opts.withReadme) {
|
|
await fsp.writeFile(join(repoDir, 'README.md'), '# Base\n');
|
|
}
|
|
await g('add', '.');
|
|
await gc('commit', '-m', 'base');
|
|
|
|
// Branch off: feature changes
|
|
await g('checkout', '-b', 'feature');
|
|
await fsp.writeFile(
|
|
join(repoDir, 'design.op'),
|
|
JSON.stringify({ version: '1.0.0', children: [{ id: 'theirs' }] }),
|
|
);
|
|
if (opts.withReadme && opts.readmeConflict) {
|
|
await fsp.writeFile(join(repoDir, 'README.md'), '# Feature\n');
|
|
}
|
|
await g('add', '.');
|
|
await gc('commit', '-m', 'theirs');
|
|
|
|
// Return to main: ours changes
|
|
await g('checkout', 'main');
|
|
await fsp.writeFile(
|
|
join(repoDir, 'design.op'),
|
|
JSON.stringify({ version: '1.0.0', children: [{ id: 'ours' }] }),
|
|
);
|
|
if (opts.withReadme && opts.readmeConflict) {
|
|
await fsp.writeFile(join(repoDir, 'README.md'), '# Main\n');
|
|
}
|
|
await g('add', '.');
|
|
await gc('commit', '-m', 'ours');
|
|
|
|
const gitdir = join(repoDir, '.git');
|
|
return { repoDir, gitdir };
|
|
}
|
|
|
|
it.skipIf(!systemGitAvailable)(
|
|
'sysMergeNoCommit returns conflict and MERGE_HEAD is set',
|
|
async () => {
|
|
const { repoDir, gitdir } = await setupDivergentRepo({});
|
|
|
|
const result = await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' });
|
|
expect(result.kind).toBe('conflict');
|
|
|
|
const mergeHead = await readMergeHead(gitdir);
|
|
expect(mergeHead).not.toBeNull();
|
|
expect(mergeHead).toMatch(/^[a-f0-9]{40}$/);
|
|
},
|
|
);
|
|
|
|
it.skipIf(!systemGitAvailable)(
|
|
'sysListUnresolved lists the tracked .op file as unresolved',
|
|
async () => {
|
|
const { repoDir } = await setupDivergentRepo({});
|
|
await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' });
|
|
|
|
const unresolved = await sysListUnresolved({ cwd: repoDir });
|
|
expect(unresolved).toContain('design.op');
|
|
},
|
|
);
|
|
|
|
it.skipIf(!systemGitAvailable)(
|
|
'sysListUnresolved lists both .op and README when both conflict',
|
|
async () => {
|
|
const { repoDir } = await setupDivergentRepo({ withReadme: true, readmeConflict: true });
|
|
await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' });
|
|
|
|
const unresolved = await sysListUnresolved({ cwd: repoDir });
|
|
expect(unresolved).toContain('design.op');
|
|
expect(unresolved).toContain('README.md');
|
|
},
|
|
);
|
|
|
|
it.skipIf(!systemGitAvailable)(
|
|
'sysShowStageBlob reads base/ours/theirs from the index',
|
|
async () => {
|
|
const { repoDir } = await setupDivergentRepo({});
|
|
await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' });
|
|
|
|
const base = await sysShowStageBlob({ cwd: repoDir, stage: 1, filepath: 'design.op' });
|
|
const ours = await sysShowStageBlob({ cwd: repoDir, stage: 2, filepath: 'design.op' });
|
|
const theirs = await sysShowStageBlob({ cwd: repoDir, stage: 3, filepath: 'design.op' });
|
|
|
|
expect(JSON.parse(base!).children[0].id).toBe('base');
|
|
expect(JSON.parse(ours!).children[0].id).toBe('ours');
|
|
expect(JSON.parse(theirs!).children[0].id).toBe('theirs');
|
|
},
|
|
);
|
|
|
|
it.skipIf(!systemGitAvailable)(
|
|
'sysRestoreOurs writes readable JSON and keeps MERGE_HEAD alive',
|
|
async () => {
|
|
const { repoDir, gitdir } = await setupDivergentRepo({});
|
|
await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' });
|
|
|
|
await sysRestoreOurs({ cwd: repoDir, filepath: 'design.op' });
|
|
|
|
// File on disk is now readable JSON.
|
|
const content = await fsp.readFile(join(repoDir, 'design.op'), 'utf-8');
|
|
expect(() => JSON.parse(content)).not.toThrow();
|
|
expect(JSON.parse(content).children[0].id).toBe('ours');
|
|
|
|
// MERGE_HEAD is still set.
|
|
const mergeHead = await readMergeHead(gitdir);
|
|
expect(mergeHead).not.toBeNull();
|
|
|
|
// File is still listed as unresolved in the index.
|
|
const unresolved = await sysListUnresolved({ cwd: repoDir });
|
|
expect(unresolved).toContain('design.op');
|
|
},
|
|
);
|
|
|
|
it.skipIf(!systemGitAvailable)(
|
|
'sysStageFile marks file as resolved so sysListUnresolved no longer includes it',
|
|
async () => {
|
|
const { repoDir } = await setupDivergentRepo({});
|
|
await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' });
|
|
|
|
// Write the final content and stage it.
|
|
await fsp.writeFile(
|
|
join(repoDir, 'design.op'),
|
|
JSON.stringify({ version: '1.0.0', children: [{ id: 'resolved' }] }),
|
|
);
|
|
await sysStageFile({ cwd: repoDir, filepath: 'design.op' });
|
|
|
|
const unresolved = await sysListUnresolved({ cwd: repoDir });
|
|
expect(unresolved).not.toContain('design.op');
|
|
},
|
|
);
|
|
|
|
it.skipIf(!systemGitAvailable)(
|
|
'sysFinalizeMerge creates a 2-parent merge commit and clears MERGE_HEAD',
|
|
async () => {
|
|
const { repoDir, gitdir } = await setupDivergentRepo({});
|
|
const headBefore = await sysReadHead({ cwd: repoDir });
|
|
await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' });
|
|
|
|
// Resolve the conflict.
|
|
await fsp.writeFile(
|
|
join(repoDir, 'design.op'),
|
|
JSON.stringify({ version: '1.0.0', children: [{ id: 'resolved' }] }),
|
|
);
|
|
await sysStageFile({ cwd: repoDir, filepath: 'design.op' });
|
|
|
|
const mergeCommit = await sysFinalizeMerge({
|
|
cwd: repoDir,
|
|
message: 'Merge feature into main',
|
|
author: { name: 'Test', email: 'test@test.com' },
|
|
});
|
|
|
|
expect(mergeCommit).toMatch(/^[a-f0-9]{40}$/);
|
|
expect(mergeCommit).not.toBe(headBefore);
|
|
|
|
// MERGE_HEAD is gone.
|
|
const mergeHead = await readMergeHead(gitdir);
|
|
expect(mergeHead).toBeNull();
|
|
|
|
// Verify 2-parent commit via git cat-file.
|
|
const catResult = await execFileAsync('git', ['cat-file', '-p', 'HEAD'], { cwd: repoDir });
|
|
const parentLines = catResult.stdout.split('\n').filter((line) => line.startsWith('parent '));
|
|
expect(parentLines).toHaveLength(2);
|
|
},
|
|
);
|
|
|
|
it.skipIf(!systemGitAvailable)(
|
|
'sysAbortMerge restores working tree and clears MERGE_HEAD',
|
|
async () => {
|
|
const { repoDir, gitdir } = await setupDivergentRepo({});
|
|
const headBefore = await sysReadHead({ cwd: repoDir });
|
|
await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' });
|
|
|
|
await sysAbortMerge({ cwd: repoDir });
|
|
|
|
// MERGE_HEAD is gone.
|
|
const mergeHead = await readMergeHead(gitdir);
|
|
expect(mergeHead).toBeNull();
|
|
|
|
// HEAD is unchanged.
|
|
const headAfter = await sysReadHead({ cwd: repoDir });
|
|
expect(headAfter).toBe(headBefore);
|
|
|
|
// design.op is the ours version (clean JSON, no conflict markers).
|
|
const content = await fsp.readFile(join(repoDir, 'design.op'), 'utf-8');
|
|
expect(() => JSON.parse(content)).not.toThrow();
|
|
expect(JSON.parse(content).children[0].id).toBe('ours');
|
|
},
|
|
);
|
|
|
|
it.skipIf(!systemGitAvailable)(
|
|
'sysAbortMerge is idempotent when no merge is in progress',
|
|
async () => {
|
|
const { repoDir } = await setupDivergentRepo({});
|
|
// No merge started — abort should not throw.
|
|
await expect(sysAbortMerge({ cwd: repoDir })).resolves.toBeUndefined();
|
|
},
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase 7a spike scenario 3: rename conflict
|
|
// Documents what git actually does when the tracked .op file is RENAMED
|
|
// on the feature branch while also being modified on both branches.
|
|
//
|
|
// Setup:
|
|
// base: design.op (base content)
|
|
// main: design.op (modified — id: 'ours')
|
|
// feature: design-v2.op (renamed + modified — id: 'theirs')
|
|
//
|
|
// Expected git behavior after `git merge --no-commit --no-ff feature`:
|
|
// - exitCode 1 (conflict)
|
|
// - `git ls-files -u` lists BOTH "design.op" (deleted-by-them, stages 1+2)
|
|
// AND "design-v2.op" (added-by-them, stage 3 only)
|
|
// - stage 3 blob for "design.op" is absent (file was renamed away on theirs)
|
|
// - stage 1/2 blobs for "design-v2.op" are absent (file is new on theirs)
|
|
//
|
|
// Engine implication (verified in the engine test below):
|
|
// Since stage 3 blob for the tracked "design.op" is missing, the engine
|
|
// falls through to { result: 'conflict-non-op' }. This is CORRECT because
|
|
// we cannot perform a semantic merge when theirs renamed the tracked file.
|
|
// ---------------------------------------------------------------------------
|
|
it.skipIf(!systemGitAvailable)(
|
|
'spike scenario 3: rename conflict — sysListUnresolved reports both old and new name',
|
|
async () => {
|
|
const repoDir = join(temp.dir, 'repo-rename');
|
|
await fsp.mkdir(repoDir, { recursive: true });
|
|
|
|
const g = (...args: string[]) => execFileAsync('git', args, { cwd: repoDir });
|
|
const gc = (...args: string[]) =>
|
|
execFileAsync('git', ['-c', 'user.name=t', '-c', 'user.email=t@e.com', ...args], {
|
|
cwd: repoDir,
|
|
});
|
|
|
|
await g('init', '-b', 'main');
|
|
await fsp.writeFile(
|
|
join(repoDir, 'design.op'),
|
|
JSON.stringify({ version: '1.0.0', children: [{ id: 'base' }] }),
|
|
);
|
|
await g('add', '.');
|
|
await gc('commit', '-m', 'base');
|
|
|
|
// feature branch: rename design.op → design-v2.op and modify content.
|
|
await g('checkout', '-b', 'feature');
|
|
await fsp.rename(join(repoDir, 'design.op'), join(repoDir, 'design-v2.op'));
|
|
// Overwrite the new name with different content so there's a real content diff.
|
|
await fsp.writeFile(
|
|
join(repoDir, 'design-v2.op'),
|
|
JSON.stringify({ version: '1.0.0', children: [{ id: 'theirs' }] }),
|
|
);
|
|
await g('add', '-A');
|
|
await gc('commit', '-m', 'rename to design-v2.op');
|
|
|
|
// main branch: modify design.op in place (divergent from the feature rename).
|
|
await g('checkout', 'main');
|
|
await fsp.writeFile(
|
|
join(repoDir, 'design.op'),
|
|
JSON.stringify({ version: '1.0.0', children: [{ id: 'ours' }] }),
|
|
);
|
|
await g('add', '.');
|
|
await gc('commit', '-m', 'ours');
|
|
|
|
// Attempt merge — expect conflict.
|
|
const mergeResult = await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' });
|
|
expect(mergeResult.kind).toBe('conflict');
|
|
|
|
// SPIKE FINDING: git reports the rename as a conflict by listing the old
|
|
// path ("design.op") and possibly the new path ("design-v2.op") as unresolved.
|
|
// The exact set depends on git version and rename detection thresholds.
|
|
const unresolved = await sysListUnresolved({ cwd: repoDir });
|
|
|
|
// The original tracked file must appear in the unresolved list because
|
|
// git detects a rename conflict involving it.
|
|
expect(unresolved).toContain('design.op');
|
|
|
|
// Stage 3 blob for the ORIGINAL path must be absent (theirs renamed it away).
|
|
const stage3Original = await sysShowStageBlob({
|
|
cwd: repoDir,
|
|
stage: 3,
|
|
filepath: 'design.op',
|
|
});
|
|
expect(stage3Original).toBeNull();
|
|
|
|
// Stage 2 blob for the ORIGINAL path (ours) must be present.
|
|
const stage2Original = await sysShowStageBlob({
|
|
cwd: repoDir,
|
|
stage: 2,
|
|
filepath: 'design.op',
|
|
});
|
|
expect(stage2Original).not.toBeNull();
|
|
expect(JSON.parse(stage2Original!).children[0].id).toBe('ours');
|
|
|
|
// CONCLUSION: the engine checks for all three stages of trackedRel.
|
|
// When stage 3 is null, it returns { result: 'conflict-non-op' }.
|
|
// This is correct: the user must resolve the rename in a terminal.
|
|
},
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase 7a spike scenario 4: dirty working tree behavior
|
|
//
|
|
// The plan says the renderer-side `withCleanWorkingTree` gate should block
|
|
// merge attempts when the tracked file has uncommitted changes. This test
|
|
// documents what git *actually does* when that gate is bypassed — establishing
|
|
// the engine-layer contract: "the engine trusts callers to gate dirty trees;
|
|
// if they don't, here is what git does."
|
|
//
|
|
// Spike setup:
|
|
// - Both branches have a divergent commit on design.op (true 3-way merge
|
|
// scenario, not a fast-forward), so git must merge the working tree.
|
|
// - The working tree has an ADDITIONAL uncommitted change on top of the
|
|
// committed ours version.
|
|
//
|
|
// Spike finding:
|
|
// git merge --no-commit --no-ff with dirty tracked files that the merge
|
|
// would touch exits with a non-zero code and a "local changes would be
|
|
// overwritten" message. sysMergeNoCommit sees exit code != 0 and != 1,
|
|
// so it throws a GitError('engine-crash'). The dirty content is NOT
|
|
// silently lost or overwritten.
|
|
//
|
|
// This confirms that the renderer gate is the right place for the check:
|
|
// the engine will throw (not silently corrupt) if called with a dirty tree.
|
|
// ---------------------------------------------------------------------------
|
|
it.skipIf(!systemGitAvailable)(
|
|
'spike scenario 4: sysMergeNoCommit throws engine-crash when dirty tracked file would be overwritten',
|
|
async () => {
|
|
const repoDir = join(temp.dir, 'repo-dirty');
|
|
await fsp.mkdir(repoDir, { recursive: true });
|
|
|
|
const g = (...args: string[]) => execFileAsync('git', args, { cwd: repoDir });
|
|
const gc = (...args: string[]) =>
|
|
execFileAsync('git', ['-c', 'user.name=t', '-c', 'user.email=t@e.com', ...args], {
|
|
cwd: repoDir,
|
|
});
|
|
|
|
// Base commit on main.
|
|
await g('init', '-b', 'main');
|
|
await fsp.writeFile(
|
|
join(repoDir, 'design.op'),
|
|
JSON.stringify({ version: '1.0.0', children: [{ id: 'base' }] }),
|
|
);
|
|
await g('add', '.');
|
|
await gc('commit', '-m', 'base');
|
|
|
|
// feature branch: modify design.op and commit.
|
|
await g('checkout', '-b', 'feature');
|
|
await fsp.writeFile(
|
|
join(repoDir, 'design.op'),
|
|
JSON.stringify({ version: '1.0.0', children: [{ id: 'theirs' }] }),
|
|
);
|
|
await g('add', '.');
|
|
await gc('commit', '-m', 'theirs');
|
|
|
|
// main: ALSO make a divergent commit (creates a true 3-way merge).
|
|
await g('checkout', 'main');
|
|
await fsp.writeFile(
|
|
join(repoDir, 'design.op'),
|
|
JSON.stringify({ version: '1.0.0', children: [{ id: 'ours' }] }),
|
|
);
|
|
await g('add', '.');
|
|
await gc('commit', '-m', 'ours');
|
|
|
|
// Now dirty the tracked file AFTER committing (uncommitted working-tree change).
|
|
await fsp.writeFile(
|
|
join(repoDir, 'design.op'),
|
|
JSON.stringify({ version: '1.0.0', children: [{ id: 'dirty-uncommitted' }] }),
|
|
);
|
|
// Do NOT stage or commit — file is now dirty.
|
|
|
|
// SPIKE: attempt the merge. Git detects the dirty tracked file would be
|
|
// overwritten by the merge and exits with a non-zero code OTHER than 1
|
|
// (typically exit code 1 but with a "would be overwritten" message, or
|
|
// exit code 128 on some git versions). Either way, sysMergeNoCommit
|
|
// either throws or returns { kind: 'conflict' } (if git wrote markers).
|
|
//
|
|
// The critical contract: the dirty content is NEVER silently discarded.
|
|
let threwOrConflict = false;
|
|
try {
|
|
const mergeResult = await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' });
|
|
// If sysMergeNoCommit did not throw, git returned exit code 0 or 1.
|
|
// Exit code 1 means it entered a conflict state — verify the dirty
|
|
// content is preserved in conflict markers or the file is unresolved.
|
|
if (mergeResult.kind === 'conflict') {
|
|
threwOrConflict = true;
|
|
const unresolved = await sysListUnresolved({ cwd: repoDir });
|
|
// design.op must be listed — dirty working tree + merge conflict.
|
|
expect(unresolved).toContain('design.op');
|
|
// The working tree file should contain conflict markers (not clean JSON).
|
|
const raw = await fsp.readFile(join(repoDir, 'design.op'), 'utf-8');
|
|
// Either it has conflict markers OR it's valid JSON (git preserved ours).
|
|
// In both cases the content must not be silently replaced with theirs.
|
|
const hasConflictMarkers = raw.includes('<<<<<<<') || raw.includes('>>>>>>>');
|
|
const isReadableJson = (() => {
|
|
try {
|
|
JSON.parse(raw);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
})();
|
|
expect(hasConflictMarkers || isReadableJson).toBe(true);
|
|
}
|
|
// If kind === 'clean', the dirty content was identical to what the merge
|
|
// would produce — the merge happened to be a no-op for this file.
|
|
} catch (err) {
|
|
// sysMergeNoCommit threw a GitError — git refused the merge entirely.
|
|
// This is the most common outcome when dirty files would be overwritten.
|
|
threwOrConflict = true;
|
|
// The error should be an engine-crash (non-0/non-1 exit code from git).
|
|
const e = err as { name?: string; code?: string };
|
|
expect(e.name).toBe('GitError');
|
|
expect(e.code).toBe('engine-crash');
|
|
}
|
|
|
|
// CONTRACT: either git threw (refused) or entered conflict state.
|
|
// It must NOT silently overwrite the dirty content with theirs.
|
|
expect(threwOrConflict).toBe(true);
|
|
},
|
|
);
|
|
|
|
it.skipIf(!systemGitAvailable)(
|
|
'full workflow: tracked .op conflict + non-.op conflict, then finalize',
|
|
async () => {
|
|
const { repoDir, gitdir } = await setupDivergentRepo({
|
|
withReadme: true,
|
|
readmeConflict: true,
|
|
});
|
|
await sysMergeNoCommit({ cwd: repoDir, ref: 'feature' });
|
|
|
|
// Confirm both conflict.
|
|
const unresolved = await sysListUnresolved({ cwd: repoDir });
|
|
expect(unresolved).toContain('design.op');
|
|
expect(unresolved).toContain('README.md');
|
|
|
|
// Resolve .op by writing final merged content and staging.
|
|
await fsp.writeFile(
|
|
join(repoDir, 'design.op'),
|
|
JSON.stringify({ version: '1.0.0', children: [{ id: 'merged' }] }),
|
|
);
|
|
await sysStageFile({ cwd: repoDir, filepath: 'design.op' });
|
|
|
|
// .op is resolved; README still unresolved.
|
|
const afterOp = await sysListUnresolved({ cwd: repoDir });
|
|
expect(afterOp).not.toContain('design.op');
|
|
expect(afterOp).toContain('README.md');
|
|
|
|
// Resolve README (take ours).
|
|
await sysRestoreOurs({ cwd: repoDir, filepath: 'README.md' });
|
|
await sysStageFile({ cwd: repoDir, filepath: 'README.md' });
|
|
|
|
// All resolved.
|
|
const afterAll = await sysListUnresolved({ cwd: repoDir });
|
|
expect(afterAll).toHaveLength(0);
|
|
|
|
// Finalize.
|
|
const mergeCommit = await sysFinalizeMerge({
|
|
cwd: repoDir,
|
|
message: 'Merge feature: mixed conflict',
|
|
author: { name: 'Test', email: 'test@test.com' },
|
|
});
|
|
expect(mergeCommit).toMatch(/^[a-f0-9]{40}$/);
|
|
const mergeHead = await readMergeHead(gitdir);
|
|
expect(mergeHead).toBeNull();
|
|
},
|
|
);
|
|
});
|
|
|
|
describe('mapSysError', () => {
|
|
it('maps known stderr substrings to GitError codes', () => {
|
|
expect(mapSysError('Authentication failed for ...')).toBe('auth-failed');
|
|
expect(mapSysError('Permission denied (publickey).')).toBe('auth-failed');
|
|
expect(mapSysError('Repository not found')).toBe('clone-failed');
|
|
expect(
|
|
mapSysError("destination path 'foo' already exists and is not an empty directory."),
|
|
).toBe('clone-target-exists');
|
|
expect(mapSysError("Couldn't resolve host 'github.com'")).toBe('network');
|
|
expect(mapSysError('Connection timed out')).toBe('timeout');
|
|
expect(mapSysError('Updates were rejected because ...')).toBe('push-rejected');
|
|
expect(mapSysError('not possible to fast-forward, aborting.')).toBe('pull-non-fast-forward');
|
|
expect(mapSysError('fatal: not a git repository')).toBe('not-a-repo');
|
|
expect(mapSysError('something completely unexpected')).toBe('engine-crash');
|
|
});
|
|
});
|