Files
msd-core/commands/gsd/reapply-patches.md
Tibsfox 88af6fdcda fix(reapply-patches): three-way merge and never-skip invariant for backed-up files
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>
2026-03-29 10:04:22 -07:00

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>