mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-02-01 07:10:47 +01:00
feat: backend support for manifest/readme updates
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user