Add shared instruction files and copilot history maintenance scripts
- Add 12 cross-project instruction files to resources/instructions/ - Add cleanup-copilot-history.mjs/.sh: quarantine-based history cleanup with dry-run and aggressive scope - Add restore-copilot-history.mjs/.sh: restore from quarantine by run, path, or filter - Add purge-copilot-history-quarantine.mjs/.sh: purge old quarantine runs with keep-latest guard - Add copilot-history-maintenance.test.mjs: 3 passing unit tests covering all three scripts
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env node
|
||||
// @ts-nocheck
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function usage() {
|
||||
console.error(
|
||||
[
|
||||
'Usage: purge-copilot-history-quarantine.mjs [options]',
|
||||
'',
|
||||
'Options:',
|
||||
' --quarantine-dir <path> Override quarantine root directory.',
|
||||
' --before <iso> Purge runs created before or at this timestamp.',
|
||||
' --keep-latest <n> Keep the newest <n> run directories. Default: 1',
|
||||
' --execute Perform purge. Default is dry-run.',
|
||||
' --help Show this help text.',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
quarantineDir: path.join(
|
||||
os.homedir(),
|
||||
'.copilot-resources-state',
|
||||
'quarantine',
|
||||
'copilot-history-cleanup',
|
||||
),
|
||||
before: '',
|
||||
keepLatest: 1,
|
||||
execute: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
usage();
|
||||
process.exit(0);
|
||||
}
|
||||
if (arg === '--quarantine-dir') {
|
||||
options.quarantineDir = path.resolve(argv[index + 1] ?? options.quarantineDir);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--before') {
|
||||
options.before = argv[index + 1] ?? '';
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--keep-latest') {
|
||||
options.keepLatest = Number.parseInt(argv[index + 1] ?? '1', 10);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--execute') {
|
||||
options.execute = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(options.keepLatest) || options.keepLatest < 0) {
|
||||
throw new Error(`Invalid --keep-latest value: ${options.keepLatest}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function safeStat(filePath) {
|
||||
try {
|
||||
return fs.statSync(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function listRuns(rootDir) {
|
||||
if (!fs.existsSync(rootDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(rootDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => {
|
||||
const runPath = path.join(rootDir, entry.name);
|
||||
const stat = safeStat(runPath);
|
||||
return stat ? { path: runPath, mtimeMs: stat.mtimeMs } : null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => right.mtimeMs - left.mtimeMs);
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let value = Number(bytes) || 0;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${value.toFixed(unitIndex === 0 ? 0 : 2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function measureSize(filePath) {
|
||||
const stat = safeStat(filePath);
|
||||
if (!stat) {
|
||||
return 0;
|
||||
}
|
||||
if (stat.isFile()) {
|
||||
return stat.size;
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
for (const entry of fs.readdirSync(filePath, { withFileTypes: true })) {
|
||||
const childPath = path.join(filePath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
total += measureSize(childPath);
|
||||
} else if (entry.isFile()) {
|
||||
total += safeStat(childPath)?.size ?? 0;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function writeSummary(rootDir, execute, beforeIso, keepLatest, results, totalBytes) {
|
||||
const summaryPath = path.join(rootDir, 'purge-summary.md');
|
||||
const lines = [
|
||||
'# Copilot History Quarantine Purge Summary',
|
||||
'',
|
||||
`- Generated: ${new Date().toISOString()}`,
|
||||
`- Mode: ${execute ? 'execute' : 'dry-run'}`,
|
||||
`- Before: ${beforeIso || '(none)'}`,
|
||||
`- Keep latest: ${keepLatest}`,
|
||||
`- Runs considered: ${results.length}`,
|
||||
`- Runs purged: ${results.filter((entry) => entry.status === 'purged').length}`,
|
||||
`- Runs skipped: ${results.filter((entry) => entry.status === 'skipped').length}`,
|
||||
`- Runs failed: ${results.filter((entry) => entry.status === 'failed').length}`,
|
||||
`- Estimated bytes affected: ${formatBytes(totalBytes)}`,
|
||||
'',
|
||||
'## Results',
|
||||
'',
|
||||
];
|
||||
|
||||
if (results.length === 0) {
|
||||
lines.push('- None');
|
||||
} else {
|
||||
for (const result of results) {
|
||||
const sizeText = result.bytes > 0 ? ` (${formatBytes(result.bytes)})` : '';
|
||||
lines.push(`- [${result.status}] ${result.path}${sizeText}`);
|
||||
if (result.message) {
|
||||
lines.push(` - ${result.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(summaryPath, `${lines.join('\n')}\n`, 'utf8');
|
||||
return summaryPath;
|
||||
}
|
||||
|
||||
export function runPurge(rawOptions) {
|
||||
const options = {
|
||||
...rawOptions,
|
||||
quarantineDir: path.resolve(rawOptions.quarantineDir),
|
||||
};
|
||||
const beforeMs = options.before ? Date.parse(options.before) : Number.POSITIVE_INFINITY;
|
||||
if (Number.isNaN(beforeMs)) {
|
||||
throw new Error(`Invalid --before timestamp: ${options.before}`);
|
||||
}
|
||||
|
||||
const runs = listRuns(options.quarantineDir);
|
||||
const protectedRuns = new Set(runs.slice(0, options.keepLatest).map((run) => run.path));
|
||||
const results = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
for (const run of runs) {
|
||||
const bytes = measureSize(run.path);
|
||||
totalBytes += bytes;
|
||||
|
||||
if (protectedRuns.has(run.path)) {
|
||||
results.push({ status: 'skipped', path: run.path, bytes, message: 'Protected by keep-latest.' });
|
||||
continue;
|
||||
}
|
||||
if (run.mtimeMs > beforeMs) {
|
||||
results.push({ status: 'skipped', path: run.path, bytes, message: 'Newer than cutoff.' });
|
||||
continue;
|
||||
}
|
||||
if (!options.execute) {
|
||||
results.push({ status: 'skipped', path: run.path, bytes, message: 'Dry-run: no changes were applied.' });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.rmSync(run.path, { recursive: true, force: true });
|
||||
results.push({ status: 'purged', path: run.path, bytes, message: '' });
|
||||
} catch (error) {
|
||||
results.push({ status: 'failed', path: run.path, bytes, message: error?.message || String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
const summaryPath = writeSummary(
|
||||
options.quarantineDir,
|
||||
options.execute,
|
||||
options.before,
|
||||
options.keepLatest,
|
||||
results,
|
||||
totalBytes,
|
||||
);
|
||||
|
||||
return {
|
||||
quarantineDir: options.quarantineDir,
|
||||
considered: results.length,
|
||||
purged: results.filter((entry) => entry.status === 'purged').length,
|
||||
failed: results.filter((entry) => entry.status === 'failed').length,
|
||||
summaryPath,
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const result = runPurge(options);
|
||||
|
||||
console.log('Copilot history quarantine purge complete.');
|
||||
console.log(`Quarantine directory: ${result.quarantineDir}`);
|
||||
console.log(`Runs considered: ${result.considered}`);
|
||||
console.log(`Runs purged: ${result.purged}`);
|
||||
console.log(`Runs failed: ${result.failed}`);
|
||||
console.log(`Summary: ${result.summaryPath}`);
|
||||
}
|
||||
|
||||
const isMainModule = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isMainModule) {
|
||||
main();
|
||||
}
|
||||
Reference in New Issue
Block a user