feat: preserve local patches across GSD updates (#481)
When users modify GSD workflow files (e.g., adding bug workarounds), those changes get wiped on every /gsd:update. This adds automatic backup and guided restore: **install.js changes:** - Writes `gsd-file-manifest.json` after install with SHA256 hashes of every installed GSD file - Before wiping on update, compares current files against manifest to detect user modifications - Backs up modified files to `gsd-local-patches/` directory - Reports backed-up patches after install completes **New command: /gsd:reapply-patches** - LLM-guided merge of backed-up modifications into new version - Handles cases where upstream also changed the same file - Reports merge status per file (merged/skipped/conflict) **update.md changes:** - Warning text now mentions automatic backup instead of manual - New step after install to check for and report backed-up patches Flow: modify GSD file → /gsd:update → modifications auto-backed up → new version installed → /gsd:reapply-patches → modifications merged back Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
145
bin/install.js
145
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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user