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,383 @@
|
||||
#!/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: restore-copilot-history.mjs [options]',
|
||||
'',
|
||||
'Options:',
|
||||
' --quarantine-run <path> Restore a specific quarantine run directory.',
|
||||
' --quarantine-dir <path> Override quarantine root directory.',
|
||||
' --path <absolute-path> Restore only one exact original path.',
|
||||
' --filter <text> Restore only entries whose original path contains the text.',
|
||||
' --overwrite Replace existing destination paths during restore.',
|
||||
' --execute Perform restore. Default is dry-run.',
|
||||
' --help Show this help text.',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
quarantineRun: '',
|
||||
quarantineDir: path.join(
|
||||
os.homedir(),
|
||||
'.copilot-resources-state',
|
||||
'quarantine',
|
||||
'copilot-history-cleanup',
|
||||
),
|
||||
exactPath: '',
|
||||
filter: '',
|
||||
overwrite: false,
|
||||
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-run') {
|
||||
options.quarantineRun = path.resolve(argv[index + 1] ?? '');
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--quarantine-dir') {
|
||||
options.quarantineDir = path.resolve(argv[index + 1] ?? options.quarantineDir);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--path') {
|
||||
options.exactPath = path.resolve(argv[index + 1] ?? '');
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--filter') {
|
||||
options.filter = argv[index + 1] ?? '';
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--overwrite') {
|
||||
options.overwrite = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--execute') {
|
||||
options.execute = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function safeStat(filePath) {
|
||||
try {
|
||||
return fs.statSync(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function listDirs(rootPath) {
|
||||
if (!fs.existsSync(rootPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(rootPath, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(rootPath, entry.name));
|
||||
}
|
||||
|
||||
function findLatestQuarantineRun(quarantineRoot) {
|
||||
let latest = null;
|
||||
|
||||
for (const runDir of listDirs(quarantineRoot)) {
|
||||
const stat = safeStat(runDir);
|
||||
if (!stat) {
|
||||
continue;
|
||||
}
|
||||
if (!latest || stat.mtimeMs > latest.mtimeMs) {
|
||||
latest = { path: runDir, mtimeMs: stat.mtimeMs };
|
||||
}
|
||||
}
|
||||
|
||||
return latest?.path ?? null;
|
||||
}
|
||||
|
||||
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 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 readManifestCandidates(rootDir) {
|
||||
const manifestPath = path.join(rootDir, 'manifest.json');
|
||||
if (!safeStat(manifestPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
return Array.isArray(manifest?.rows)
|
||||
? manifest.rows.map((row) => ({
|
||||
quarantinePath: path.resolve(row.quarantinePath),
|
||||
originalPath: path.resolve(row.originalPath),
|
||||
}))
|
||||
: [];
|
||||
}
|
||||
|
||||
function isRestoreTargetDirectory(dirPath) {
|
||||
const baseName = path.basename(dirPath);
|
||||
if (['chatSessions', 'chatEditingSessions', 'transcripts', 'chat-session-resources'].includes(baseName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(safeStat(path.join(dirPath, 'selection-manifest.tsv')));
|
||||
}
|
||||
|
||||
function readHeuristicCandidates(rootDir) {
|
||||
const candidates = [];
|
||||
|
||||
function walk(currentDir) {
|
||||
for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
|
||||
const entryPath = path.join(currentDir, entry.name);
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isRestoreTargetDirectory(entryPath)) {
|
||||
const relativePath = path.relative(rootDir, entryPath);
|
||||
candidates.push({
|
||||
quarantinePath: entryPath,
|
||||
originalPath: path.join(path.parse(rootDir).root, relativePath),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
walk(entryPath);
|
||||
}
|
||||
}
|
||||
|
||||
walk(rootDir);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function collectRestoreCandidates(rootDir, filterText, exactPath) {
|
||||
const manifestCandidates = readManifestCandidates(rootDir);
|
||||
const baseCandidates = manifestCandidates.length > 0 ? manifestCandidates : readHeuristicCandidates(rootDir);
|
||||
const uniqueCandidates = [];
|
||||
const seenPaths = new Set();
|
||||
|
||||
for (const candidate of baseCandidates) {
|
||||
if (seenPaths.has(candidate.originalPath)) {
|
||||
continue;
|
||||
}
|
||||
seenPaths.add(candidate.originalPath);
|
||||
uniqueCandidates.push(candidate);
|
||||
}
|
||||
|
||||
let filtered = uniqueCandidates;
|
||||
if (exactPath) {
|
||||
filtered = filtered.filter((candidate) => candidate.originalPath === exactPath);
|
||||
}
|
||||
if (filterText) {
|
||||
filtered = filtered.filter((candidate) => candidate.originalPath.includes(filterText));
|
||||
}
|
||||
|
||||
return filtered.sort((left, right) => left.originalPath.localeCompare(right.originalPath));
|
||||
}
|
||||
|
||||
function movePath(sourcePath, destinationPath, overwrite) {
|
||||
if (safeStat(destinationPath)) {
|
||||
if (!overwrite) {
|
||||
return { status: 'skipped', message: 'Destination already exists.' };
|
||||
}
|
||||
fs.rmSync(destinationPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
ensureDir(path.dirname(destinationPath));
|
||||
try {
|
||||
fs.renameSync(sourcePath, destinationPath);
|
||||
} catch (error) {
|
||||
if (error?.code !== 'EXDEV') {
|
||||
throw error;
|
||||
}
|
||||
fs.cpSync(sourcePath, destinationPath, { recursive: true });
|
||||
fs.rmSync(sourcePath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
return { status: 'restored', message: '' };
|
||||
}
|
||||
|
||||
function pruneEmptyParents(startDir, stopDir) {
|
||||
let currentDir = path.dirname(startDir);
|
||||
while (currentDir.startsWith(stopDir) && currentDir !== stopDir) {
|
||||
const entries = fs.readdirSync(currentDir);
|
||||
if (entries.length > 0) {
|
||||
break;
|
||||
}
|
||||
fs.rmdirSync(currentDir);
|
||||
currentDir = path.dirname(currentDir);
|
||||
}
|
||||
}
|
||||
|
||||
function writeSummary(runDir, execute, overwrite, results, totalBytes) {
|
||||
const summaryPath = path.join(runDir, 'restore-summary.md');
|
||||
const lines = [
|
||||
'# Copilot History Restore Summary',
|
||||
'',
|
||||
`- Generated: ${new Date().toISOString()}`,
|
||||
`- Mode: ${execute ? 'execute' : 'dry-run'}`,
|
||||
`- Overwrite existing destinations: ${overwrite ? 'yes' : 'no'}`,
|
||||
`- Entries considered: ${results.length}`,
|
||||
`- Entries restored: ${results.filter((entry) => entry.status === 'restored').length}`,
|
||||
`- Entries skipped: ${results.filter((entry) => entry.status === 'skipped').length}`,
|
||||
`- Entries 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.originalPath}${sizeText}`);
|
||||
lines.push(` - Quarantine path: ${result.quarantinePath}`);
|
||||
if (result.message) {
|
||||
lines.push(` - ${result.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(summaryPath, `${lines.join('\n')}\n`, 'utf8');
|
||||
return summaryPath;
|
||||
}
|
||||
|
||||
export function runRestore(rawOptions) {
|
||||
const options = {
|
||||
...rawOptions,
|
||||
quarantineDir: path.resolve(rawOptions.quarantineDir),
|
||||
quarantineRun: rawOptions.quarantineRun ? path.resolve(rawOptions.quarantineRun) : '',
|
||||
};
|
||||
|
||||
const runDir = options.quarantineRun || findLatestQuarantineRun(options.quarantineDir);
|
||||
if (!runDir) {
|
||||
throw new Error('No quarantine run was found. Pass --quarantine-run or create one with cleanup first.');
|
||||
}
|
||||
|
||||
const candidates = collectRestoreCandidates(runDir, options.filter, options.exactPath);
|
||||
const results = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const bytes = measureSize(candidate.quarantinePath);
|
||||
totalBytes += bytes;
|
||||
|
||||
if (!options.execute) {
|
||||
results.push({
|
||||
status: 'skipped',
|
||||
quarantinePath: candidate.quarantinePath,
|
||||
originalPath: candidate.originalPath,
|
||||
bytes,
|
||||
message: 'Dry-run: no changes were applied.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const moveResult = movePath(candidate.quarantinePath, candidate.originalPath, options.overwrite);
|
||||
results.push({
|
||||
status: moveResult.status,
|
||||
quarantinePath: candidate.quarantinePath,
|
||||
originalPath: candidate.originalPath,
|
||||
bytes,
|
||||
message: moveResult.message,
|
||||
});
|
||||
if (moveResult.status === 'restored') {
|
||||
pruneEmptyParents(candidate.quarantinePath, runDir);
|
||||
}
|
||||
} catch (error) {
|
||||
results.push({
|
||||
status: 'failed',
|
||||
quarantinePath: candidate.quarantinePath,
|
||||
originalPath: candidate.originalPath,
|
||||
bytes,
|
||||
message: error?.message || String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const summaryPath = writeSummary(runDir, options.execute, options.overwrite, results, totalBytes);
|
||||
|
||||
return {
|
||||
runDir,
|
||||
candidates: results.length,
|
||||
restored: results.filter((entry) => entry.status === 'restored').length,
|
||||
failed: results.filter((entry) => entry.status === 'failed').length,
|
||||
summaryPath,
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const result = runRestore(options);
|
||||
|
||||
console.log('Copilot history restore complete.');
|
||||
console.log(`Quarantine run: ${result.runDir}`);
|
||||
console.log(`Entries considered: ${result.candidates}`);
|
||||
console.log(`Entries restored: ${result.restored}`);
|
||||
console.log(`Entries 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