diff --git a/bin/install.js b/bin/install.js index 36deb6ee4..42cea961c 100755 --- a/bin/install.js +++ b/bin/install.js @@ -4,6 +4,7 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); const readline = require('readline'); +const crypto = require('crypto'); // Colors const cyan = '\x1b[36m'; @@ -1131,6 +1132,140 @@ function verifyFileInstalled(filePath, description) { * @param {boolean} isGlobal - Whether to install globally or locally * @param {string} runtime - Target runtime ('claude', 'opencode', 'gemini') */ + +// ────────────────────────────────────────────────────── +// Local Patch Persistence +// ────────────────────────────────────────────────────── + +const PATCHES_DIR_NAME = 'gsd-local-patches'; +const MANIFEST_NAME = 'gsd-file-manifest.json'; + +/** + * Compute SHA256 hash of file contents + */ +function fileHash(filePath) { + const content = fs.readFileSync(filePath); + return crypto.createHash('sha256').update(content).digest('hex'); +} + +/** + * Recursively collect all files in dir with their hashes + */ +function generateManifest(dir, baseDir) { + if (!baseDir) baseDir = dir; + const manifest = {}; + if (!fs.existsSync(dir)) return manifest; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + const relPath = path.relative(baseDir, fullPath).replace(/\\/g, '/'); + if (entry.isDirectory()) { + Object.assign(manifest, generateManifest(fullPath, baseDir)); + } else { + manifest[relPath] = fileHash(fullPath); + } + } + return manifest; +} + +/** + * Write file manifest after installation for future modification detection + */ +function writeManifest(configDir) { + const gsdDir = path.join(configDir, 'get-shit-done'); + const commandsDir = path.join(configDir, 'commands', 'gsd'); + const agentsDir = path.join(configDir, 'agents'); + const manifest = { version: pkg.version, timestamp: new Date().toISOString(), files: {} }; + + const gsdHashes = generateManifest(gsdDir); + for (const [rel, hash] of Object.entries(gsdHashes)) { + manifest.files['get-shit-done/' + rel] = hash; + } + if (fs.existsSync(commandsDir)) { + const cmdHashes = generateManifest(commandsDir); + for (const [rel, hash] of Object.entries(cmdHashes)) { + manifest.files['commands/gsd/' + rel] = hash; + } + } + if (fs.existsSync(agentsDir)) { + for (const file of fs.readdirSync(agentsDir)) { + if (file.startsWith('gsd-') && file.endsWith('.md')) { + manifest.files['agents/' + file] = fileHash(path.join(agentsDir, file)); + } + } + } + + fs.writeFileSync(path.join(configDir, MANIFEST_NAME), JSON.stringify(manifest, null, 2)); + return manifest; +} + +/** + * Detect user-modified GSD files by comparing against install manifest. + * Backs up modified files to gsd-local-patches/ for reapply after update. + */ +function saveLocalPatches(configDir) { + const manifestPath = path.join(configDir, MANIFEST_NAME); + if (!fs.existsSync(manifestPath)) return []; + + let manifest; + try { manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); } catch { return []; } + + const patchesDir = path.join(configDir, PATCHES_DIR_NAME); + const modified = []; + + for (const [relPath, originalHash] of Object.entries(manifest.files || {})) { + const fullPath = path.join(configDir, relPath); + if (!fs.existsSync(fullPath)) continue; + const currentHash = fileHash(fullPath); + if (currentHash !== originalHash) { + const backupPath = path.join(patchesDir, relPath); + fs.mkdirSync(path.dirname(backupPath), { recursive: true }); + fs.copyFileSync(fullPath, backupPath); + modified.push(relPath); + } + } + + if (modified.length > 0) { + const meta = { + backed_up_at: new Date().toISOString(), + from_version: manifest.version, + files: modified + }; + fs.writeFileSync(path.join(patchesDir, 'backup-meta.json'), JSON.stringify(meta, null, 2)); + console.log(' ' + yellow + 'i' + reset + ' Found ' + modified.length + ' locally modified GSD file(s) — backed up to ' + PATCHES_DIR_NAME + '/'); + for (const f of modified) { + console.log(' ' + dim + f + reset); + } + } + return modified; +} + +/** + * After install, report backed-up patches for user to reapply. + */ +function reportLocalPatches(configDir) { + const patchesDir = path.join(configDir, PATCHES_DIR_NAME); + const metaPath = path.join(patchesDir, 'backup-meta.json'); + if (!fs.existsSync(metaPath)) return []; + + let meta; + try { meta = JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { return []; } + + if (meta.files && meta.files.length > 0) { + console.log(''); + console.log(' ' + yellow + 'Local patches detected' + reset + ' (from v' + meta.from_version + '):'); + for (const f of meta.files) { + console.log(' ' + cyan + f + reset); + } + console.log(''); + console.log(' Your modifications are saved in ' + cyan + PATCHES_DIR_NAME + '/' + reset); + console.log(' Run ' + cyan + '/gsd:reapply-patches' + reset + ' to merge them into the new version.'); + console.log(' Or manually compare and merge the files.'); + console.log(''); + } + return meta.files || []; +} + function install(isGlobal, runtime = 'claude') { const isOpencode = runtime === 'opencode'; const isGemini = runtime === 'gemini'; @@ -1162,6 +1297,9 @@ function install(isGlobal, runtime = 'claude') { // Track installation failures const failures = []; + // Save any locally modified GSD files before they get wiped + saveLocalPatches(targetDir); + // Clean up orphaned files from previous versions cleanupOrphanedFiles(targetDir); @@ -1340,6 +1478,13 @@ function install(isGlobal, runtime = 'claude') { } } + // Write file manifest for future modification detection + writeManifest(targetDir); + console.log(` ${green}✓${reset} Wrote file manifest (${MANIFEST_NAME})`); + + // Report any backed-up local patches + reportLocalPatches(targetDir); + return { settingsPath, settings, statuslineCommand, runtime }; } diff --git a/commands/gsd/reapply-patches.md b/commands/gsd/reapply-patches.md new file mode 100644 index 000000000..4f1260d70 --- /dev/null +++ b/commands/gsd/reapply-patches.md @@ -0,0 +1,110 @@ +--- +description: Reapply local modifications after a GSD update +allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion +--- + + +After a GSD update wipes and reinstalls files, this command merges user's previously saved local modifications back into the new version. Uses intelligent comparison to handle cases where the upstream file also changed. + + + + +## Step 1: Detect backed-up patches + +Check for local patches directory: + +```bash +# Global install +PATCHES_DIR="${HOME}/.claude/gsd-local-patches" +# Local install fallback +if [ ! -d "$PATCHES_DIR" ]; then + PATCHES_DIR="./.claude/gsd-local-patches" +fi +``` + +Read `backup-meta.json` from the patches directory. + +**If no patches found:** +``` +No local patches found. Nothing to reapply. + +Local patches are automatically saved when you run /gsd:update +after modifying any GSD workflow, command, or agent files. +``` +Exit. + +## Step 2: Show patch summary + +``` +## Local Patches to Reapply + +**Backed up from:** v{from_version} +**Current version:** {read VERSION file} +**Files modified:** {count} + +| # | File | Status | +|---|------|--------| +| 1 | {file_path} | Pending | +| 2 | {file_path} | Pending | +``` + +## Step 3: Merge each file + +For each file in `backup-meta.json`: + +1. **Read the backed-up version** (user's modified copy from `gsd-local-patches/`) +2. **Read the newly installed version** (current file after update) +3. **Compare and merge:** + + - If the new file is identical to the backed-up file: skip (modification was incorporated upstream) + - If the new file differs: identify the user's modifications and apply them to the new version + + **Merge strategy:** + - Read both versions fully + - Identify sections the user added or modified (look for additions, not just differences from path replacement) + - Apply user's additions/modifications to the new version + - If a section the user modified was also changed upstream: flag as conflict, show both versions, ask user which to keep + +4. **Write merged result** to the installed location +5. **Report status:** + - `Merged` — user modifications applied cleanly + - `Skipped` — modification already in upstream + - `Conflict` — user chose resolution + +## Step 4: Update manifest + +After reapplying, regenerate the file manifest so future updates correctly detect these as user modifications: + +```bash +# The manifest will be regenerated on next /gsd:update +# For now, just note which files were modified +``` + +## Step 5: Cleanup option + +Ask user: +- "Keep patch backups for reference?" → preserve `gsd-local-patches/` +- "Clean up patch backups?" → remove `gsd-local-patches/` directory + +## Step 6: Report + +``` +## Patches Reapplied + +| # | File | Status | +|---|------|--------| +| 1 | {file_path} | ✓ Merged | +| 2 | {file_path} | ○ Skipped (already upstream) | +| 3 | {file_path} | ⚠ Conflict resolved | + +{count} file(s) updated. Your local modifications are active again. +``` + + + + +- [ ] All backed-up patches processed +- [ ] User modifications merged into new version +- [ ] Conflicts resolved with user input +- [ ] Status reported for each file + diff --git a/get-shit-done/workflows/update.md b/get-shit-done/workflows/update.md index 10d4d1dfd..84e08462e 100644 --- a/get-shit-done/workflows/update.md +++ b/get-shit-done/workflows/update.md @@ -129,7 +129,7 @@ Your custom files in other locations are preserved: - Custom hooks ✓ - Your CLAUDE.md files ✓ -If you've modified any GSD files directly, back them up first. +If you've modified any GSD files directly, they'll be automatically backed up to `gsd-local-patches/` and can be reapplied with `/gsd:reapply-patches` after the update. ``` Use AskUserQuestion: @@ -183,6 +183,21 @@ Format completion message (changelog was already shown in confirmation step): ``` + + +After update completes, check if the installer detected and backed up any locally modified files: + +Check for gsd-local-patches/backup-meta.json in the config directory. + +**If patches found:** + +``` +Local patches were backed up before the update. +Run /gsd:reapply-patches to merge your modifications into the new version. +``` + +**If no patches:** Continue normally. +