feat: add safe Gitea sync and signed updates
This commit is contained in:
@@ -53,6 +53,35 @@ test('GitService reads changes and commits/pushes selected files to a real bare
|
||||
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 }));
|
||||
@@ -204,6 +233,81 @@ test('detects and removes a stale HEAD.lock while skipping Git object storage',
|
||||
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((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/);
|
||||
});
|
||||
|
||||
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 }));
|
||||
|
||||
Reference in New Issue
Block a user