Add advanced Git and deployment workflows, secure backups and auditing, live Gitea integration, desktop notifications, connection validation, and the premium responsive UX refresh.
273 lines
13 KiB
JavaScript
273 lines
13 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('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');
|
|
});
|