Skip to content

Commit e4abd28

Browse files
Add uv lock to release script and subtree vendoring doc
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 04743e9 commit e4abd28

2 files changed

Lines changed: 266 additions & 0 deletions

File tree

docs/subtree.md

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
# Vendoring Reactivated as a Subtree
2+
3+
Instead of installing reactivated from PyPI and npm, you can vendor the framework
4+
source directly into your project. This enables live editing of the framework during
5+
development: fix a bug or add a feature in the vendored copy, use it immediately in
6+
your app, and push the change upstream as a PR when it's ready.
7+
8+
## Directory layout
9+
10+
Vendor the repo at `upstream/reactivated/` in your project root:
11+
12+
```
13+
upstream/reactivated/ # vendored copy of github.com/silviogutierrez/reactivated
14+
├── reactivated/ # Python package (Django app)
15+
├── packages/reactivated/ # TypeScript package (npm)
16+
├── scripts/generate_types.py # Generates TS types from Python
17+
├── pyproject.toml # Python package metadata
18+
└── ... # tests, sample, website, etc.
19+
```
20+
21+
### Why `upstream/reactivated` and not `reactivated`
22+
23+
The directory cannot be named `reactivated/` at the project root because Python's
24+
import system would find it as a namespace package (it has no `__init__.py` at the top
25+
level) before the editable install's finder can intercept. Nesting it under `upstream/`
26+
eliminates the collision between the directory name and the Python package name.
27+
28+
## Wiring it up
29+
30+
### Python — `pyproject.toml`
31+
32+
Point uv at the local source with an editable install:
33+
34+
```toml
35+
[project]
36+
dependencies = [
37+
"reactivated",
38+
]
39+
40+
[tool.uv.sources]
41+
reactivated = { path = "./upstream/reactivated", editable = true }
42+
```
43+
44+
### Node — `package.json`
45+
46+
```jsonc
47+
"dependencies": {
48+
"reactivated": "file:upstream/reactivated/packages/reactivated"
49+
}
50+
```
51+
52+
## Build steps
53+
54+
After cloning or pulling changes to the subtree:
55+
56+
```bash
57+
uv sync # Install Python deps (editable reactivated)
58+
npm install # Install Node deps (symlinks to subtree)
59+
60+
# Generate types (writes packages/reactivated/src/generated.tsx):
61+
cd upstream/reactivated
62+
PATH=$(pwd)/../../node_modules/.bin:$PATH python scripts/generate_types.py
63+
cd ../..
64+
65+
# Initial build (required once before dev server works):
66+
npx tsc -p upstream/reactivated/packages/reactivated/tsconfig.json
67+
```
68+
69+
## Live editing
70+
71+
Both Python and Node changes are picked up without reinstalling:
72+
73+
- **Python**: The editable install means changes to
74+
`upstream/reactivated/reactivated/*.py` are immediately visible — no `uv sync`
75+
needed.
76+
- **Node**: npm creates a symlink (`node_modules/reactivated` ->
77+
`upstream/reactivated/packages/reactivated`). Rebuild the TypeScript after editing
78+
and changes are visible through the symlink:
79+
80+
```bash
81+
# One-off build:
82+
npx tsc -p upstream/reactivated/packages/reactivated/tsconfig.json
83+
84+
# Watch mode (auto-rebuilds on save):
85+
npx tsc -p upstream/reactivated/packages/reactivated/tsconfig.json --watch
86+
```
87+
88+
**Note for CI and Docker**: `npm ci` copies instead of symlinking, so re-run
89+
`npm install ./upstream/reactivated/packages/reactivated` after compiling the
90+
TypeScript to update the copy with the freshly built `dist/`.
91+
92+
## Why not `git subtree` or `git submodule`?
93+
94+
- **`git subtree`** is fundamentally incompatible with squash merges. `git subtree pull`
95+
creates two-parent merge commits that link upstream history to the parent repo.
96+
`git subtree split/push` walks the commit graph looking for those merge commits to
97+
know what's already been synced. Squash merging (GitHub's default for PRs) flattens
98+
those into single-parent commits, severing the link. The next `split` either
99+
reprocesses the entire history (producing duplicate commits upstream) or fails
100+
outright with "Can't squash-merge: '<dir>' was never added". On top of that, GitHub's
101+
squash merge rewrites commit messages with CRLF line endings, which breaks the
102+
`git-subtree-dir:` / `git-subtree-split:` markers that `git subtree` (a bash script)
103+
greps for. If either repo uses squash merges, `git subtree` is broken by design.
104+
- **`git submodule`** adds deployment complexity (submodule init/update in CI, Docker,
105+
every clone) and makes the subtree feel second-class. Vendoring keeps everything in
106+
one repo with one `git clone`.
107+
108+
Instead, use **patch-based sync**: generate diffs between known commits and apply them
109+
with `git apply --3way`, which preserves local additions on both sides and uses git's
110+
merge machinery to handle conflicts.
111+
112+
## Sync version tracking
113+
114+
The subtree's `pyproject.toml` contains the `version` field (e.g. `0.50.1`). This is
115+
the **upstream version the subtree is synced to**. Your project may have local
116+
additions on top, but this version tells you the upstream baseline.
117+
118+
To find what's new upstream since the last sync:
119+
120+
```bash
121+
REACTIVATED=/path/to/reactivated/checkout
122+
SYNCED_VERSION=$(grep '^version' upstream/reactivated/pyproject.toml | sed 's/.*"\(.*\)"/\1/')
123+
124+
cd $REACTIVATED && git checkout main && git pull
125+
git log --oneline v$SYNCED_VERSION..HEAD
126+
```
127+
128+
## Pushing changes upstream (creating a PR)
129+
130+
When you've accumulated changes in `upstream/reactivated/` and want to contribute them
131+
back:
132+
133+
1. **Find the baseline commit** — the last commit in your project that synced with
134+
upstream:
135+
136+
```bash
137+
git log --oneline -- upstream/reactivated/ | head -20
138+
```
139+
140+
2. **Generate a patch** from your subtree changes, stripping the
141+
`upstream/reactivated/` prefix so paths match this repo's layout:
142+
143+
```bash
144+
git diff <baseline>..HEAD -- upstream/reactivated/ \
145+
| sed -E \
146+
-e 's|^diff --git a/upstream/reactivated/(.+) b/upstream/reactivated/(.+)|diff --git a/\1 b/\2|' \
147+
-e 's|^--- a/upstream/reactivated/|--- a/|' \
148+
-e 's|^\+\+\+ b/upstream/reactivated/|+++ b/|' \
149+
-e 's|^rename from upstream/reactivated/|rename from |' \
150+
-e 's|^rename to upstream/reactivated/|rename to |' \
151+
> /tmp/reactivated-changes.patch
152+
```
153+
154+
3. **Create a branch on a reactivated checkout** from `main`, apply the patch:
155+
156+
```bash
157+
cd /path/to/reactivated/checkout
158+
git checkout main && git pull
159+
git checkout -b downstream/my-changes main
160+
git apply --3way /tmp/reactivated-changes.patch
161+
```
162+
163+
`--3way` uses git's merge machinery, so conflicts are resolvable with standard
164+
tools instead of failing outright.
165+
166+
4. **Rebuild lock files** if `pyproject.toml` or
167+
`packages/reactivated/package.json` changed:
168+
169+
```bash
170+
uv lock
171+
npm install --package-lock-only
172+
```
173+
174+
5. **Commit, push, create a PR** against `silviogutierrez/reactivated`.
175+
176+
6. After the PR merges, pull the squashed result back down using the patch flow below.
177+
This picks up any fixes made during review.
178+
179+
## Pulling upstream changes
180+
181+
When this repo has new changes you want in your project (whether from your own merged
182+
PRs or independent upstream work):
183+
184+
```bash
185+
REACTIVATED=/path/to/reactivated/checkout
186+
SYNCED_VERSION=$(grep '^version' upstream/reactivated/pyproject.toml | sed 's/.*"\(.*\)"/\1/')
187+
188+
# Update the reactivated checkout
189+
cd $REACTIVATED && git checkout main && git pull
190+
191+
# Check what's new
192+
git log --oneline v$SYNCED_VERSION..HEAD
193+
194+
# Generate a patch of upstream changes since the last sync
195+
git diff v$SYNCED_VERSION..HEAD > /tmp/reactivated-upstream.patch
196+
197+
# Add the upstream/reactivated/ prefix so paths match your project's layout
198+
sed -E \
199+
-e 's|^diff --git a/(.+) b/(.+)|diff --git a/upstream/reactivated/\1 b/upstream/reactivated/\2|' \
200+
-e 's|^--- a/|--- a/upstream/reactivated/|' \
201+
-e 's|^\+\+\+ b/|+++ b/upstream/reactivated/|' \
202+
-e 's|^rename from (.+)|rename from upstream/reactivated/\1|' \
203+
-e 's|^rename to (.+)|rename to upstream/reactivated/\1|' \
204+
/tmp/reactivated-upstream.patch > /tmp/reactivated-prefixed.patch
205+
206+
# Apply with 3-way merge (preserves your local additions, conflicts are resolvable)
207+
cd /path/to/your/project
208+
git apply --3way /tmp/reactivated-prefixed.patch
209+
210+
# Regen lock files
211+
uv sync
212+
npm install --package-lock-only
213+
```
214+
215+
If there are conflicts, `--3way` leaves standard conflict markers that you resolve
216+
normally. The common cause is a file that both sides modified (e.g. both added code to
217+
the end of the same test file). Resolve by keeping both sides.
218+
219+
### Why not rsync?
220+
221+
`rsync --delete` overwrites the entire subtree, destroying any local additions
222+
(utilities, tests, etc.) that haven't been pushed upstream yet. It also picks up
223+
untracked files from the upstream checkout (build artifacts, editor config) that
224+
shouldn't be vendored. The patch approach only applies what upstream actually changed
225+
in git.
226+
227+
## Lint exclusions
228+
229+
The vendored source has its own lint configs. To avoid conflicts with your project's
230+
linters, exclude `upstream/reactivated/` from them:
231+
232+
- **ruff**: `exclude = ["upstream/reactivated/"]` in `pyproject.toml`
233+
- **mypy**: add `upstream/reactivated/` to `exclude` in `mypy.ini`, plus
234+
`follow_imports = silent` for `reactivated` and `reactivated.*` modules (suppresses
235+
errors within reactivated source while still type-checking your usage of it)
236+
- **prettier**: add `upstream/reactivated/packages/reactivated/src/generated.tsx` to
237+
`.gitignore` (prettier reads `.gitignore` patterns by default)
238+
239+
## Running reactivated's test suite
240+
241+
The subtree carries reactivated's own tests and Nix environment. To run them, enter
242+
the subtree and use its own tooling — completely independent from your project's
243+
checks:
244+
245+
```bash
246+
cd upstream/reactivated
247+
nix-shell --command "scripts/test.sh"
248+
```
249+
250+
Consider wiring this up as a dedicated CI job so subtree changes are validated against
251+
reactivated's own suite before you push them upstream.
252+
253+
## Ejecting back to published packages
254+
255+
To switch back to PyPI and npm, no source changes are needed — imports work
256+
identically with the published packages:
257+
258+
1. In `pyproject.toml`: pin `"reactivated>=LATEST_VERSION"` and delete the
259+
`[tool.uv.sources]` override, then `uv sync`.
260+
2. In `package.json`: change `"file:upstream/reactivated/packages/reactivated"` to
261+
`"^LATEST_VERSION"`, then `npm install`.
262+
3. Remove any subtree-specific build steps from CI and Docker (type generation, tsc,
263+
re-install of the built package).
264+
4. Remove the lint exclusions.
265+
5. `rm -rf upstream/reactivated/`

scripts/release.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ if [ "$VERSIONING" != "snapshot" ]; then
7878
# Need to update the monorepo package-lock.json
7979
npm install
8080
sed -i "s/^version = .*/version = \"$NEW_VERSION\"/" pyproject.toml
81+
uv lock
8182
git commit -am "v${NEW_VERSION}"
8283
git tag "v${NEW_VERSION}"
8384

0 commit comments

Comments
 (0)