feat: add safe Gitea sync and signed updates
This commit is contained in:
+195
-1
@@ -2,11 +2,13 @@
|
||||
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs/promises');
|
||||
const crypto = require('node:crypto');
|
||||
const { run } = require('./process-runner.cjs');
|
||||
const { parsePorcelainV2 } = require('../shared/git-status.cjs');
|
||||
const { normalizeRemoteUrl } = require('../shared/repository-match.cjs');
|
||||
|
||||
const COMMON_GIT_LOCK_FILES = ['HEAD.lock', 'index.lock'];
|
||||
const MAX_UNTRACKED_DIFF_BYTES = 16 * 1024 * 1024;
|
||||
const {
|
||||
assertSafeRepositoryPath,
|
||||
assertRepositoryRelativePath,
|
||||
@@ -28,6 +30,34 @@ function parseUnifiedDiff(diffText) {
|
||||
return { header, hunks };
|
||||
}
|
||||
|
||||
function parseNameStatus(output) {
|
||||
const entries = String(output || '').split('\0');
|
||||
const changes = [];
|
||||
for (let index = 0; index < entries.length;) {
|
||||
const rawStatus = entries[index++];
|
||||
if (!rawStatus) continue;
|
||||
const code = rawStatus[0];
|
||||
if (code === 'R' || code === 'C') {
|
||||
const originalPath = entries[index++] || '';
|
||||
const filePath = entries[index++] || '';
|
||||
if (filePath) changes.push({ code, status: code === 'R' ? 'renamed' : 'copied', path: filePath, originalPath });
|
||||
continue;
|
||||
}
|
||||
const filePath = entries[index++] || '';
|
||||
if (!filePath) continue;
|
||||
const labels = { A: 'added', D: 'deleted', M: 'modified', T: 'type-changed', U: 'conflict' };
|
||||
changes.push({ code, status: labels[code] || 'changed', path: filePath, originalPath: null });
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function parseCompactLog(output) {
|
||||
return String(output || '').split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
|
||||
const [sha, shortSha, date, subject] = record.split('\x1f');
|
||||
return { sha, shortSha, date, subject };
|
||||
});
|
||||
}
|
||||
|
||||
class GitService {
|
||||
constructor() {
|
||||
// `git remote get-url` is only re-run when the repository configuration file
|
||||
@@ -316,6 +346,151 @@ class GitService {
|
||||
return { strategy: requested, backupBranch: null, status, lockReport: await this.listGitLocks(root) };
|
||||
}
|
||||
|
||||
async previewWorkspaceSync(repoPath) {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const { status } = await this.fetch(root);
|
||||
const branch = status.branch?.head;
|
||||
const upstream = status.branch?.upstream;
|
||||
if (!status.head || !branch || branch === '(detached)') {
|
||||
const error = new Error('Workspace synchronization requires a named branch with at least one commit.');
|
||||
error.code = 'WORKSPACE_SYNC_BRANCH_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
if (!upstream) {
|
||||
const error = new Error('The current branch has no Gitea upstream. Publish it or switch to a tracked branch first.');
|
||||
error.code = 'WORKSPACE_SYNC_UPSTREAM_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const targetSha = (await run('git', ['rev-parse', '--verify', upstream], { cwd: root, timeout: 30_000 })).stdout.trim();
|
||||
const changes = parseNameStatus((await run('git', [
|
||||
'diff', '--name-status', '-z', '--find-renames', 'HEAD', upstream, '--'
|
||||
], { cwd: root, timeout: 60_000, maxBuffer: 16 * 1024 * 1024 })).stdout);
|
||||
const logFormat = '%H%x1f%h%x1f%aI%x1f%s%x1e';
|
||||
const [incomingResult, localResult, interruptedOperation] = await Promise.all([
|
||||
run('git', ['log', `--format=${logFormat}`, `HEAD..${upstream}`, '-20'], { cwd: root, timeout: 30_000 }),
|
||||
run('git', ['log', `--format=${logFormat}`, `${upstream}..HEAD`, '-20'], { cwd: root, timeout: 30_000 }),
|
||||
this.detectInterruptedOperation(root)
|
||||
]);
|
||||
const blockers = [];
|
||||
if (interruptedOperation) blockers.push(`Finish or abort the active Git ${interruptedOperation} before synchronizing.`);
|
||||
if (status.counts.conflicts) blockers.push(`Resolve ${status.counts.conflicts} conflicted file${status.counts.conflicts === 1 ? '' : 's'} before synchronizing.`);
|
||||
const summary = {
|
||||
resultingTrackedChanges: changes.length,
|
||||
added: changes.filter((item) => item.code === 'A').length,
|
||||
modified: changes.filter((item) => ['M', 'T'].includes(item.code)).length,
|
||||
deleted: changes.filter((item) => item.code === 'D').length,
|
||||
renamed: changes.filter((item) => item.code === 'R').length,
|
||||
localFilesToStash: status.counts.changed,
|
||||
untrackedFilesToStash: status.counts.untracked,
|
||||
localCommitsToProtect: status.branch.ahead,
|
||||
incomingCommits: status.branch.behind
|
||||
};
|
||||
const planId = crypto.createHash('sha256').update(JSON.stringify({
|
||||
head: status.head,
|
||||
targetSha,
|
||||
branch,
|
||||
upstream,
|
||||
fingerprint: this.statusFingerprint(status)
|
||||
})).digest('hex');
|
||||
return {
|
||||
id: planId,
|
||||
repositoryRoot: root,
|
||||
branch,
|
||||
upstream,
|
||||
currentSha: status.head,
|
||||
targetSha,
|
||||
needsSync: status.head !== targetSha || !status.clean,
|
||||
cleanBeforeSync: status.clean,
|
||||
blockers,
|
||||
summary,
|
||||
changes: changes.slice(0, 250),
|
||||
changesTruncated: changes.length > 250,
|
||||
localFiles: status.files.slice(0, 250),
|
||||
localFilesTruncated: status.files.length > 250,
|
||||
incomingCommits: parseCompactLog(incomingResult.stdout),
|
||||
localCommits: parseCompactLog(localResult.stdout),
|
||||
recovery: {
|
||||
safetyBranch: status.branch.ahead > 0,
|
||||
stash: status.counts.changed > 0,
|
||||
untrackedCleanup: status.counts.untracked > 0,
|
||||
ignoredFilesPreserved: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async synchronizeWorkspace(repoPath, expectedPlanId) {
|
||||
const expected = String(expectedPlanId || '').trim();
|
||||
if (!/^[0-9a-f]{64}$/i.test(expected)) {
|
||||
const error = new Error('Apply workspace synchronization only from a reviewed preview.');
|
||||
error.code = 'WORKSPACE_SYNC_PLAN_REQUIRED';
|
||||
throw error;
|
||||
}
|
||||
const plan = await this.previewWorkspaceSync(repoPath);
|
||||
if (plan.id !== expected) {
|
||||
const error = new Error('The local workspace or Gitea branch changed after the preview. Review a fresh synchronization plan.');
|
||||
error.code = 'WORKSPACE_SYNC_PLAN_STALE';
|
||||
error.recoverable = true;
|
||||
throw error;
|
||||
}
|
||||
if (plan.blockers.length) {
|
||||
const error = new Error(plan.blockers.join(' '));
|
||||
error.code = 'WORKSPACE_SYNC_BLOCKED';
|
||||
error.recoverable = true;
|
||||
throw error;
|
||||
}
|
||||
if (!plan.needsSync) {
|
||||
return { applied: false, unchanged: true, plan, status: await this.status(plan.repositoryRoot), backupBranch: null, stash: null, cleaned: [] };
|
||||
}
|
||||
|
||||
const root = plan.repositoryRoot;
|
||||
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
|
||||
let backupBranch = null;
|
||||
let stash = null;
|
||||
if (plan.summary.localCommitsToProtect > 0) {
|
||||
const safeBranch = plan.branch.replace(/[^A-Za-z0-9._-]/g, '-');
|
||||
backupBranch = `forgeflow/recovery-${safeBranch}-${stamp}-${plan.currentSha.slice(0, 7)}`;
|
||||
await run('git', ['check-ref-format', '--branch', backupBranch], { cwd: root, timeout: 30_000 });
|
||||
await run('git', ['branch', backupBranch, 'HEAD'], { cwd: root, timeout: 30_000 });
|
||||
}
|
||||
if (plan.summary.localFilesToStash > 0) {
|
||||
const label = `ForgeFlow workspace sync ${plan.branch} ${stamp}`;
|
||||
await run('git', ['stash', 'push', '--include-untracked', '-m', label], { cwd: root, timeout: 120_000 });
|
||||
stash = (await this.stashList(root))[0] || null;
|
||||
}
|
||||
|
||||
const protectedStatus = await this.status(root);
|
||||
if (!protectedStatus.clean || protectedStatus.head !== plan.currentSha) {
|
||||
const error = new Error('The workspace changed while ForgeFlow was protecting local work. Nothing was reset; review a fresh synchronization plan.');
|
||||
error.code = 'WORKSPACE_SYNC_CONCURRENT_CHANGE';
|
||||
error.recoverable = true;
|
||||
error.backupBranch = backupBranch;
|
||||
error.stash = stash;
|
||||
throw error;
|
||||
}
|
||||
|
||||
await run('git', ['reset', '--hard', plan.targetSha], { cwd: root, timeout: 2 * 60_000 });
|
||||
const status = await this.status(root);
|
||||
if (status.head !== plan.targetSha || !status.clean) {
|
||||
const error = new Error('Git did not verify an exact clean match with the reviewed Gitea commit. Local recovery references were preserved.');
|
||||
error.code = 'WORKSPACE_SYNC_VERIFICATION_FAILED';
|
||||
error.recoverable = true;
|
||||
error.backupBranch = backupBranch;
|
||||
error.stash = stash;
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
applied: true,
|
||||
unchanged: false,
|
||||
plan,
|
||||
status,
|
||||
backupBranch,
|
||||
stash,
|
||||
cleaned: plan.localFiles.filter((file) => file.untracked).map((file) => file.path),
|
||||
ignoredFilesPreserved: true
|
||||
};
|
||||
}
|
||||
|
||||
async setRemoteUrl(repoPath, remoteUrl, remote = 'origin') {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const safeRemote = assertCloneRemote(remoteUrl);
|
||||
@@ -336,7 +511,26 @@ class GitService {
|
||||
if (!result.stdout && safeFile && !staged) {
|
||||
const candidate = path.resolve(root, safeFile);
|
||||
if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) throw new Error('File path escapes repository root.');
|
||||
const content = await fs.readFile(candidate, 'utf8').catch(() => '');
|
||||
const [realRoot, realCandidate, candidateStat] = await Promise.all([
|
||||
fs.realpath(root).catch(() => root),
|
||||
fs.realpath(candidate).catch(() => candidate),
|
||||
fs.stat(candidate).catch(() => null)
|
||||
]);
|
||||
const normalize = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
|
||||
const normalizedRoot = normalize(realRoot);
|
||||
const normalizedCandidate = normalize(realCandidate);
|
||||
if (normalizedCandidate !== normalizedRoot && !normalizedCandidate.startsWith(`${normalizedRoot}${path.sep}`)) {
|
||||
const error = new Error('ForgeFlow refuses to read a diff target that resolves outside the repository.');
|
||||
error.code = 'DIFF_TARGET_OUTSIDE_REPOSITORY';
|
||||
throw error;
|
||||
}
|
||||
if (candidateStat?.size > MAX_UNTRACKED_DIFF_BYTES) {
|
||||
const error = new Error('The untracked file is too large to render safely as a diff.');
|
||||
error.code = 'DIFF_FILE_TOO_LARGE';
|
||||
error.recoverable = true;
|
||||
throw error;
|
||||
}
|
||||
const content = candidateStat?.isFile() ? await fs.readFile(candidate, 'utf8').catch(() => '') : '';
|
||||
if (content) return `diff --git a/${safeFile} b/${safeFile}\nnew file mode 100644\n--- /dev/null\n+++ b/${safeFile}\n${content.split('\n').map((line) => `+${line}`).join('\n')}`;
|
||||
}
|
||||
return result.stdout;
|
||||
|
||||
Reference in New Issue
Block a user