fix(installer): restore detect-custom-files and backup_custom_files lost in release drift (#1997) (#2233)

PR #2038 added detect-custom-files to gsd-tools.cjs and the backup_custom_files
step to update.md, but commit 7bfb11b6 is not an ancestor of v1.36.0: main was
rebuilt after the merge, orphaning the change. Users on 1.36.0 running /gsd-update
silently lose any locally-authored files inside GSD-managed directories.

Root cause: git merge-base 7bfb11b6 HEAD returns aa3e9cf (Cline runtime, PR #2032),
117 commits before the release tag. The "merged" GitHub state reflects the PR merge
event, not reachability from the default branch.

Fix: re-apply the three changes from 7bfb11b6 onto current main:
- Add detect-custom-files subcommand to gsd-tools.cjs (walk managed dirs, compare
  against gsd-file-manifest.json keys via path.relative(), return JSON list)
- Add 'detect-custom-files' to SKIP_ROOT_RESOLUTION set
- Restore backup_custom_files step in update.md before run_update
- Restore tests/update-custom-backup.test.cjs (7 tests, all passing)

Closes #2229
Closes #1997

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-04-14 18:50:53 -04:00
committed by GitHub
parent 50f61bfd9a
commit 62b5278040
3 changed files with 403 additions and 1 deletions

View File

@@ -333,7 +333,7 @@ async function main() {
// filesystem traversal on every invocation.
const SKIP_ROOT_RESOLUTION = new Set([
'generate-slug', 'current-timestamp', 'verify-path-exists',
'verify-summary', 'template', 'frontmatter',
'verify-summary', 'template', 'frontmatter', 'detect-custom-files',
]);
if (!SKIP_ROOT_RESOLUTION.has(command)) {
cwd = findProjectRoot(cwd);
@@ -1142,6 +1142,98 @@ async function runCommand(command, args, cwd, raw, defaultValue) {
break;
}
// ─── detect-custom-files ───────────────────────────────────────────────
// Detect user-added files inside GSD-managed directories that are not
// tracked in gsd-file-manifest.json. Used by the update workflow to back
// up custom files before the installer wipes those directories.
//
// This replaces the fragile bash pattern:
// MANIFEST_FILES=$(node -e "require('$RUNTIME_DIR/...')" 2>/dev/null)
// ${filepath#$RUNTIME_DIR/} # unreliable path stripping
// which silently returns CUSTOM_COUNT=0 when $RUNTIME_DIR is unset or
// when the stripped path does not match the manifest key format (#1997).
case 'detect-custom-files': {
const configDirIdx = args.indexOf('--config-dir');
const configDir = configDirIdx !== -1 ? args[configDirIdx + 1] : null;
if (!configDir) {
error('Usage: gsd-tools detect-custom-files --config-dir <path>');
}
const resolvedConfigDir = path.resolve(configDir);
if (!fs.existsSync(resolvedConfigDir)) {
error(`Config directory not found: ${resolvedConfigDir}`);
}
const manifestPath = path.join(resolvedConfigDir, 'gsd-file-manifest.json');
if (!fs.existsSync(manifestPath)) {
// No manifest — cannot determine what is custom. Return empty list
// (same behaviour as saveLocalPatches in install.js when no manifest).
const out = { custom_files: [], custom_count: 0, manifest_found: false };
process.stdout.write(JSON.stringify(out, null, 2));
break;
}
let manifest;
try {
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
} catch {
const out = { custom_files: [], custom_count: 0, manifest_found: false, error: 'manifest parse error' };
process.stdout.write(JSON.stringify(out, null, 2));
break;
}
const manifestKeys = new Set(Object.keys(manifest.files || {}));
// GSD-managed directories to scan for user-added files.
// These are the directories the installer wipes on update.
const GSD_MANAGED_DIRS = [
'get-shit-done',
'agents',
path.join('commands', 'gsd'),
'hooks',
// OpenCode/Kilo flat command dir
'command',
// Codex/Copilot skills dir
'skills',
];
function walkDir(dir, baseDir) {
const results = [];
if (!fs.existsSync(dir)) return results;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...walkDir(fullPath, baseDir));
} else {
// Use forward slashes for cross-platform manifest key compatibility
const relPath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
results.push(relPath);
}
}
return results;
}
const customFiles = [];
for (const managedDir of GSD_MANAGED_DIRS) {
const absDir = path.join(resolvedConfigDir, managedDir);
if (!fs.existsSync(absDir)) continue;
for (const relPath of walkDir(absDir, resolvedConfigDir)) {
if (!manifestKeys.has(relPath)) {
customFiles.push(relPath);
}
}
}
const out = {
custom_files: customFiles,
custom_count: customFiles.length,
manifest_found: true,
manifest_version: manifest.version || null,
};
process.stdout.write(JSON.stringify(out, null, 2));
break;
}
// ─── GSD-2 Reverse Migration ───────────────────────────────────────────
case 'from-gsd2': {

View File

@@ -361,6 +361,88 @@ Use AskUserQuestion:
**If user cancels:** Exit.
</step>
<step name="backup_custom_files">
Before running the installer, detect and back up any user-added files inside
GSD-managed directories. These are files that exist on disk but are NOT listed
in `gsd-file-manifest.json` — i.e., files the user added themselves that the
installer does not know about and will delete during the wipe.
**Do not use bash path-stripping (`${filepath#$RUNTIME_DIR/}`) or `node -e require()`
inline** — those patterns fail when `$RUNTIME_DIR` is unset and the stripped
relative path may not match manifest key format, which causes CUSTOM_COUNT=0
even when custom files exist (bug #1997). Use `gsd-tools detect-custom-files`
instead, which resolves paths reliably with Node.js `path.relative()`.
First, resolve the config directory (`RUNTIME_DIR`) from the install scope
detected in `get_installed_version`:
```bash
# RUNTIME_DIR is the resolved config directory (e.g. ~/.claude, ~/.config/opencode)
# It should already be set from get_installed_version as GLOBAL_DIR or LOCAL_DIR.
# Use the appropriate variable based on INSTALL_SCOPE.
if [ "$INSTALL_SCOPE" = "LOCAL" ]; then
RUNTIME_DIR="$LOCAL_DIR"
elif [ "$INSTALL_SCOPE" = "GLOBAL" ]; then
RUNTIME_DIR="$GLOBAL_DIR"
else
RUNTIME_DIR=""
fi
```
If `RUNTIME_DIR` is empty or does not exist, skip this step (no config dir to
inspect).
Otherwise, resolve the path to `gsd-tools.cjs` and run:
```bash
GSD_TOOLS="$RUNTIME_DIR/get-shit-done/bin/gsd-tools.cjs"
if [ -f "$GSD_TOOLS" ] && [ -n "$RUNTIME_DIR" ]; then
CUSTOM_JSON=$(node "$GSD_TOOLS" detect-custom-files --config-dir "$RUNTIME_DIR" 2>/dev/null)
CUSTOM_COUNT=$(echo "$CUSTOM_JSON" | node -e "process.stdin.resume();let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{try{console.log(JSON.parse(d).custom_count);}catch{console.log(0);}})" 2>/dev/null || echo "0")
else
CUSTOM_COUNT=0
CUSTOM_JSON='{"custom_files":[],"custom_count":0}'
fi
```
**If `CUSTOM_COUNT` > 0:**
Back up each custom file to `$RUNTIME_DIR/gsd-user-files-backup/` before the
installer wipes the directories:
```bash
BACKUP_DIR="$RUNTIME_DIR/gsd-user-files-backup"
mkdir -p "$BACKUP_DIR"
# Parse custom_files array from CUSTOM_JSON and copy each file
node - "$RUNTIME_DIR" "$BACKUP_DIR" "$CUSTOM_JSON" <<'JSEOF'
const [,, runtimeDir, backupDir, customJson] = process.argv;
const { custom_files } = JSON.parse(customJson);
const fs = require('fs');
const path = require('path');
for (const relPath of custom_files) {
const src = path.join(runtimeDir, relPath);
const dst = path.join(backupDir, relPath);
if (fs.existsSync(src)) {
fs.mkdirSync(path.dirname(dst), { recursive: true });
fs.copyFileSync(src, dst);
console.log(' Backed up: ' + relPath);
}
}
JSEOF
```
Then inform the user:
```
⚠️ Found N custom file(s) inside GSD-managed directories.
These have been backed up to gsd-user-files-backup/ before the update.
Restore them after the update if needed.
```
**If `CUSTOM_COUNT` == 0:** No user-added files detected. Continue to install.
</step>
<step name="run_update">
Run the update using the install type detected in step 1:

View File

@@ -0,0 +1,228 @@
/**
* GSD Tools Tests — update workflow custom file backup detection (#1997)
*
* The update workflow must detect user-added files inside GSD-managed
* directories (get-shit-done/, agents/, commands/gsd/, hooks/) before the
* installer wipes those directories.
*
* This tests the `detect-custom-files` subcommand of gsd-tools.cjs, which is
* the correct fix for the bash path-stripping failure described in #1997.
*
* The bash pattern `${filepath#$RUNTIME_DIR/}` is unreliable because
* $RUNTIME_DIR may not be set and the stripped relative path may not match
* manifest key format. Moving the logic into gsd-tools.cjs eliminates the
* shell variable expansion failure entirely.
*
* Closes: #1997
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { runGsdTools, createTempDir, cleanup } = require('./helpers.cjs');
function sha256(content) {
return crypto.createHash('sha256').update(content).digest('hex');
}
/**
* Write a fake gsd-file-manifest.json into configDir with the given file entries.
*/
function writeManifest(configDir, files) {
const manifest = {
version: '1.32.0',
timestamp: new Date().toISOString(),
files: {}
};
for (const [relPath, content] of Object.entries(files)) {
const fullPath = path.join(configDir, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
manifest.files[relPath] = sha256(content);
}
fs.writeFileSync(
path.join(configDir, 'gsd-file-manifest.json'),
JSON.stringify(manifest, null, 2)
);
}
describe('detect-custom-files — update workflow backup detection (#1997)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-custom-detect-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('detects a custom file added inside get-shit-done/workflows/', () => {
writeManifest(tmpDir, {
'get-shit-done/workflows/execute-phase.md': '# Execute Phase\n',
'get-shit-done/workflows/plan-phase.md': '# Plan Phase\n',
});
// Add a custom file NOT in the manifest
const customFile = path.join(tmpDir, 'get-shit-done/workflows/my-custom-workflow.md');
fs.writeFileSync(customFile, '# My Custom Workflow\n');
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.ok(Array.isArray(json.custom_files), 'should return custom_files array');
assert.ok(json.custom_files.length > 0, 'should detect at least one custom file');
assert.ok(
json.custom_files.includes('get-shit-done/workflows/my-custom-workflow.md'),
`custom file should be listed; got: ${JSON.stringify(json.custom_files)}`
);
});
test('detects custom files added inside agents/', () => {
writeManifest(tmpDir, {
'agents/gsd-executor.md': '# GSD Executor\n',
});
// Add a user's custom agent (not prefixed with gsd-)
const customAgent = path.join(tmpDir, 'agents/my-custom-agent.md');
fs.mkdirSync(path.dirname(customAgent), { recursive: true });
fs.writeFileSync(customAgent, '# My Custom Agent\n');
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.ok(json.custom_files.includes('agents/my-custom-agent.md'),
`custom agent should be detected; got: ${JSON.stringify(json.custom_files)}`);
});
test('reports zero custom files when all files are in manifest', () => {
writeManifest(tmpDir, {
'get-shit-done/workflows/execute-phase.md': '# Execute Phase\n',
'get-shit-done/references/gates.md': '# Gates\n',
'agents/gsd-executor.md': '# Executor\n',
});
// No extra files added
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.ok(Array.isArray(json.custom_files), 'should return custom_files array');
assert.strictEqual(json.custom_files.length, 0, 'no custom files should be detected');
assert.strictEqual(json.custom_count, 0, 'custom_count should be 0');
});
test('returns custom_count equal to custom_files length', () => {
writeManifest(tmpDir, {
'get-shit-done/workflows/execute-phase.md': '# Execute Phase\n',
});
// Add two custom files
fs.writeFileSync(
path.join(tmpDir, 'get-shit-done/workflows/custom-a.md'),
'# Custom A\n'
);
fs.writeFileSync(
path.join(tmpDir, 'get-shit-done/workflows/custom-b.md'),
'# Custom B\n'
);
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.strictEqual(json.custom_count, json.custom_files.length,
'custom_count should equal custom_files.length');
assert.strictEqual(json.custom_count, 2, 'should detect exactly 2 custom files');
});
test('does not flag manifest files as custom even if content was modified', () => {
writeManifest(tmpDir, {
'get-shit-done/workflows/execute-phase.md': '# Execute Phase\nOriginal\n',
});
// Modify the content of an existing manifest file
fs.writeFileSync(
path.join(tmpDir, 'get-shit-done/workflows/execute-phase.md'),
'# Execute Phase\nModified by user\n'
);
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
// Modified manifest files are handled by saveLocalPatches (in install.js).
// detect-custom-files only finds files NOT in the manifest at all.
assert.ok(
!json.custom_files.includes('get-shit-done/workflows/execute-phase.md'),
'modified manifest files should NOT be listed as custom (that is saveLocalPatches territory)'
);
});
test('handles missing manifest gracefully — treats all GSD-dir files as custom', () => {
// No manifest. Add a file in a GSD-managed dir.
const workflowDir = path.join(tmpDir, 'get-shit-done/workflows');
fs.mkdirSync(workflowDir, { recursive: true });
fs.writeFileSync(path.join(workflowDir, 'my-workflow.md'), '# My Workflow\n');
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
// Without a manifest, we cannot determine what is custom vs GSD-owned.
// The command should return an empty list (no manifest = skip detection,
// which is safe since saveLocalPatches also does nothing without a manifest).
assert.ok(Array.isArray(json.custom_files), 'should return custom_files array');
assert.ok(typeof json.custom_count === 'number', 'should return numeric custom_count');
});
test('detects custom files inside get-shit-done/references/', () => {
writeManifest(tmpDir, {
'get-shit-done/references/gates.md': '# Gates\n',
});
const customRef = path.join(tmpDir, 'get-shit-done/references/my-domain-probes.md');
fs.writeFileSync(customRef, '# My Domain Probes\n');
const result = runGsdTools(
['detect-custom-files', '--config-dir', tmpDir],
tmpDir
);
assert.ok(result.success, `Command failed: ${result.error}`);
const json = JSON.parse(result.output);
assert.ok(
json.custom_files.includes('get-shit-done/references/my-domain-probes.md'),
`should detect custom reference; got: ${JSON.stringify(json.custom_files)}`
);
});
});