396 lines
20 KiB
JavaScript
396 lines
20 KiB
JavaScript
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('untracked diff rendering refuses links outside the repository and oversized files', async (t) => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-diff-boundary-'));
|
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
const repository = path.join(root, 'repository');
|
|
const outside = path.join(root, 'outside');
|
|
await fs.mkdir(repository, { recursive: true });
|
|
await fs.mkdir(outside, { recursive: true });
|
|
await git(['init'], repository);
|
|
await fs.writeFile(path.join(outside, 'secret.txt'), 'outside-secret');
|
|
try {
|
|
await fs.symlink(outside, path.join(repository, 'linked'), process.platform === 'win32' ? 'junction' : 'dir');
|
|
} catch {
|
|
t.skip('this platform does not allow creating directory links');
|
|
return;
|
|
}
|
|
|
|
const service = new GitService();
|
|
await assert.rejects(
|
|
service.diff(repository, 'linked/secret.txt'),
|
|
(error) => error.code === 'DIFF_TARGET_OUTSIDE_REPOSITORY',
|
|
);
|
|
|
|
await fs.writeFile(path.join(repository, 'too-large.txt'), Buffer.alloc(16 * 1024 * 1024 + 1, 0x61));
|
|
await assert.rejects(
|
|
service.diff(repository, 'too-large.txt'),
|
|
(error) => error.code === 'DIFF_FILE_TOO_LARGE' && error.recoverable === true,
|
|
);
|
|
});
|
|
|
|
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('previews and safely mirrors a workspace to Gitea while preserving every class of local work', async (t) => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-workspace-sync-'));
|
|
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
const remote = path.join(root, 'remote.git');
|
|
const working = path.join(root, 'working');
|
|
const external = path.join(root, 'external');
|
|
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, '.gitignore'), 'runtime/\n');
|
|
await fs.writeFile(path.join(working, 'README.md'), 'initial\n');
|
|
await fs.writeFile(path.join(working, 'obsolete.txt'), 'remove remotely\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, external], root);
|
|
await git(['config', 'user.name', 'External Gitea Test'], external);
|
|
await git(['config', 'user.email', 'external@example.invalid'], external);
|
|
await git(['checkout', 'main'], external);
|
|
await fs.writeFile(path.join(external, 'README.md'), 'changed on Gitea\n');
|
|
await fs.rm(path.join(external, 'obsolete.txt'));
|
|
await fs.writeFile(path.join(external, 'remote-only.txt'), 'new on Gitea\n');
|
|
await git(['add', '-A'], external);
|
|
await git(['commit', '-m', 'External cleanup'], external);
|
|
await git(['push', 'origin', 'main'], external);
|
|
|
|
await fs.writeFile(path.join(working, 'local-commit.txt'), 'local committed work\n');
|
|
await git(['add', 'local-commit.txt'], working);
|
|
await git(['commit', '-m', 'Local Codex work'], working);
|
|
const localHead = (await git(['rev-parse', 'HEAD'], working)).stdout.trim();
|
|
await fs.appendFile(path.join(working, 'README.md'), 'local uncommitted edit\n');
|
|
await fs.writeFile(path.join(working, 'local-notes.txt'), 'untracked local notes\n');
|
|
await fs.mkdir(path.join(working, 'runtime'), { recursive: true });
|
|
await fs.writeFile(path.join(working, 'runtime', 'local.db'), 'ignored runtime state\n');
|
|
|
|
const service = new GitService();
|
|
const firstPlan = await service.previewWorkspaceSync(working);
|
|
assert.match(firstPlan.id, /^[0-9a-f]{64}$/);
|
|
assert.equal(firstPlan.summary.localCommitsToProtect, 1);
|
|
assert.equal(firstPlan.summary.incomingCommits, 1);
|
|
assert.equal(firstPlan.summary.localFilesToStash, 2);
|
|
assert.equal(firstPlan.summary.untrackedFilesToStash, 1);
|
|
assert.ok(firstPlan.changes.some((item) => item.path === 'obsolete.txt' && item.code === 'D'));
|
|
assert.equal(firstPlan.recovery.ignoredFilesPreserved, true);
|
|
|
|
await fs.writeFile(path.join(working, 'changed-after-preview.txt'), 'forces a stale plan\n');
|
|
await assert.rejects(
|
|
service.synchronizeWorkspace(working, firstPlan.id),
|
|
(error) => error.code === 'WORKSPACE_SYNC_PLAN_STALE'
|
|
);
|
|
assert.equal(await fs.readFile(path.join(working, 'changed-after-preview.txt'), 'utf8'), 'forces a stale plan\n');
|
|
|
|
const reviewedPlan = await service.previewWorkspaceSync(working);
|
|
const result = await service.synchronizeWorkspace(working, reviewedPlan.id);
|
|
assert.equal(result.applied, true);
|
|
assert.equal(result.status.clean, true);
|
|
assert.equal(result.status.head, reviewedPlan.targetSha);
|
|
assert.match(result.backupBranch, /^forgeflow\/recovery-main-/);
|
|
assert.ok(result.stash?.sha);
|
|
assert.equal(result.stash.quarantined, true);
|
|
assert.equal(result.review.id, reviewedPlan.id);
|
|
assert.equal(result.review.status, 'pending-codex-review');
|
|
const reviewManifest = JSON.parse(await fs.readFile(result.review.manifestPath, 'utf8'));
|
|
assert.equal(reviewManifest.recoveryBranch, result.backupBranch);
|
|
assert.equal(reviewManifest.stashSha, result.stash.sha);
|
|
assert.deepEqual(
|
|
new Set(reviewManifest.files.map((file) => file.path)),
|
|
new Set(['README.md', 'local-notes.txt', 'changed-after-preview.txt'])
|
|
);
|
|
assert.equal((await git(['rev-parse', result.backupBranch], working)).stdout.trim(), localHead);
|
|
assert.equal((await fs.readFile(path.join(working, 'README.md'), 'utf8')).replace(/\r\n/g, '\n'), 'changed on Gitea\n');
|
|
assert.equal((await fs.readFile(path.join(working, 'remote-only.txt'), 'utf8')).replace(/\r\n/g, '\n'), 'new on Gitea\n');
|
|
await assert.rejects(fs.stat(path.join(working, 'obsolete.txt')), (error) => error.code === 'ENOENT');
|
|
await assert.rejects(fs.stat(path.join(working, 'local-commit.txt')), (error) => error.code === 'ENOENT');
|
|
await assert.rejects(fs.stat(path.join(working, 'local-notes.txt')), (error) => error.code === 'ENOENT');
|
|
assert.equal(await fs.readFile(path.join(working, 'runtime', 'local.db'), 'utf8'), 'ignored runtime state\n');
|
|
const stashedPaths = (await git(['stash', 'show', '--include-untracked', '--name-only', result.stash.ref], working)).stdout;
|
|
assert.match(stashedPaths, /README\.md/);
|
|
assert.match(stashedPaths, /local-notes\.txt/);
|
|
assert.match(stashedPaths, /changed-after-preview\.txt/);
|
|
await assert.rejects(
|
|
service.popStash(working, result.stash.ref),
|
|
(error) => error.code === 'WORKSPACE_QUARANTINE_REVIEW_REQUIRED'
|
|
);
|
|
await git(['switch', result.backupBranch], working);
|
|
await assert.rejects(
|
|
service.push(working),
|
|
(error) => error.code === 'WORKSPACE_RECOVERY_BRANCH_LOCAL_ONLY'
|
|
);
|
|
});
|
|
|
|
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');
|
|
});
|