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,510 @@
|
||||
#!/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));
|
||||
|
||||
const SCOPES = new Set(['aggressive']);
|
||||
|
||||
function usage() {
|
||||
console.error(
|
||||
[
|
||||
'Usage: cleanup-copilot-history.mjs [options]',
|
||||
'',
|
||||
'Options:',
|
||||
' --audit-dir <path> Audit directory used as completion boundary context.',
|
||||
' --completion-cutoff <iso> Delete targets with mtime at or before this timestamp.',
|
||||
' --scope <scope> Cleanup scope. Supported: aggressive. Default: aggressive',
|
||||
' --quarantine-dir <path> Override quarantine root directory.',
|
||||
' --repo-root <path> Override repository root.',
|
||||
' --execute Perform cleanup. Default is dry-run.',
|
||||
' --help Show this help text.',
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
auditDir: '',
|
||||
completionCutoff: '',
|
||||
execute: false,
|
||||
scope: 'aggressive',
|
||||
repoRoot: path.resolve(__dirname, '../..'),
|
||||
quarantineDir: path.join(
|
||||
os.homedir(),
|
||||
'.copilot-resources-state',
|
||||
'quarantine',
|
||||
'copilot-history-cleanup',
|
||||
),
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
usage();
|
||||
process.exit(0);
|
||||
}
|
||||
if (arg === '--audit-dir') {
|
||||
options.auditDir = path.resolve(argv[index + 1] ?? '');
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--completion-cutoff') {
|
||||
options.completionCutoff = argv[index + 1] ?? '';
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--scope') {
|
||||
options.scope = argv[index + 1] ?? options.scope;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--quarantine-dir') {
|
||||
options.quarantineDir = path.resolve(argv[index + 1] ?? options.quarantineDir);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--repo-root') {
|
||||
options.repoRoot = path.resolve(argv[index + 1] ?? options.repoRoot);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--execute') {
|
||||
options.execute = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
if (!SCOPES.has(options.scope)) {
|
||||
throw new Error(`Unsupported scope: ${options.scope}`);
|
||||
}
|
||||
|
||||
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 findLatestAuditDir(repoRoot) {
|
||||
const auditRoot = path.join(repoRoot, '.local', 'audits');
|
||||
const dirs = [];
|
||||
|
||||
for (const firstLevel of listDirs(auditRoot)) {
|
||||
if (safeStat(path.join(firstLevel, 'selection-manifest.tsv'))) {
|
||||
dirs.push(firstLevel);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const secondLevel of listDirs(firstLevel)) {
|
||||
if (safeStat(path.join(secondLevel, 'selection-manifest.tsv'))) {
|
||||
dirs.push(secondLevel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let latest = null;
|
||||
for (const dirPath of dirs) {
|
||||
const stat = safeStat(dirPath);
|
||||
if (!stat) {
|
||||
continue;
|
||||
}
|
||||
if (!latest || stat.mtimeMs > latest.mtimeMs) {
|
||||
latest = { path: dirPath, mtimeMs: stat.mtimeMs };
|
||||
}
|
||||
}
|
||||
|
||||
return latest?.path ?? null;
|
||||
}
|
||||
|
||||
function resolveCutoffMs(options, auditDir) {
|
||||
if (options.completionCutoff) {
|
||||
const parsed = Date.parse(options.completionCutoff);
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new Error(`Invalid --completion-cutoff timestamp: ${options.completionCutoff}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const summaryPath = path.join(auditDir, 'promotion-summary.md');
|
||||
const summaryStat = safeStat(summaryPath);
|
||||
if (summaryStat) {
|
||||
return summaryStat.mtimeMs;
|
||||
}
|
||||
|
||||
const auditStat = safeStat(auditDir);
|
||||
if (auditStat) {
|
||||
return auditStat.mtimeMs;
|
||||
}
|
||||
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function normalizePath(filePath) {
|
||||
return path.resolve(filePath);
|
||||
}
|
||||
|
||||
function pathIsInside(candidatePath, parentPath) {
|
||||
const relativePath = path.relative(parentPath, candidatePath);
|
||||
return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath));
|
||||
}
|
||||
|
||||
function collectAuditRunTargets(repoRoot, currentAuditDir, cutoffMs) {
|
||||
const auditRoot = path.join(repoRoot, '.local', 'audits');
|
||||
const candidates = [];
|
||||
|
||||
for (const firstLevel of listDirs(auditRoot)) {
|
||||
const nested = listDirs(firstLevel);
|
||||
if (safeStat(path.join(firstLevel, 'selection-manifest.tsv'))) {
|
||||
nested.push(firstLevel);
|
||||
}
|
||||
|
||||
for (const runDir of nested) {
|
||||
const manifestPath = path.join(runDir, 'selection-manifest.tsv');
|
||||
if (!safeStat(manifestPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedRunDir = normalizePath(runDir);
|
||||
if (normalizedRunDir === currentAuditDir) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const runStat = safeStat(runDir);
|
||||
if (!runStat) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (runStat.mtimeMs > cutoffMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
path: normalizedRunDir,
|
||||
category: 'audit-run',
|
||||
reason: 'Historical audit run older than completion cutoff.',
|
||||
mtimeMs: runStat.mtimeMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function collectWorkspaceTargets(cutoffMs) {
|
||||
const workspaceRoot = path.join(
|
||||
os.homedir(),
|
||||
'Library',
|
||||
'Application Support',
|
||||
'Code',
|
||||
'User',
|
||||
'workspaceStorage',
|
||||
);
|
||||
|
||||
const relativeTargets = [
|
||||
'chatSessions',
|
||||
'chatEditingSessions',
|
||||
path.join('GitHub.copilot-chat', 'transcripts'),
|
||||
path.join('GitHub.copilot-chat', 'chat-session-resources'),
|
||||
];
|
||||
|
||||
const candidates = [];
|
||||
|
||||
for (const workspaceDir of listDirs(workspaceRoot)) {
|
||||
for (const relativeTarget of relativeTargets) {
|
||||
const absoluteTarget = path.join(workspaceDir, relativeTarget);
|
||||
const targetStat = safeStat(absoluteTarget);
|
||||
if (!targetStat || !targetStat.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (targetStat.mtimeMs > cutoffMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates.push({
|
||||
path: normalizePath(absoluteTarget),
|
||||
category: 'workspace-history',
|
||||
reason: 'Copilot or chat history artifact older than completion cutoff.',
|
||||
mtimeMs: targetStat.mtimeMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function dedupeNestedTargets(targets) {
|
||||
const sorted = [...targets].sort((left, right) => left.path.localeCompare(right.path));
|
||||
const result = [];
|
||||
|
||||
for (const target of sorted) {
|
||||
const parentAlreadySelected = result.some((existing) => pathIsInside(target.path, existing.path));
|
||||
if (!parentAlreadySelected) {
|
||||
result.push(target);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
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;
|
||||
const entries = fs.readdirSync(filePath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const childPath = path.join(filePath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
total += measureSize(childPath);
|
||||
} else if (entry.isFile()) {
|
||||
const childStat = safeStat(childPath);
|
||||
total += childStat?.size ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
function quarantineRelativePath(targetPath) {
|
||||
const relativeToRoot = path.relative(path.parse(targetPath).root, targetPath);
|
||||
return relativeToRoot.replace(/:/g, '_');
|
||||
}
|
||||
|
||||
function moveToQuarantine(targetPath, quarantineRoot) {
|
||||
const destinationPath = path.join(quarantineRoot, quarantineRelativePath(targetPath));
|
||||
ensureDir(path.dirname(destinationPath));
|
||||
|
||||
try {
|
||||
fs.renameSync(targetPath, destinationPath);
|
||||
} catch (error) {
|
||||
if (error?.code !== 'EXDEV') {
|
||||
throw error;
|
||||
}
|
||||
fs.cpSync(targetPath, destinationPath, { recursive: true });
|
||||
fs.rmSync(targetPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
return destinationPath;
|
||||
}
|
||||
|
||||
function writeQuarantineManifest(quarantineRunDir, results) {
|
||||
const manifestPath = path.join(quarantineRunDir, 'manifest.json');
|
||||
const rows = results
|
||||
.filter((result) => result.status === 'quarantined' && result.destination)
|
||||
.map((result) => ({
|
||||
originalPath: result.path,
|
||||
quarantinePath: result.destination,
|
||||
bytes: result.bytes,
|
||||
reason: result.reason,
|
||||
}));
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify({ createdAt: new Date().toISOString(), rows }, null, 2)}\n`, 'utf8');
|
||||
return manifestPath;
|
||||
}
|
||||
|
||||
function writeSummary({
|
||||
auditDir,
|
||||
execute,
|
||||
scope,
|
||||
cutoffMs,
|
||||
quarantineRunDir,
|
||||
targets,
|
||||
results,
|
||||
totalBytes,
|
||||
}) {
|
||||
const summaryPath = path.join(auditDir, 'cleanup-summary.md');
|
||||
const lines = [
|
||||
'# Copilot History Cleanup Summary',
|
||||
'',
|
||||
`- Generated: ${new Date().toISOString()}`,
|
||||
`- Mode: ${execute ? 'execute' : 'dry-run'}`,
|
||||
`- Scope: ${scope}`,
|
||||
`- Completion cutoff: ${new Date(cutoffMs).toISOString()}`,
|
||||
`- Targets discovered: ${targets.length}`,
|
||||
`- Targets processed: ${results.filter((entry) => entry.status === 'quarantined').length}`,
|
||||
`- Targets skipped: ${results.filter((entry) => entry.status === 'skipped').length}`,
|
||||
`- Targets failed: ${results.filter((entry) => entry.status === 'failed').length}`,
|
||||
`- Estimated bytes affected: ${formatBytes(totalBytes)}`,
|
||||
`- Quarantine directory: ${quarantineRunDir}`,
|
||||
'',
|
||||
'## Results',
|
||||
'',
|
||||
];
|
||||
|
||||
if (results.length === 0) {
|
||||
lines.push('- None');
|
||||
} else {
|
||||
for (const result of results) {
|
||||
const sizeText = result.bytes > 0 ? ` (${formatBytes(result.bytes)})` : '';
|
||||
const destinationText = result.destination ? ` -> ${result.destination}` : '';
|
||||
lines.push(`- [${result.status}] ${result.path}${sizeText}${destinationText}`);
|
||||
if (result.message) {
|
||||
lines.push(` - ${result.message}`);
|
||||
}
|
||||
if (result.reason) {
|
||||
lines.push(` - Reason: ${result.reason}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(summaryPath, `${lines.join('\n')}\n`, 'utf8');
|
||||
return summaryPath;
|
||||
}
|
||||
|
||||
export function runCleanup(rawOptions) {
|
||||
const options = {
|
||||
...rawOptions,
|
||||
repoRoot: path.resolve(rawOptions.repoRoot),
|
||||
auditDir: rawOptions.auditDir ? path.resolve(rawOptions.auditDir) : '',
|
||||
quarantineDir: path.resolve(rawOptions.quarantineDir),
|
||||
};
|
||||
|
||||
const auditDir = options.auditDir || findLatestAuditDir(options.repoRoot);
|
||||
if (!auditDir) {
|
||||
throw new Error('No audit directory was found. Run the audit first or pass --audit-dir.');
|
||||
}
|
||||
|
||||
const currentAuditDir = normalizePath(auditDir);
|
||||
const cutoffMs = resolveCutoffMs(options, currentAuditDir);
|
||||
const discoveredTargets = dedupeNestedTargets([
|
||||
...collectAuditRunTargets(options.repoRoot, currentAuditDir, cutoffMs),
|
||||
...collectWorkspaceTargets(cutoffMs),
|
||||
]);
|
||||
|
||||
const runLabel = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const quarantineRunDir = path.join(options.quarantineDir, runLabel);
|
||||
const results = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
if (options.execute) {
|
||||
ensureDir(quarantineRunDir);
|
||||
}
|
||||
|
||||
for (const target of discoveredTargets) {
|
||||
const bytes = measureSize(target.path);
|
||||
totalBytes += bytes;
|
||||
|
||||
if (!options.execute) {
|
||||
results.push({
|
||||
status: 'skipped',
|
||||
path: target.path,
|
||||
bytes,
|
||||
reason: target.reason,
|
||||
message: 'Dry-run: no changes were applied.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const destination = moveToQuarantine(target.path, quarantineRunDir);
|
||||
results.push({
|
||||
status: 'quarantined',
|
||||
path: target.path,
|
||||
destination,
|
||||
bytes,
|
||||
reason: target.reason,
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
status: 'failed',
|
||||
path: target.path,
|
||||
bytes,
|
||||
reason: target.reason,
|
||||
message: error?.message || String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let manifestPath = '';
|
||||
if (options.execute) {
|
||||
manifestPath = writeQuarantineManifest(quarantineRunDir, results);
|
||||
}
|
||||
|
||||
const summaryPath = writeSummary({
|
||||
auditDir: currentAuditDir,
|
||||
execute: options.execute,
|
||||
scope: options.scope,
|
||||
cutoffMs,
|
||||
quarantineRunDir,
|
||||
targets: discoveredTargets,
|
||||
results,
|
||||
totalBytes,
|
||||
});
|
||||
|
||||
return {
|
||||
auditDir: currentAuditDir,
|
||||
cutoffIso: new Date(cutoffMs).toISOString(),
|
||||
targetsDiscovered: discoveredTargets.length,
|
||||
processed: results.filter((entry) => entry.status === 'quarantined').length,
|
||||
failed: results.filter((entry) => entry.status === 'failed').length,
|
||||
summaryPath,
|
||||
manifestPath,
|
||||
quarantineRunDir,
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const result = runCleanup(options);
|
||||
|
||||
console.log('Copilot history cleanup complete.');
|
||||
console.log(`Audit directory: ${result.auditDir}`);
|
||||
console.log(`Completion cutoff: ${result.cutoffIso}`);
|
||||
console.log(`Targets discovered: ${result.targetsDiscovered}`);
|
||||
console.log(`Targets quarantined: ${result.processed}`);
|
||||
console.log(`Targets failed: ${result.failed}`);
|
||||
console.log(`Quarantine directory: ${result.quarantineRunDir}`);
|
||||
if (result.manifestPath) {
|
||||
console.log(`Manifest: ${result.manifestPath}`);
|
||||
}
|
||||
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