|
| 1 | +--- |
| 2 | +name: rebase-branch |
| 3 | +description: Rebase the current feature branch onto the fresh tip of a base branch (e.g. 2.3.x) — read both sides first, plan every conflict, and keep turbo-shadowed PHP classes and their turbo-ext C++ mirrors identical in each replayed commit |
| 4 | +argument-hint: "[base-branch]" |
| 5 | +--- |
| 6 | + |
| 7 | +# Rebasing a feature branch onto a fresh base |
| 8 | + |
| 9 | +The base branch is `$ARGUMENTS`. If that is empty, use the base of the branch's pull request |
| 10 | +(`gh pr view --json baseRefName -q .baseRefName`); if there is no pull request, ask. |
| 11 | + |
| 12 | +Work in the checkout that has the feature branch checked out. Address it by absolute path |
| 13 | +(`git -C <path>`), because the shell's working directory resets between commands. Never use `git stash`: |
| 14 | +the stash is shared by every worktree of the repository. `git rebase -i` runs here only with a |
| 15 | +scripted `GIT_SEQUENCE_EDITOR` (step 4), never with an interactive editor. |
| 16 | + |
| 17 | +Helpers in this skill's directory (the directory this SKILL.md was loaded from): |
| 18 | + |
| 19 | +- `rebase-plan.php <new-base> <old-tip>` prints a read-only report for the planning step. |
| 20 | +- `rebase-todo.php` is a `GIT_SEQUENCE_EDITOR` that marks commits `drop` or `edit`. |
| 21 | + |
| 22 | +If the feature branch is older than this skill, the helpers are not in its checkout. Extract them with |
| 23 | +`git show origin/2.3.x:.claude/skills/rebase-branch/<file> > <scratchpad>/<file>`. |
| 24 | + |
| 25 | +## 1. Pin the three points |
| 26 | + |
| 27 | +```bash |
| 28 | +git fetch origin |
| 29 | +git status --porcelain --untracked-files=no # must print nothing: commit first or ask |
| 30 | +BRANCH=$(git branch --show-current) |
| 31 | +OLD_TIP=$(git rev-parse HEAD) |
| 32 | +NEW_BASE=$(git rev-parse origin/<base>) # the remote tip, never the possibly stale local branch |
| 33 | +OLD_BASE=$(git merge-base "$NEW_BASE" "$OLD_TIP") |
| 34 | +git branch -f "backup/$BRANCH-pre-rebase" "$OLD_TIP" |
| 35 | +``` |
| 36 | + |
| 37 | +Write the three SHAs to the scratchpad. Every later step uses the SHAs, not refs that can move. If |
| 38 | +`OLD_BASE` equals `NEW_BASE`, stop, because there is nothing to rebase. |
| 39 | + |
| 40 | +- **The feature's work** is `git diff $OLD_BASE $OLD_TIP`, commit by commit |
| 41 | + `git log --reverse -p $OLD_BASE..$OLD_TIP`. |
| 42 | +- **What happened on the base meanwhile** is `git diff $OLD_BASE $NEW_BASE`, commit by commit |
| 43 | + `git log --reverse --no-merges -p $OLD_BASE..$NEW_BASE`. |
| 44 | + |
| 45 | +## 2. Read both sides, plan every conflict |
| 46 | + |
| 47 | +Run `php <skill dir>/rebase-plan.php $NEW_BASE $OLD_TIP`. It reports: |
| 48 | + |
| 49 | +- the base commits, |
| 50 | +- merge commits in the feature range, |
| 51 | +- feature commits the base already contains, |
| 52 | +- turbo bump commits, |
| 53 | +- files changed on both sides, with the feature commits that touch each one, |
| 54 | +- the conflicts that `git merge-tree` predicts for the two tips, |
| 55 | +- the turbo twin table (step 3), |
| 56 | +- names that vanished on one side but are still spelled in the other side's C++. |
| 57 | + |
| 58 | +The report tells you where to look; it does not replace reading the changes. Read: |
| 59 | + |
| 60 | +1. The feature's work: `git log --reverse --stat $OLD_BASE..$OLD_TIP`, then the diffs, starting with the |
| 61 | + files changed on both sides. |
| 62 | +2. The base's work: every base commit that touches a file changed on both sides, plus any base commit |
| 63 | + that renames, moves or changes the signature of code the feature uses. Those changes break the |
| 64 | + feature silently, without a textual conflict. |
| 65 | +3. For each file changed on both sides, put `git diff $OLD_BASE $NEW_BASE -- <file>` next to |
| 66 | + `git log --reverse -p $OLD_BASE..$OLD_TIP -- <file>`. Decide what the combined file must contain. |
| 67 | + |
| 68 | +Then write the plan to the scratchpad. Include every feature commit that will conflict or needs a mirror |
| 69 | +port, each with the file, what the base did there, and the intended resolution. A resolution keeps the |
| 70 | +intent of both sides. When the feature reshaped code that the base then changed, carry the base's |
| 71 | +change into the feature's new shape; don't just pick one side. When the base moved code that the feature |
| 72 | +edits, apply the feature's edit at the new location. Where the right resolution is unclear, ask before |
| 73 | +you start, not in the middle of the replay. |
| 74 | + |
| 75 | +Decide these before replaying (ask the user if the answer isn't obvious): |
| 76 | + |
| 77 | +- **Merge commits in the feature range**: a rebase flattens them away. Linearize only with the user's |
| 78 | + agreement. |
| 79 | +- **Commits the base already contains**: this covers patch-identical commits and adapted twins, such as |
| 80 | + a chunk extracted into its own pull request that has since landed. Drop them instead of replaying |
| 81 | + them. Replaying one conflicts, and resolving it on the branch's side silently wipes out the base's |
| 82 | + version. |
| 83 | +- **`Bump expected turbo version` commits**: drop all of them and run one `make bump-turbo` at the end. |
| 84 | + During the replay, a conflict on `EXPECTED_EXTENSION_VERSION` resolves to the base's value. |
| 85 | + |
| 86 | +## 3. Turbo mirrors |
| 87 | + |
| 88 | +Classes carrying `#[ShadowedByTurboExtension(turboClass: ..., implementation: .../turbo-ext/src/X.cpp)]` |
| 89 | +have a native C++ twin. Vendored twins live in `VENDORED_PAIRS` in |
| 90 | +`build/PHPStan/Build/TurboAttributeCollector.php` and change when their package's version in |
| 91 | +`composer.lock` changes. The PHP class and the C++ class must behave identically after every replayed |
| 92 | +commit. A mirror change goes into the same commit as the PHP change it mirrors, never into a trailing |
| 93 | +"port" commit. |
| 94 | + |
| 95 | +The report's twin table uses these classifications: |
| 96 | + |
| 97 | +- **Shadowed at OLD BASE and still on both sides**: each side already mirrored its own PHP edits. |
| 98 | + Resolve a `.cpp` conflict the same way as the matching `.php` conflict, so the merged `.cpp` mirrors |
| 99 | + the merged `.php`. If a side changed the `.php` but not the `.cpp`, check whether that change needed a |
| 100 | + port (a docblock-only change does not). |
| 101 | +- **NEW ON BASE**: the base ported a class that the feature edits. Every feature commit that changes |
| 102 | + that PHP class must make the same change in the base's `.cpp`. Mark those commits `edit`. |
| 103 | +- **NEW ON FEATURE**: the feature ports a class that the base changed in the meantime. Port the base's |
| 104 | + PHP change into the `.cpp` in the feature commit that introduces the `.cpp`, and mark that commit |
| 105 | + `edit`. Later feature commits that touch the `.cpp` then replay on top of the port. |
| 106 | +- **SHADOWED ON BOTH SIDES INDEPENDENTLY**: reconcile the two ports by hand, and ask which one survives. |
| 107 | +- **UNSHADOWED on one side**: the other side's `.cpp` edits no longer apply. Resolve a modify/delete |
| 108 | + conflict by deleting the file. |
| 109 | +- **Names spelled in C++**: native code reaches PHP by name, which git cannot see. It calls methods and |
| 110 | + reads properties through method and property sites and string literals. It also copies constants |
| 111 | + (e.g. `PhpVersionFactory::MAX_PHP_VERSION` as `PT_MS_MAX_PHP_VERSION`) and slot numbers (a |
| 112 | + `PT_*_PROP_*` copy of another class's property layout). If a twin gains or loses a property on either |
| 113 | + side, grep turbo-ext for copies of its slot numbers. The report lists removed names and changed |
| 114 | + constant values it found spelled on the other side, but it can't see a changed parameter list. |
| 115 | + `side-by-side.php`, `signature-parity.php` and the tests catch that later. |
| 116 | + |
| 117 | +The turbo machinery itself may differ between the base and the feature. Compare `turbo-ext/CLAUDE.md` |
| 118 | +and `turbo-ext/README.md` on both sides: stub shells vs real-name activation, handwritten `reg::Class` |
| 119 | +registrations vs generated `sig::` declarations, helper APIs in `reg.h`, `zv.h` and `support.h`. Write |
| 120 | +each mirror in the conventions of the commit being replayed. A port that is new on the base has to |
| 121 | +follow the feature's conventions from the feature commit that changed them onwards. The report's last |
| 122 | +section lists the shared turbo-ext files the feature changed. |
| 123 | + |
| 124 | +Never hand-merge generated files. Regenerate them in the commit being replayed: |
| 125 | + |
| 126 | +| File | Resolution | |
| 127 | +| --- | --- | |
| 128 | +| `turbo-ext/src/generated/*.h` | `php turbo-ext/bin/generate-declarations.php`, where the generator exists at that commit | |
| 129 | +| `turbo-ext/src/parser/ParserRunnerActions*.cpp`, `ParserRunnerActionsSplit.h` | `php turbo-ext/bin/generate-parser-actions.php` | |
| 130 | +| `composer.lock` | take the current (base) lock, re-apply the commit's `composer.json` change, then `composer update <the packages it changed>`, or `composer update --lock` when only the hash differs | |
| 131 | +| `phpstan-baseline.neon` | apply the replayed commit's own edit (`git show REBASE_HEAD -- phpstan-baseline.neon`) to the current file | |
| 132 | +| `src/Turbo/TurboExtensionEnabler.php` version | keep the base's value, bump at the end | |
| 133 | + |
| 134 | +For registries that both sides append to, take the union of both sides' entries. These include |
| 135 | +`$covered` / `$coveredElsewhere` in `turbo-ext/tests/smoke.php`, `VENDORED_CLASS_MAP`, and |
| 136 | +`pt_class_refs` keys. MINIT registrations and the build files' source lists no longer exist: a |
| 137 | +replayed commit that adds a `pt_register_*()` call to `main.cpp`, its declaration to `support.h` or |
| 138 | +a `.cpp` name to `config.w32` drops that hunk and defines the function with |
| 139 | +`PT_MINIT_REGISTRATION(pt_register_*)` in its own file instead. |
| 140 | + |
| 141 | +## 4. Replay |
| 142 | + |
| 143 | +```bash |
| 144 | +GIT_SEQUENCE_EDITOR="php <skill dir>/rebase-todo.php" \ |
| 145 | +REBASE_DROP="<shas to drop>" REBASE_EDIT="<shas to stop after>" \ |
| 146 | +git rebase -i --onto "$NEW_BASE" "$OLD_BASE" "$BRANCH" |
| 147 | +``` |
| 148 | + |
| 149 | +The helper aborts the rebase before anything is replayed if a listed SHA has no pick line. Git's rerere |
| 150 | +is enabled on this machine. It may reapply a resolution recorded during an earlier attempt, so review |
| 151 | +those hunks (`git rerere diff`) like any other resolution. |
| 152 | + |
| 153 | +At each stop: |
| 154 | + |
| 155 | +- **Conflict.** Run `git diff --name-only --diff-filter=U` to list the conflicted files and |
| 156 | + `git show REBASE_HEAD` to see the commit being replayed. Resolve according to the plan, and make this |
| 157 | + commit's planned mirror edits now as well. A conflicting commit marked `edit` is committed by |
| 158 | + `--continue` without stopping again, so the edit stop never comes. Stage files by name, never a |
| 159 | + directory. Delete any `*.orig` or `*.rej` files. Then run `GIT_EDITOR=true git rebase --continue`. |
| 160 | +- **Planned `edit` stop.** Make the mirror edit, `git add <files>`, `git commit --amend --no-edit`, then |
| 161 | + `git rebase --continue`. |
| 162 | +- **After touching C++**: rebuild turbo-ext incrementally (`make -C turbo-ext`) and fix compile errors |
| 163 | + in the same commit. After touching PHP, run `php -l` on the files. |
| 164 | +- **A commit that unexpectedly became empty** means a resolution dropped its content. Investigate it; |
| 165 | + don't `--skip` it. Skip only commits the plan already marked as contained in the base. |
| 166 | +- If a stop reveals something the plan didn't foresee and the right resolution is unclear, stop and ask |
| 167 | + instead of guessing. |
| 168 | + |
| 169 | +## 5. Check nothing was lost |
| 170 | + |
| 171 | +```bash |
| 172 | +git range-diff "$OLD_BASE..$OLD_TIP" "$NEW_BASE..HEAD" |
| 173 | +``` |
| 174 | + |
| 175 | +Review every commit that range-diff reports as changed. Each difference must be an intended conflict |
| 176 | +resolution or mirror port. |
| 177 | + |
| 178 | +```bash |
| 179 | +# files the rebase changed that the base never touched: exactly the adaptations and mirror ports |
| 180 | +comm -23 <(git diff --name-only "$OLD_TIP" HEAD | sort) <(git diff --name-only "$OLD_BASE" "$NEW_BASE" | sort) |
| 181 | +# base changes to files the feature never touched must arrive unchanged: expect no output other than |
| 182 | +# the planned mirror ports (xargs runs nothing on empty input - a bare `git diff --` would diff everything) |
| 183 | +comm -23 <(git diff --name-only "$OLD_BASE" "$NEW_BASE" | sort) <(git diff --name-only "$OLD_BASE" "$OLD_TIP" | sort) | xargs git diff --stat "$NEW_BASE" HEAD -- |
| 184 | +``` |
| 185 | + |
| 186 | +If `git diff --quiet "$NEW_BASE" HEAD -- turbo-ext/src` reports a difference, run `make bump-turbo`. It |
| 187 | +creates the single bump commit, or amends the unpushed one. |
| 188 | + |
| 189 | +## 6. Verify |
| 190 | + |
| 191 | +1. Sync the dependencies, because the base may have changed the locks: run `composer install`, |
| 192 | + `composer install -d tests` and `composer dump-autoload`. The dump regenerates `vendor/attributes.php` |
| 193 | + and the turbo manifest. `composer install --dry-run` must then report nothing to install. |
| 194 | +2. Run `make phpstan` and `make tests`. |
| 195 | +3. If any turbo twin, turbo-ext file or php-parser version differs from `NEW_BASE`, run the verify list |
| 196 | + in `turbo-ext/CLAUDE.md` at HEAD: |
| 197 | + - the strict build, |
| 198 | + - `turbo-ext/tests/smoke.php` (ALL OK), |
| 199 | + - `turbo-ext/bin/side-by-side.php`, |
| 200 | + - `turbo-ext/tests/signature-parity.php`, |
| 201 | + - `turbo-ext/tests/walk-trace.php` where it exists, |
| 202 | + - `turbo-ext/tests/parser-corpus.php` when `turbo-ext/src/parser/` or php-parser changed, |
| 203 | + - `make lint-turbo` when C++ changed, |
| 204 | + - `make tests` with the extension loaded, |
| 205 | + - `--error-format=raw` analysis output that is byte-identical with the extension loaded and not |
| 206 | + loaded. |
| 207 | +4. Make sure the build you test is the one that's loaded. The global ini may load a `.so` from another |
| 208 | + worktree. Load this checkout's build through a scratch `PHP_INI_SCAN_DIR` (or |
| 209 | + `php -n -d extension=...`), and confirm that `phpversion('phpstan_turbo')` equals |
| 210 | + `EXPECTED_EXTENSION_VERSION`. On a mismatch, turbo silently stays off. |
| 211 | + |
| 212 | +Fix a failure in the commit that introduced it: `git commit --fixup=<sha>`, then |
| 213 | +`GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash "$NEW_BASE"`, then `make bump-turbo` again. Don't add a |
| 214 | +trailing fix commit. |
| 215 | + |
| 216 | +## 7. Report and push |
| 217 | + |
| 218 | +Tell the user: |
| 219 | + |
| 220 | +- the old base and the new base, |
| 221 | +- which commits were dropped, and why, |
| 222 | +- each conflict, with its file and how it was resolved, |
| 223 | +- each mirror port, with its class and commit, |
| 224 | +- the verification results, |
| 225 | +- the backup branch `backup/$BRANCH-pre-rebase`. |
| 226 | + |
| 227 | +Push only when asked: `git push --force-with-lease="$BRANCH:$OLD_TIP" origin "$BRANCH"`. Delete the |
| 228 | +backup branch only after the user confirms the result. |
0 commit comments