The reapply-patches workflow used a two-way comparison (user's backup vs new version) which couldn't distinguish user customizations from version drift. This caused 10/10 files to be misclassified as "no custom content" in real-world usage, silently discarding user modifications. Changes: - Rewrite workflow with three-way merge strategy (pristine baseline vs user-modified backup vs newly installed version) - Add critical invariant: files in gsd-local-patches/ must NEVER be classified as "no custom content" — they were backed up because the installer's hash check detected modifications - Add git-aware detection path using commit history when config dir is a git repo - Add pristine_hashes to backup-meta.json so the reapply workflow can verify reconstructed baseline files - Add from_manifest_timestamp to backup-meta.json for version tracking - Conservative default: flag as CONFLICT when uncertain, not SKIP Closes #1469 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
194 lines
7.9 KiB
Markdown
194 lines
7.9 KiB
Markdown
---
|
|
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 three-way comparison (pristine baseline, user-modified backup, newly installed version) to reliably distinguish user customizations from version drift.
|
|
|
|
**Critical invariant:** Every file in `gsd-local-patches/` was backed up because the installer's hash comparison detected it was modified. The workflow must NEVER conclude "no custom content" for any backed-up file — that is a logical contradiction. When in doubt, classify as CONFLICT requiring user review, not SKIP.
|
|
</purpose>
|
|
|
|
<process>
|
|
|
|
## Step 1: Detect backed-up patches
|
|
|
|
Check for local patches directory:
|
|
|
|
```bash
|
|
# Global install — detect runtime config directory
|
|
if [ -d "$HOME/.config/opencode/gsd-local-patches" ]; then
|
|
PATCHES_DIR="$HOME/.config/opencode/gsd-local-patches"
|
|
elif [ -d "$HOME/.opencode/gsd-local-patches" ]; then
|
|
PATCHES_DIR="$HOME/.opencode/gsd-local-patches"
|
|
elif [ -d "$HOME/.gemini/gsd-local-patches" ]; then
|
|
PATCHES_DIR="$HOME/.gemini/gsd-local-patches"
|
|
else
|
|
PATCHES_DIR="$HOME/.claude/gsd-local-patches"
|
|
fi
|
|
# Local install fallback — check all runtime directories
|
|
if [ ! -d "$PATCHES_DIR" ]; then
|
|
for dir in .config/opencode .opencode .gemini .claude; do
|
|
if [ -d "./$dir/gsd-local-patches" ]; then
|
|
PATCHES_DIR="./$dir/gsd-local-patches"
|
|
break
|
|
fi
|
|
done
|
|
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: Determine baseline for three-way comparison
|
|
|
|
The quality of the merge depends on having a **pristine baseline** — the original unmodified version of each file from the pre-update GSD release. This enables three-way comparison:
|
|
- **Pristine baseline** (original GSD file before any user edits)
|
|
- **User's version** (backed up in `gsd-local-patches/`)
|
|
- **New version** (freshly installed after update)
|
|
|
|
Check for baseline sources in priority order:
|
|
|
|
### Option A: Git history (most reliable)
|
|
If the config directory is a git repository:
|
|
```bash
|
|
CONFIG_DIR=$(dirname "$PATCHES_DIR")
|
|
if git -C "$CONFIG_DIR" rev-parse --git-dir >/dev/null 2>&1; then
|
|
HAS_GIT=true
|
|
fi
|
|
```
|
|
When `HAS_GIT=true`, use `git log` to find the commit where GSD was originally installed (before user edits). For each file, the pristine baseline can be extracted with:
|
|
```bash
|
|
git -C "$CONFIG_DIR" log --diff-filter=A --format="%H" -- "{file_path}"
|
|
```
|
|
This gives the commit that first added the file (the install commit). Extract the pristine version:
|
|
```bash
|
|
git -C "$CONFIG_DIR" show {install_commit}:{file_path}
|
|
```
|
|
|
|
### Option B: Pristine snapshot directory
|
|
Check if a `gsd-pristine/` directory exists alongside `gsd-local-patches/`:
|
|
```bash
|
|
PRISTINE_DIR="$CONFIG_DIR/gsd-pristine"
|
|
```
|
|
If it exists, the installer saved pristine copies at install time. Use these as the baseline.
|
|
|
|
### Option C: No baseline available (two-way fallback)
|
|
If neither git history nor pristine snapshots are available, fall back to two-way comparison — but with **strengthened heuristics** (see Step 3).
|
|
|
|
## Step 3: Show patch summary
|
|
|
|
```
|
|
## Local Patches to Reapply
|
|
|
|
**Backed up from:** v{from_version}
|
|
**Current version:** {read VERSION file}
|
|
**Files modified:** {count}
|
|
**Merge strategy:** {three-way (git) | three-way (pristine) | two-way (enhanced)}
|
|
|
|
| # | File | Status |
|
|
|---|------|--------|
|
|
| 1 | {file_path} | Pending |
|
|
| 2 | {file_path} | Pending |
|
|
```
|
|
|
|
## Step 4: 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. **If available, read the pristine baseline** (from git history or `gsd-pristine/`)
|
|
|
|
### Three-way merge (when baseline is available)
|
|
|
|
Compare the three versions to isolate changes:
|
|
- **User changes** = diff(pristine → user's version) — these are the customizations to preserve
|
|
- **Upstream changes** = diff(pristine → new version) — these are version updates to accept
|
|
|
|
**Merge rules:**
|
|
- Sections changed only by user → apply user's version
|
|
- Sections changed only by upstream → accept upstream version
|
|
- Sections changed by both → flag as CONFLICT, show both, ask user
|
|
- Sections unchanged by either → use new version (identical to all three)
|
|
|
|
### Two-way merge (fallback when no baseline)
|
|
|
|
When no pristine baseline is available, use these **strengthened heuristics**:
|
|
|
|
**CRITICAL RULE: Every file in this backup directory was explicitly detected as modified by the installer's SHA-256 hash comparison. "No custom content" is never a valid conclusion.**
|
|
|
|
For each file:
|
|
a. Read both versions completely
|
|
b. Identify ALL differences, then classify each as:
|
|
- **Mechanical drift** — path substitutions (e.g. `/Users/xxx/.claude/` → `$HOME/.claude/`), variable additions (`${GSD_WS}`, `${AGENT_SKILLS_*}`), error handling additions (`|| true`)
|
|
- **User customization** — added steps/sections, removed sections, reordered content, changed behavior, added frontmatter fields, modified instructions
|
|
|
|
c. **If ANY differences remain after filtering out mechanical drift → those are user customizations. Merge them.**
|
|
d. **If ALL differences appear to be mechanical drift → still flag as CONFLICT.** The installer's hash check already proved this file was modified. Ask the user: "This file appears to only have path/variable differences. Were there intentional customizations?" Do NOT silently skip.
|
|
|
|
### Git-enhanced two-way merge
|
|
|
|
When the config directory is a git repo but the pristine install commit can't be found, use commit history to identify user changes:
|
|
```bash
|
|
# Find non-update commits that touched this file
|
|
git -C "$CONFIG_DIR" log --oneline --no-merges -- "{file_path}" | grep -v "gsd:update\|GSD update\|gsd-install"
|
|
```
|
|
Each matching commit represents an intentional user modification. Use the commit messages and diffs to understand what was changed and why.
|
|
|
|
4. **Write merged result** to the installed location
|
|
5. **Report status per file:**
|
|
- `Merged` — user modifications applied cleanly (show summary of what was preserved)
|
|
- `Conflict` — user reviewed and chose resolution
|
|
- `Incorporated` — user's modification was already adopted upstream (only valid when pristine baseline confirms this)
|
|
|
|
**Never report `Skipped — no custom content`.** If a file is in the backup, it has custom content.
|
|
|
|
## Step 5: 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 6: Cleanup option
|
|
|
|
Ask user:
|
|
- "Keep patch backups for reference?" → preserve `gsd-local-patches/`
|
|
- "Clean up patch backups?" → remove `gsd-local-patches/` directory
|
|
|
|
## Step 7: Report
|
|
|
|
```
|
|
## Patches Reapplied
|
|
|
|
| # | File | Result | User Changes Preserved |
|
|
|---|------|--------|----------------------|
|
|
| 1 | {file_path} | Merged | Added step X, modified section Y |
|
|
| 2 | {file_path} | Incorporated | Already in upstream v{version} |
|
|
| 3 | {file_path} | Conflict resolved | User chose: keep custom section |
|
|
|
|
{count} file(s) updated. Your local modifications are active again.
|
|
```
|
|
|
|
</process>
|
|
|
|
<success_criteria>
|
|
- [ ] All backed-up patches processed — zero files left unhandled
|
|
- [ ] No file classified as "no custom content" or "SKIP" — every backed-up file is definitionally modified
|
|
- [ ] Three-way merge used when pristine baseline available (git history or gsd-pristine/)
|
|
- [ ] User modifications identified and merged into new version
|
|
- [ ] Conflicts surfaced to user with both versions shown
|
|
- [ ] Status reported for each file with summary of what was preserved
|
|
</success_criteria>
|