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 };
|
||||
}
|
||||
|
||||
|
||||
110
commands/gsd/reapply-patches.md
Normal file
110
commands/gsd/reapply-patches.md
Normal file
@@ -0,0 +1,110 @@
|
||||
---
|
||||
description: Reapply local modifications after a GSD update
|
||||
allowed-tools: Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion
|
||||
---
|
||||
|
||||
<purpose>
|
||||
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.
|
||||
</purpose>
|
||||
|
||||
<process>
|
||||
|
||||
## 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.
|
||||
```
|
||||
|
||||
</process>
|
||||
|
||||
<success_criteria>
|
||||
- [ ] All backed-up patches processed
|
||||
- [ ] User modifications merged into new version
|
||||
- [ ] Conflicts resolved with user input
|
||||
- [ ] Status reported for each file
|
||||
</success_criteria>
|
||||
@@ -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):
|
||||
```
|
||||
</step>
|
||||
|
||||
|
||||
<step name="check_local_patches">
|
||||
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.
|
||||
</step>
|
||||
</process>
|
||||
|
||||
<success_criteria>
|
||||
|
||||
Reference in New Issue
Block a user