Compare commits
2
Commits
14b71eac0a
...
e33d98df12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e33d98df12 | ||
|
|
71e89ca5c0 |
@@ -119,6 +119,76 @@ The command reads approved rows from `selection-manifest.tsv` and writes:
|
|||||||
- `staging-notes/` for approved rows that still need manual design work
|
- `staging-notes/` for approved rows that still need manual design work
|
||||||
- `promotion-summary.md` to summarize what was generated
|
- `promotion-summary.md` to summarize what was generated
|
||||||
|
|
||||||
|
After promotion preparation completes, aggressive cleanup runs automatically.
|
||||||
|
The cleanup step quarantines historical Copilot chat/session artifacts and older
|
||||||
|
audit runs up to the completion boundary, then writes `cleanup-summary.md` in
|
||||||
|
the same audit directory.
|
||||||
|
|
||||||
|
Manual cleanup command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
resources/scripts/cleanup-copilot-history.sh --audit-dir .local/audits/<machine-id>/<timestamp>
|
||||||
|
```
|
||||||
|
|
||||||
|
Execute mode (applies changes instead of dry-run):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
resources/scripts/cleanup-copilot-history.sh --audit-dir .local/audits/<machine-id>/<timestamp> --execute
|
||||||
|
```
|
||||||
|
|
||||||
|
Cleanup quarantines data under:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.copilot-resources-state/quarantine/copilot-history-cleanup/
|
||||||
|
```
|
||||||
|
|
||||||
|
Restore the latest quarantine run in dry-run mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
resources/scripts/restore-copilot-history.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Restore a specific quarantine run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
resources/scripts/restore-copilot-history.sh --quarantine-run ~/.copilot-resources-state/quarantine/copilot-history-cleanup/<timestamp> --execute
|
||||||
|
```
|
||||||
|
|
||||||
|
Restore only matching paths:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
resources/scripts/restore-copilot-history.sh --filter workspaceStorage/483a18cf047eff8026d789521f5879e3 --execute
|
||||||
|
```
|
||||||
|
|
||||||
|
Restore one exact original path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
resources/scripts/restore-copilot-history.sh --path "/Users/<you>/Library/Application Support/Code/User/workspaceStorage/<id>/chatSessions" --execute
|
||||||
|
```
|
||||||
|
|
||||||
|
The restore command skips destinations that already exist unless `--overwrite`
|
||||||
|
is passed.
|
||||||
|
|
||||||
|
Hard-purge old quarantine runs in dry-run mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
resources/scripts/purge-copilot-history-quarantine.sh --before 2026-05-24T00:00:00Z
|
||||||
|
```
|
||||||
|
|
||||||
|
Execute purge while keeping the newest quarantine run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
resources/scripts/purge-copilot-history-quarantine.sh --before 2026-05-24T00:00:00Z --keep-latest 1 --execute
|
||||||
|
```
|
||||||
|
|
||||||
|
Protected paths are never cleanup targets, including:
|
||||||
|
|
||||||
|
- `~/.copilot-resources`
|
||||||
|
- `~/.copilot/*` symlink targets
|
||||||
|
- `~/.copilot-resources-state/publish-log.tsv`
|
||||||
|
- `~/.copilot-resources-state/project-ports-registry.json`
|
||||||
|
- repository source files under `resources/`, `docs/`, and `templates/`
|
||||||
|
|
||||||
## Limits
|
## Limits
|
||||||
|
|
||||||
- This audits persisted local artifacts only.
|
- This audits persisted local artifacts only.
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
name: "Global Engineering Best Practices"
|
||||||
|
description: "Umbrella baseline for cross-project engineering guidance."
|
||||||
|
applyTo: "**"
|
||||||
|
---
|
||||||
|
|
||||||
|
- Use `engineering-core-defaults.instructions.md` for global engineering behavior.
|
||||||
|
- Use `language-and-tooling-defaults.instructions.md` for file-type and tooling baselines.
|
||||||
|
- When project-local instructions are present, treat them as the authoritative source.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
name: "Engineering Core Defaults"
|
||||||
|
description: "Cross-project baseline for maintainable, predictable, and secure engineering decisions."
|
||||||
|
applyTo: "**"
|
||||||
|
---
|
||||||
|
|
||||||
|
- Prioritize maintainability, readability, and predictability over novelty.
|
||||||
|
- Prefer explicit behavior and descriptive names over implicit shortcuts.
|
||||||
|
- Reuse existing project patterns and utilities before introducing abstractions.
|
||||||
|
- Keep implementations modular and scoped; avoid monolithic files and deep nesting.
|
||||||
|
- Remove dead code, unused imports, and duplicate logic when touching related areas.
|
||||||
|
- Add comments only for non-obvious intent, trade-offs, or constraints.
|
||||||
|
- Favor composition over inheritance and use early returns to simplify control flow.
|
||||||
|
- Default to secure practices: validate inputs, isolate secrets, and avoid hardcoded credentials.
|
||||||
|
- Keep dependencies minimal and add new ones only with clear justification.
|
||||||
|
- Optimize for practical performance without reducing clarity or maintainability.
|
||||||
|
- Treat project-local instructions as authoritative when they conflict with global defaults.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: "Environment and Config Standards"
|
||||||
|
description: "Cross-project standards for env and conf files, with secure configuration defaults."
|
||||||
|
applyTo: "**/*.env,**/*.env.*,**/*.conf"
|
||||||
|
---
|
||||||
|
|
||||||
|
- Keep secrets out of source-controlled env files.
|
||||||
|
- Use environment-specific overrides instead of duplicating large config blocks.
|
||||||
|
- Keep `.example` templates free of real credentials.
|
||||||
|
- Document required variables and reasonable defaults.
|
||||||
|
- Prefer least-privilege and secret isolation practices.
|
||||||
|
- Ensure runtime/deploy scripts fail clearly when required variables are missing.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
name: "HTML Standards"
|
||||||
|
description: "Cross-project HTML standards for semantic structure and accessibility."
|
||||||
|
applyTo: "**/*.html"
|
||||||
|
---
|
||||||
|
|
||||||
|
- Prefer semantic elements over generic wrappers.
|
||||||
|
- Maintain accessible heading order and labels.
|
||||||
|
- Keep layouts responsive and avoid inline styling where possible.
|
||||||
|
- Avoid deprecated or non-semantic patterns.
|
||||||
|
- Ensure keyboard and screen-reader-friendly markup for interactive elements.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
name: "JavaScript Standards"
|
||||||
|
description: "Cross-project JavaScript and MJS standards for clarity, maintainability, and performance-aware code."
|
||||||
|
applyTo: "**/*.js,**/*.mjs"
|
||||||
|
---
|
||||||
|
|
||||||
|
- Prefer `const` over `let`, and avoid `var`.
|
||||||
|
- Use async/await for asynchronous flows.
|
||||||
|
- Keep modules focused and avoid monolithic files.
|
||||||
|
- Remove dead code and unused imports during related edits.
|
||||||
|
- Use early returns to reduce branching complexity.
|
||||||
|
- Keep behavior explicit; avoid implicit coercion and hidden side effects.
|
||||||
|
- Favor composition and reusable utilities over duplicated logic.
|
||||||
|
- Avoid deep nesting, callback-heavy control flow, and blocking sync work in user-facing paths.
|
||||||
|
- Validate with the project's JavaScript lint, test, and build commands.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: "JSON Standards"
|
||||||
|
description: "Cross-project JSON standards for stable structure and deterministic formatting."
|
||||||
|
applyTo: "**/*.json"
|
||||||
|
---
|
||||||
|
|
||||||
|
- Keep JSON formatting consistent and deterministic.
|
||||||
|
- Avoid duplicate keys and ambiguous nesting.
|
||||||
|
- Prefer stable, explicit property names.
|
||||||
|
- Remove unused configuration fields during related edits.
|
||||||
|
- Preserve existing data contracts unless intentional changes are documented.
|
||||||
|
- Validate JSON syntax before commit.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
name: "Language and Tooling Defaults"
|
||||||
|
description: "Cross-project language and file-type guidance for JS, Vue, SCSS, Markdown, JSON, HTML, Shell, YAML, and config files."
|
||||||
|
applyTo: "**/*.js,**/*.mjs,**/*.vue,**/*.scss,**/*.md,**/*.json,**/*.html,**/*.sh,**/*.yml,**/*.yaml,**/*.env,**/*.conf"
|
||||||
|
---
|
||||||
|
|
||||||
|
- Use `javascript.instructions.md` for JavaScript and MJS files.
|
||||||
|
- Use `vue.instructions.md` for Vue SFC files.
|
||||||
|
- Use `scss.instructions.md` for SCSS stylesheets.
|
||||||
|
- Use `markdown.instructions.md` for Markdown docs.
|
||||||
|
- Use `json.instructions.md` for JSON data/config files.
|
||||||
|
- Use `html.instructions.md` for HTML templates/pages.
|
||||||
|
- Use `shell.instructions.md` for shell scripts.
|
||||||
|
- Use `yaml.instructions.md` for YAML configs.
|
||||||
|
- Use `env-config.instructions.md` for env and conf files.
|
||||||
|
- Prefer project-local instructions when they provide more specific guidance.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: "Markdown Standards"
|
||||||
|
description: "Cross-project Markdown standards for concise, scan-friendly documentation."
|
||||||
|
applyTo: "**/*.md"
|
||||||
|
---
|
||||||
|
|
||||||
|
- Keep documents concise, structured, and easy to scan.
|
||||||
|
- Use consistent heading hierarchy.
|
||||||
|
- Prefer bullet lists and tables for structured content.
|
||||||
|
- Avoid large unbroken paragraphs.
|
||||||
|
- Use examples where they improve clarity.
|
||||||
|
- Keep formatting markdownlint-compatible when possible.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
name: "SCSS Standards"
|
||||||
|
description: "Cross-project SCSS standards for modular styling and maintainable selector design."
|
||||||
|
applyTo: "**/*.scss"
|
||||||
|
---
|
||||||
|
|
||||||
|
- Keep selectors shallow and specificity low.
|
||||||
|
- Reuse variables and mixins instead of repeating style constants.
|
||||||
|
- Keep style modules focused; avoid monolithic stylesheet files.
|
||||||
|
- Avoid deep nesting and `!important` except as a last resort.
|
||||||
|
- Remove duplicated selectors and dead style rules during related edits.
|
||||||
|
- Prefer design-token reuse before adding new primitives.
|
||||||
|
- Validate with project style lint/build commands when available.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: "Shell Standards"
|
||||||
|
description: "Cross-project shell scripting standards for safety and predictable automation."
|
||||||
|
applyTo: "**/*.sh"
|
||||||
|
---
|
||||||
|
|
||||||
|
- Use defensive scripting with explicit error handling.
|
||||||
|
- Quote variables and paths.
|
||||||
|
- Validate inputs before using them in commands.
|
||||||
|
- Keep scripts modular and avoid hidden side effects.
|
||||||
|
- Avoid unsafe deletion patterns, silent failures, and hardcoded environment-specific paths.
|
||||||
|
- Keep scripts ShellCheck-compatible where feasible.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
name: "Vue Standards"
|
||||||
|
description: "Cross-project Vue standards for component structure, state derivation, and rendering efficiency."
|
||||||
|
applyTo: "**/*.vue"
|
||||||
|
---
|
||||||
|
|
||||||
|
- Prefer Composition API with `<script setup>` for new components.
|
||||||
|
- Keep components single-purpose and move reusable logic to composables.
|
||||||
|
- Prefer computed state over watchers when deriving values.
|
||||||
|
- Use props/events for communication instead of tight coupling.
|
||||||
|
- Keep templates declarative; avoid embedding business logic in templates.
|
||||||
|
- Minimize reactive dependencies and avoid broad reactive objects when narrower refs suffice.
|
||||||
|
- Use lazy loading and route-level splitting when project architecture supports it.
|
||||||
|
- Validate with the project's Vue lint/test/build commands.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
name: "YAML Standards"
|
||||||
|
description: "Cross-project YAML standards for readable and consistent configuration files."
|
||||||
|
applyTo: "**/*.yml,**/*.yaml"
|
||||||
|
---
|
||||||
|
|
||||||
|
- Use consistent indentation and spacing.
|
||||||
|
- Keep values explicit and structures readable.
|
||||||
|
- Avoid overly complex inline expressions.
|
||||||
|
- Keep file conventions consistent within each configuration domain.
|
||||||
|
- Keep YAML yamllint-compatible where feasible.
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
Executable
+17
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||||
|
repo_root="$(cd -- "$script_dir/../.." && pwd -P)"
|
||||||
|
|
||||||
|
if command -v node >/dev/null 2>&1; then
|
||||||
|
node_bin="$(command -v node)"
|
||||||
|
elif command -v nodejs >/dev/null 2>&1; then
|
||||||
|
node_bin="$(command -v nodejs)"
|
||||||
|
else
|
||||||
|
printf 'Node.js is required to run Copilot history cleanup.\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$node_bin" "$script_dir/cleanup-copilot-history.mjs" --repo-root "$repo_root" "$@"
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { runCleanup } from "./cleanup-copilot-history.mjs";
|
||||||
|
import { runRestore } from "./restore-copilot-history.mjs";
|
||||||
|
import { runPurge } from "./purge-copilot-history-quarantine.mjs";
|
||||||
|
|
||||||
|
function makeDir(dirPath) {
|
||||||
|
fs.mkdirSync(dirPath, { recursive: true });
|
||||||
|
return dirPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeFile(filePath, content = "x") {
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
fs.writeFileSync(filePath, content, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(filePath) {
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test("cleanup writes a quarantine manifest for exact restore targeting", () => {
|
||||||
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-history-cleanup-"));
|
||||||
|
const homeDir = path.join(tempRoot, "home");
|
||||||
|
const repoRoot = path.join(tempRoot, "repo");
|
||||||
|
const auditDir = makeDir(path.join(repoRoot, ".local", "audits", "forced-run"));
|
||||||
|
writeFile(path.join(auditDir, "selection-manifest.tsv"), "decision\treview_note\n");
|
||||||
|
writeFile(path.join(auditDir, "promotion-summary.md"), "summary\n");
|
||||||
|
|
||||||
|
const oldAuditDir = makeDir(path.join(repoRoot, ".local", "audits", "old-run"));
|
||||||
|
writeFile(path.join(oldAuditDir, "selection-manifest.tsv"), "decision\treview_note\n");
|
||||||
|
|
||||||
|
const workspaceArtifact = makeDir(
|
||||||
|
path.join(
|
||||||
|
homeDir,
|
||||||
|
"Library",
|
||||||
|
"Application Support",
|
||||||
|
"Code",
|
||||||
|
"User",
|
||||||
|
"workspaceStorage",
|
||||||
|
"abc123",
|
||||||
|
"chatSessions",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
writeFile(path.join(workspaceArtifact, "session.jsonl"), "{}\n");
|
||||||
|
|
||||||
|
const previousHome = process.env.HOME;
|
||||||
|
process.env.HOME = homeDir;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = runCleanup({
|
||||||
|
repoRoot,
|
||||||
|
auditDir,
|
||||||
|
completionCutoff: "2100-01-01T00:00:00.000Z",
|
||||||
|
scope: "aggressive",
|
||||||
|
quarantineDir: path.join(homeDir, ".copilot-resources-state", "quarantine", "copilot-history-cleanup"),
|
||||||
|
execute: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.processed, 2);
|
||||||
|
assert.equal(Boolean(result.manifestPath), true);
|
||||||
|
|
||||||
|
const manifest = readJson(result.manifestPath);
|
||||||
|
assert.equal(manifest.rows.length, 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
manifest.rows.map((row) => row.originalPath).sort(),
|
||||||
|
[
|
||||||
|
path.join(oldAuditDir),
|
||||||
|
path.join(homeDir, "Library", "Application Support", "Code", "User", "workspaceStorage", "abc123", "chatSessions"),
|
||||||
|
].sort(),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
process.env.HOME = previousHome;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("restore exact path limits candidates to one original target", () => {
|
||||||
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-history-restore-"));
|
||||||
|
const quarantineDir = makeDir(path.join(tempRoot, "quarantine"));
|
||||||
|
const runDir = makeDir(path.join(quarantineDir, "2026-05-24T01-21-04-653Z"));
|
||||||
|
|
||||||
|
const firstTarget = makeDir(path.join(runDir, "Users", "fragginwagon", "Library", "Application Support", "Code", "User", "workspaceStorage", "aaa", "chatSessions"));
|
||||||
|
const secondTarget = makeDir(path.join(runDir, "Users", "fragginwagon", "Library", "Application Support", "Code", "User", "workspaceStorage", "bbb", "chatEditingSessions"));
|
||||||
|
writeFile(path.join(firstTarget, "session.jsonl"), "{}\n");
|
||||||
|
writeFile(path.join(secondTarget, "state.json"), "{}\n");
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(runDir, "manifest.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
createdAt: "2026-05-24T01:21:04.653Z",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
originalPath: "/Users/fragginwagon/Library/Application Support/Code/User/workspaceStorage/aaa/chatSessions",
|
||||||
|
quarantinePath: firstTarget,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
originalPath: "/Users/fragginwagon/Library/Application Support/Code/User/workspaceStorage/bbb/chatEditingSessions",
|
||||||
|
quarantinePath: secondTarget,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = runRestore({
|
||||||
|
quarantineDir,
|
||||||
|
quarantineRun: runDir,
|
||||||
|
exactPath: "/Users/fragginwagon/Library/Application Support/Code/User/workspaceStorage/aaa/chatSessions",
|
||||||
|
filter: "",
|
||||||
|
overwrite: false,
|
||||||
|
execute: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.candidates, 1);
|
||||||
|
const summary = fs.readFileSync(result.summaryPath, "utf8");
|
||||||
|
assert.match(summary, /workspaceStorage\/aaa\/chatSessions/);
|
||||||
|
assert.doesNotMatch(summary, /workspaceStorage\/bbb\/chatEditingSessions/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("purge keeps newest quarantine run and selects older ones before cutoff", () => {
|
||||||
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-history-purge-"));
|
||||||
|
const quarantineDir = makeDir(path.join(tempRoot, "quarantine"));
|
||||||
|
const oldRun = makeDir(path.join(quarantineDir, "2026-05-20T00-00-00-000Z"));
|
||||||
|
const newRun = makeDir(path.join(quarantineDir, "2026-05-24T00-00-00-000Z"));
|
||||||
|
writeFile(path.join(oldRun, "manifest.json"), "{}\n");
|
||||||
|
writeFile(path.join(newRun, "manifest.json"), "{}\n");
|
||||||
|
|
||||||
|
const oldTime = new Date("2026-05-20T00:00:00.000Z");
|
||||||
|
const newTime = new Date("2026-05-24T00:00:00.000Z");
|
||||||
|
fs.utimesSync(oldRun, oldTime, oldTime);
|
||||||
|
fs.utimesSync(newRun, newTime, newTime);
|
||||||
|
|
||||||
|
const result = runPurge({
|
||||||
|
quarantineDir,
|
||||||
|
before: "2026-05-23T00:00:00.000Z",
|
||||||
|
keepLatest: 1,
|
||||||
|
execute: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.considered, 2);
|
||||||
|
assert.equal(result.purged, 0);
|
||||||
|
const summary = fs.readFileSync(result.summaryPath, "utf8");
|
||||||
|
assert.match(summary, /Protected by keep-latest/);
|
||||||
|
assert.match(summary, /Dry-run: no changes were applied/);
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import fs from 'node:fs';
|
|||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { runCleanup } from './cleanup-copilot-history.mjs';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
@@ -470,12 +471,35 @@ function main() {
|
|||||||
const generatedArtifacts = writeArtifacts(auditDir, approvedRows);
|
const generatedArtifacts = writeArtifacts(auditDir, approvedRows);
|
||||||
const summaryPath = writeSummary(auditDir, manifestPath, approvedRows, generatedArtifacts);
|
const summaryPath = writeSummary(auditDir, manifestPath, approvedRows, generatedArtifacts);
|
||||||
|
|
||||||
|
let cleanupSummaryPath = '';
|
||||||
|
try {
|
||||||
|
const cleanupResult = runCleanup({
|
||||||
|
repoRoot: options.repoRoot,
|
||||||
|
auditDir,
|
||||||
|
completionCutoff: new Date().toISOString(),
|
||||||
|
scope: 'aggressive',
|
||||||
|
execute: true,
|
||||||
|
quarantineDir: path.join(
|
||||||
|
os.homedir(),
|
||||||
|
'.copilot-resources-state',
|
||||||
|
'quarantine',
|
||||||
|
'copilot-history-cleanup',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
cleanupSummaryPath = cleanupResult.summaryPath;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Post-audit cleanup failed: ${error?.message || String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Audit promotion preparation complete.');
|
console.log('Audit promotion preparation complete.');
|
||||||
console.log(`Audit directory: ${auditDir}`);
|
console.log(`Audit directory: ${auditDir}`);
|
||||||
console.log(`Approved rows: ${approvedRows.length}`);
|
console.log(`Approved rows: ${approvedRows.length}`);
|
||||||
console.log(`Draft resources: ${generatedArtifacts.filter((artifact) => artifact.kind === 'draft').length}`);
|
console.log(`Draft resources: ${generatedArtifacts.filter((artifact) => artifact.kind === 'draft').length}`);
|
||||||
console.log(`Staging notes: ${generatedArtifacts.filter((artifact) => artifact.kind === 'note').length}`);
|
console.log(`Staging notes: ${generatedArtifacts.filter((artifact) => artifact.kind === 'note').length}`);
|
||||||
console.log(`Summary: ${summaryPath}`);
|
console.log(`Summary: ${summaryPath}`);
|
||||||
|
if (cleanupSummaryPath) {
|
||||||
|
console.log(`Cleanup summary: ${cleanupSummaryPath}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main();
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||||
|
|
||||||
|
if command -v node >/dev/null 2>&1; then
|
||||||
|
node_bin="$(command -v node)"
|
||||||
|
elif command -v nodejs >/dev/null 2>&1; then
|
||||||
|
node_bin="$(command -v nodejs)"
|
||||||
|
else
|
||||||
|
printf 'Node.js is required to purge Copilot history quarantine runs.\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$node_bin" "$script_dir/purge-copilot-history-quarantine.mjs" "$@"
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||||
|
|
||||||
|
if command -v node >/dev/null 2>&1; then
|
||||||
|
node_bin="$(command -v node)"
|
||||||
|
elif command -v nodejs >/dev/null 2>&1; then
|
||||||
|
node_bin="$(command -v nodejs)"
|
||||||
|
else
|
||||||
|
printf 'Node.js is required to run Copilot history restore.\n' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$node_bin" "$script_dir/restore-copilot-history.mjs" "$@"
|
||||||
Reference in New Issue
Block a user