feat: backend support for manifest/readme updates

This commit is contained in:
Sam Chau
2026-01-17 00:40:03 +10:30
parent 4186e1413a
commit 3d1e55e46c
8 changed files with 263 additions and 211 deletions

View File

@@ -43,27 +43,60 @@ function extractOpNumber(filename: string): number {
}
/**
* Get uncommitted operation files from ops/ directory
* Get metadata for known config files
*/
function getConfigFileMetadata(filename: string, status: string): Partial<OperationFile> {
const isNew = status.includes('?') || status.includes('A');
const operation = isNew ? 'create' : 'update';
if (filename === 'pcd.json') {
return { operation, entity: 'manifest', name: 'pcd.json' };
}
if (filename === 'README.md') {
return { operation, entity: 'readme', name: 'README.md' };
}
if (filename.startsWith('tweaks/') && filename.endsWith('.sql')) {
return { operation, entity: 'tweak', name: filename.replace('tweaks/', '') };
}
return { operation, entity: null, name: filename };
}
/**
* Get uncommitted files from the repository (excludes user_ops/ and deps/)
*/
export async function getUncommittedOps(repoPath: string): Promise<OperationFile[]> {
const files: OperationFile[] = [];
const output = await execGitSafe(['status', '--porcelain', 'ops/'], repoPath);
const output = await execGitSafe(['status', '--porcelain'], repoPath);
if (!output) return files;
for (const line of output.split('\n')) {
if (!line.trim()) continue;
const status = line.substring(0, 2);
const filename = line.substring(3).trim();
// Git porcelain format: XY PATH (2 char status + variable whitespace + path)
const rawFilename = line.substring(2).trimStart();
// Only include new/modified .sql files
if ((status.includes('?') || status.includes('A') || status.includes('M')) && filename.endsWith('.sql')) {
const filepath = `${repoPath}/${filename}`;
const metadata = await parseOperationMetadata(filepath);
// Skip user_ops and deps directories
if (rawFilename.startsWith('user_ops/') || rawFilename.startsWith('deps/')) continue;
// Only include new/modified files
if (status.includes('?') || status.includes('A') || status.includes('M')) {
const filepath = `${repoPath}/${rawFilename}`;
let metadata: Partial<OperationFile> = {};
if (rawFilename.startsWith('ops/') && rawFilename.endsWith('.sql')) {
// Parse metadata from SQL file header
metadata = await parseOperationMetadata(filepath);
} else {
// Get metadata for config files
metadata = getConfigFileMetadata(rawFilename, status);
}
files.push({
filename: filename.replace('ops/', ''),
filename: rawFilename.startsWith('ops/') ? rawFilename.replace('ops/', '') : rawFilename,
filepath,
operation: metadata.operation || null,
entity: metadata.entity || null,
@@ -73,8 +106,18 @@ export async function getUncommittedOps(repoPath: string): Promise<OperationFile
}
}
// Sort by operation number (natural sort)
files.sort((a, b) => extractOpNumber(a.filename) - extractOpNumber(b.filename));
// Sort: ops/ files by number first, then other files alphabetically
files.sort((a, b) => {
const aIsOps = a.filepath.includes('/ops/');
const bIsOps = b.filepath.includes('/ops/');
if (aIsOps && bIsOps) {
return extractOpNumber(a.filename) - extractOpNumber(b.filename);
}
if (aIsOps) return -1;
if (bIsOps) return 1;
return a.filename.localeCompare(b.filename);
});
return files;
}
@@ -103,25 +146,37 @@ export async function getMaxOpNumber(repoPath: string): Promise<number> {
}
/**
* Discard operation files (delete them)
* Discard uncommitted files (restore or delete them)
*/
export async function discardOps(repoPath: string, filepaths: string[]): Promise<void> {
for (const filepath of filepaths) {
// Security: ensure file is within ops directory
if (!filepath.startsWith(repoPath + '/ops/')) {
// Security: ensure file is within repo and not in user_ops or deps
if (!filepath.startsWith(repoPath + '/') ||
filepath.startsWith(repoPath + '/user_ops/') ||
filepath.startsWith(repoPath + '/deps/')) {
continue;
}
try {
await Deno.remove(filepath);
// Check if file is tracked by git
const relativePath = filepath.replace(repoPath + '/', '');
const isTracked = await execGitSafe(['ls-files', relativePath], repoPath);
if (isTracked?.trim()) {
// Tracked file - restore from git
await execGitSafe(['checkout', 'HEAD', '--', relativePath], repoPath);
} else {
// Untracked file - delete it
await Deno.remove(filepath);
}
} catch {
// File might already be deleted
// File might already be deleted or restored
}
}
}
/**
* Add operation files: pull, renumber if needed, commit, push
* Add files: pull, renumber ops if needed, commit, push
*
* TODO: This functionality needs to be redesigned. The current approach of
* pull -> renumber -> commit -> push has race conditions and edge cases that
@@ -148,32 +203,43 @@ export async function addOps(
// 2. Get max op number after pull
let maxNum = await getMaxOpNumber(repoPath);
// 3. Renumber and collect files to stage
// 3. Process files - renumber ops/, keep others as-is
const filesToStage: string[] = [];
const opsPath = `${repoPath}/ops`;
for (const filepath of filepaths) {
if (!filepath.startsWith(repoPath + '/ops/')) continue;
// Security: ensure file is within repo and not in user_ops or deps
if (!filepath.startsWith(repoPath + '/') ||
filepath.startsWith(repoPath + '/user_ops/') ||
filepath.startsWith(repoPath + '/deps/')) {
continue;
}
const filename = filepath.split('/').pop()!;
const match = filename.match(/^(\d+)\.(.+)$/);
// Handle ops/ files with renumbering
if (filepath.startsWith(repoPath + '/ops/')) {
const filename = filepath.split('/').pop()!;
const match = filename.match(/^(\d+)\.(.+)$/);
if (match) {
const currentNum = parseInt(match[1], 10);
const rest = match[2];
if (match) {
const currentNum = parseInt(match[1], 10);
const rest = match[2];
if (currentNum <= maxNum) {
// Need to renumber
maxNum++;
const newFilename = `${maxNum}.${rest}`;
const newFilepath = `${opsPath}/${newFilename}`;
await Deno.rename(filepath, newFilepath);
filesToStage.push(newFilepath);
if (currentNum <= maxNum) {
// Need to renumber
maxNum++;
const newFilename = `${maxNum}.${rest}`;
const newFilepath = `${opsPath}/${newFilename}`;
await Deno.rename(filepath, newFilepath);
filesToStage.push(newFilepath);
} else {
maxNum = Math.max(maxNum, currentNum);
filesToStage.push(filepath);
}
} else {
maxNum = Math.max(maxNum, currentNum);
filesToStage.push(filepath);
}
} else {
// Non-ops files - stage as-is
filesToStage.push(filepath);
}
}