import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, writeFileSync, rmSync, readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { execFileSync } from 'node:child_process'; import { Git } from './git.js'; function g(cwd, args) { return execFileSync('git', args, { cwd, encoding: 'utf8' }); } /** 建立 bare remote + working clone,於 target/source 製造會衝突的變更。 */ function setupRepo() { const root = mkdtempSync(join(tmpdir(), 'git-test-')); const bare = join(root, 'remote.git'); const work = join(root, 'work'); execFileSync('git', ['init', '-q', '--bare', bare]); execFileSync('git', ['clone', '-q', bare, work]); g(work, ['config', 'user.email', 'test@example.com']); g(work, ['config', 'user.name', 'tester']); g(work, ['config', 'commit.gpgsign', 'false']); writeFileSync(join(work, 'file.txt'), 'base\n'); g(work, ['add', '-A']); g(work, ['commit', '-q', '-m', 'base']); const def = g(work, ['rev-parse', '--abbrev-ref', 'HEAD']).trim(); g(work, ['checkout', '-q', '-b', 'target']); writeFileSync(join(work, 'file.txt'), 'target change\n'); g(work, ['commit', '-qam', 'target change']); g(work, ['checkout', '-q', def]); g(work, ['checkout', '-q', '-b', 'source']); writeFileSync(join(work, 'file.txt'), 'source change\n'); g(work, ['commit', '-qam', 'source change']); g(work, ['push', '-q', 'origin', 'target', 'source', def]); return { root, bare, work }; } test('detectConflict: 偵測到衝突並回傳衝突檔,事後還原暫存分支', () => { const { root, bare, work } = setupRepo(); try { const git = new Git({ cwd: work, remoteUrl: bare, token: 'x' }); git.configure(); git.fetchBranches(['target', 'source']); assert.ok(git.countAheadCommits('target', 'source') >= 1); const det = git.detectConflict('target', 'source'); assert.equal(det.hasConflict, true); assert.ok(det.files.includes('file.txt')); // 暫存分支應已清除 assert.ok(!g(work, ['branch']).includes('__conflict_check')); } finally { rmSync(root, { recursive: true, force: true }); } }); test('createResolveBranch: 推送含衝突標記的解衝突分支到 remote', () => { const { root, bare, work } = setupRepo(); try { const git = new Git({ cwd: work, remoteUrl: bare, token: 'x' }); git.configure(); git.fetchBranches(['target', 'source']); const res = git.createResolveBranch({ target: 'target', source: 'source', resolveBranch: 'resolve-x' }); assert.ok(res.files.includes('file.txt')); // remote 應有 resolve-x 分支 assert.ok(execFileSync('git', ['--git-dir', bare, 'branch'], { encoding: 'utf8' }).includes('resolve-x')); // 已 commit 的檔案應保留衝突標記 assert.ok(readFileSync(join(work, 'file.txt'), 'utf8').includes('<<<<<<<')); } finally { rmSync(root, { recursive: true, force: true }); } }); test('detectConflict: 可順利合併時回報無衝突', () => { const root = mkdtempSync(join(tmpdir(), 'git-test-')); try { const bare = join(root, 'remote.git'); const work = join(root, 'work'); execFileSync('git', ['init', '-q', '--bare', bare]); execFileSync('git', ['clone', '-q', bare, work]); g(work, ['config', 'user.email', 'test@example.com']); g(work, ['config', 'user.name', 'tester']); g(work, ['config', 'commit.gpgsign', 'false']); writeFileSync(join(work, 'a.txt'), 'base\n'); g(work, ['add', '-A']); g(work, ['commit', '-qm', 'base']); const def = g(work, ['rev-parse', '--abbrev-ref', 'HEAD']).trim(); g(work, ['checkout', '-q', '-b', 'target']); // target 不動 g(work, ['checkout', '-q', def]); g(work, ['checkout', '-q', '-b', 'source']); writeFileSync(join(work, 'b.txt'), 'new file\n'); // 改不同檔,無衝突 g(work, ['add', '-A']); g(work, ['commit', '-qm', 'add b']); g(work, ['push', '-q', 'origin', 'target', 'source', def]); const git = new Git({ cwd: work, remoteUrl: bare, token: 'x' }); git.configure(); git.fetchBranches(['target', 'source']); const det = git.detectConflict('target', 'source'); assert.equal(det.hasConflict, false); assert.deepEqual(det.files, []); } finally { rmSync(root, { recursive: true, force: true }); } });