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:
min-k-khant
2026-02-08 06:35:42 -08:00
committed by GitHub
parent ea0204bfc7
commit ca03a061a8
3 changed files with 271 additions and 1 deletions

View File

@@ -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 };
}