import test from 'node:test'; import assert from 'node:assert/strict'; import os from 'node:os'; import path from 'node:path'; import fs from 'node:fs/promises'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import gitModule from '../src/main/git-service.cjs'; const exec = promisify(execFile); const { GitService } = gitModule; async function git(args, cwd) { return exec('git', args, { cwd, encoding: 'utf8' }); } test('GitService reads changes and commits/pushes selected files to a real bare remote', async (t) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-')); t.after(() => fs.rm(root, { recursive: true, force: true })); const remote = path.join(root, 'remote.git'); const working = path.join(root, 'working'); await git(['init', '--bare', remote], root); await git(['clone', remote, working], root); await git(['config', 'user.name', 'ForgeFlow Test'], working); await git(['config', 'user.email', 'forgeflow@example.invalid'], working); await fs.writeFile(path.join(working, 'README.md'), '# ForgeFlow\n'); await git(['add', 'README.md'], working); await git(['commit', '-m', 'Initial commit'], working); await git(['branch', '-M', 'main'], working); await git(['push', '-u', 'origin', 'main'], working); await fs.appendFile(path.join(working, 'README.md'), '\nDesktop release cockpit.\n'); await fs.writeFile(path.join(working, 'feature.txt'), 'new file\n'); const service = new GitService(); const before = await service.status(working); assert.equal(before.branch.head, 'main'); assert.equal(before.branch.ahead, 0); assert.equal(before.branch.behind, 0); assert.equal(before.counts.changed, 2); assert.deepEqual(new Set(before.files.map((file) => file.path)), new Set(['README.md', 'feature.txt'])); const diff = await service.diff(working, 'README.md'); assert.match(diff, /Desktop release cockpit/); const result = await service.commitAndPush(working, 'Add desktop cockpit copy', ['README.md', 'feature.txt']); assert.equal(result.status.clean, true); assert.equal(result.status.branch.ahead, 0); assert.match(result.pushOutput, /main/); const remoteLog = await git(['--git-dir', remote, 'log', '-1', '--pretty=%s', 'refs/heads/main'], root); assert.equal(remoteLog.stdout.trim(), 'Add desktop cockpit copy'); }); test('stages and pushes deleted and renamed files selected from the working tree', async (t) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-delete-rename-')); t.after(() => fs.rm(root, { recursive: true, force: true })); const remote = path.join(root, 'remote.git'); const working = path.join(root, 'working'); await git(['init', '--bare', remote], root); await git(['clone', remote, working], root); await git(['config', 'user.name', 'ForgeFlow Test'], working); await git(['config', 'user.email', 'forgeflow@example.invalid'], working); await fs.writeFile(path.join(working, 'silent-zebra-glow.zip'), 'obsolete archive\n'); await fs.writeFile(path.join(working, 'old-name.txt'), 'rename me\n'); await git(['add', '.'], working); await git(['commit', '-m', 'Initial files'], working); await git(['branch', '-M', 'main'], working); await git(['push', '-u', 'origin', 'main'], working); await fs.rm(path.join(working, 'silent-zebra-glow.zip')); await fs.rename(path.join(working, 'old-name.txt'), path.join(working, 'new-name.txt')); const service = new GitService(); const before = await service.status(working); assert.ok(before.files.some((file) => file.path === 'silent-zebra-glow.zip' && file.status === 'deleted')); const selected = before.files.map((file) => file.path); const result = await service.commitAndPush(working, 'Remove obsolete archive and rename file', selected); assert.equal(result.status.clean, true); assert.equal(result.status.branch.ahead, 0); const tree = await git(['--git-dir', remote, 'ls-tree', '-r', '--name-only', 'refs/heads/main'], root); const names = tree.stdout.trim().split(/\r?\n/).filter(Boolean); assert.deepEqual(names, ['new-name.txt']); }); test('commits a deletion that was already staged manually without restaging its missing path', async (t) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-staged-delete-')); t.after(() => fs.rm(root, { recursive: true, force: true })); const remote = path.join(root, 'remote.git'); const working = path.join(root, 'working'); await git(['init', '--bare', remote], root); await git(['clone', remote, working], root); await git(['config', 'user.name', 'ForgeFlow Test'], working); await git(['config', 'user.email', 'forgeflow@example.invalid'], working); await fs.writeFile(path.join(working, 'silent-zebra-glow.zip'), 'obsolete archive\n'); await git(['add', '.'], working); await git(['commit', '-m', 'Initial archive'], working); await git(['branch', '-M', 'main'], working); await git(['push', '-u', 'origin', 'main'], working); await fs.rm(path.join(working, 'silent-zebra-glow.zip')); const service = new GitService(); const staged = await service.stage(working, ['silent-zebra-glow.zip']); assert.equal(staged.files[0].path, 'silent-zebra-glow.zip'); assert.equal(staged.files[0].staged, true); assert.equal(staged.files[0].unstaged, false); // This used to call git add -A for the same already-staged deletion again, // which fails with a pathspec error because the file no longer exists. const result = await service.commitAndPush(working, 'Remove obsolete archive', ['silent-zebra-glow.zip']); assert.equal(result.status.clean, true); assert.equal(result.status.branch.ahead, 0); const tree = await git(['--git-dir', remote, 'ls-tree', '-r', '--name-only', 'refs/heads/main'], root); assert.equal(tree.stdout.trim(), ''); }); test('keeps a successful local commit visible as ahead when the following push fails', async (t) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-push-failure-')); t.after(() => fs.rm(root, { recursive: true, force: true })); const remote = path.join(root, 'remote.git'); const working = path.join(root, 'working'); await git(['init', '--bare', remote], root); await git(['clone', remote, working], root); await git(['config', 'user.name', 'ForgeFlow Test'], working); await git(['config', 'user.email', 'forgeflow@example.invalid'], working); await fs.writeFile(path.join(working, 'README.md'), '# Portfolio\n'); await git(['add', '.'], working); await git(['commit', '-m', 'Initial'], working); await git(['branch', '-M', 'main'], working); await git(['push', '-u', 'origin', 'main'], working); await fs.appendFile(path.join(working, 'README.md'), '\nUpdated locally.\n'); await git(['remote', 'set-url', 'origin', path.join(root, 'missing-remote.git')], working); const service = new GitService(); await assert.rejects( service.commitAndPush(working, 'Update portfolio', ['README.md']), (error) => Boolean(error.code === 'PUSH_AFTER_COMMIT_FAILED' && error.commitSha) ); const status = await service.status(working); assert.equal(status.clean, true); assert.equal(status.branch.ahead, 1); const subject = await git(['log', '-1', '--pretty=%s'], working); assert.equal(subject.stdout.trim(), 'Update portfolio'); }); test('stages a large Windows-sized partial selection through NUL-delimited stdin', async (t) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-large-selection-')); t.after(() => fs.rm(root, { recursive: true, force: true })); const working = path.join(root, 'working'); await fs.mkdir(working); await git(['init'], working); await git(['config', 'user.name', 'ForgeFlow Test'], working); await git(['config', 'user.email', 'forgeflow@example.invalid'], working); await fs.writeFile(path.join(working, 'README.md'), '# Large selection\n'); await git(['add', '.'], working); await git(['commit', '-m', 'Initial'], working); const names = []; for (let index = 0; index < 850; index += 1) { const name = `generated/feature-${String(index).padStart(4, '0')}-${'x'.repeat(28)}.txt`; names.push(name); await fs.mkdir(path.dirname(path.join(working, name)), { recursive: true }); await fs.writeFile(path.join(working, name), `file ${index}\n`); } const service = new GitService(); const status = await service.stage(working, names); assert.equal(status.counts.staged, names.length); assert.equal(status.counts.unstaged, 0); }); test('detects and removes a stale HEAD.lock while skipping Git object storage', async (t) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-head-lock-')); t.after(() => fs.rm(root, { recursive: true, force: true })); await git(['init'], root); await git(['config', 'user.name', 'ForgeFlow Test'], root); await git(['config', 'user.email', 'forgeflow@example.invalid'], root); await fs.writeFile(path.join(root, 'README.md'), 'lock test\n'); await git(['add', '.'], root); await git(['commit', '-m', 'Initial'], root); const headLock = path.join(root, '.git', 'HEAD.lock'); const ignoredObjectLock = path.join(root, '.git', 'objects', 'fake.lock'); await fs.writeFile(headLock, 'stale'); await fs.writeFile(ignoredObjectLock, 'not a repository mutation lock'); const old = new Date(Date.now() - 60_000); await fs.utimes(headLock, old, old); const service = new GitService(); const report = await service.listGitLocks(root); assert.deepEqual(report.locks.map((item) => item.name), ['HEAD.lock']); const repaired = await service.repairStaleGitLocks(root, { minimumAgeMs: 0, allowWithoutProcessProbe: true }); assert.equal(repaired.removed.length, 1); await assert.rejects(() => fs.stat(headLock), (error) => error.code === 'ENOENT'); assert.ok(await fs.stat(ignoredObjectLock)); }); test('repairs a diverged branch by creating a safety branch before resetting to upstream', async (t) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-diverged-')); t.after(() => fs.rm(root, { recursive: true, force: true })); const remote = path.join(root, 'remote.git'); const working = path.join(root, 'working'); const other = path.join(root, 'other'); await git(['init', '--bare', remote], root); await git(['clone', remote, working], root); await git(['config', 'user.name', 'ForgeFlow Test'], working); await git(['config', 'user.email', 'forgeflow@example.invalid'], working); await fs.writeFile(path.join(working, 'README.md'), 'initial\n'); await git(['add', '.'], working); await git(['commit', '-m', 'Initial'], working); await git(['branch', '-M', 'main'], working); await git(['push', '-u', 'origin', 'main'], working); await git(['clone', remote, other], root); await git(['config', 'user.name', 'Other Test'], other); await git(['config', 'user.email', 'other@example.invalid'], other); await git(['checkout', 'main'], other); await fs.writeFile(path.join(other, 'remote.txt'), 'remote\n'); await git(['add', '.'], other); await git(['commit', '-m', 'Remote commit'], other); await git(['push', 'origin', 'main'], other); await fs.writeFile(path.join(working, 'local.txt'), 'local\n'); await git(['add', '.'], working); await git(['commit', '-m', 'Local commit'], working); const localBefore = (await git(['rev-parse', 'HEAD'], working)).stdout.trim(); const service = new GitService(); const scan = await service.reconcile(working); assert.equal(scan.status.branch.ahead, 1); assert.equal(scan.status.branch.behind, 1); assert.ok(scan.recommendations.some((item) => item.action === 'backup-reset')); const repaired = await service.repairSync(working, 'backup-reset'); assert.match(repaired.backupBranch, /^forgeflow\/backup-main-/); assert.equal(repaired.status.branch.ahead, 0); assert.equal(repaired.status.branch.behind, 0); const backupSha = (await git(['rev-parse', repaired.backupBranch], working)).stdout.trim(); assert.equal(backupSha, localBefore); }); test('troubleshooter detects and aborts an interrupted merge without discarding committed history', async (t) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-interrupted-merge-')); t.after(() => fs.rm(root, { recursive: true, force: true })); await git(['init'], root); await git(['config', 'user.name', 'ForgeFlow Test'], root); await git(['config', 'user.email', 'forgeflow@example.invalid'], root); await fs.writeFile(path.join(root, 'file.txt'), 'base\n'); await git(['add', '.'], root); await git(['commit', '-m', 'Base'], root); await git(['checkout', '-b', 'other'], root); await fs.writeFile(path.join(root, 'file.txt'), 'other\n'); await git(['commit', '-am', 'Other'], root); await git(['checkout', 'master'], root); await fs.writeFile(path.join(root, 'file.txt'), 'main\n'); await git(['commit', '-am', 'Main'], root); await assert.rejects(git(['merge', 'other'], root)); const service = new GitService(); assert.equal(await service.detectInterruptedOperation(root), 'merge'); const result = await service.abortInterruptedOperation(root); assert.equal(result.aborted, 'merge'); assert.equal(await service.detectInterruptedOperation(root), null); assert.equal(result.status.clean, true); const subject = await git(['log', '-1', '--pretty=%s'], root); assert.equal(subject.stdout.trim(), 'Main'); });