Skip to content

Commit 14e1c87

Browse files
authored
Merge pull request #20 from ickb/fork-management
Generalize ccc-dev/ into multi-repo fork management tool
2 parents 4ebfaf6 + 8b6d680 commit 14e1c87

45 files changed

Lines changed: 1610 additions & 925 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
---
2+
---
3+
4+
Generalize ccc-dev/ into multi-repo fork management tool

.pnpmfile.cjs

Lines changed: 66 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,95 @@
11
// .pnpmfile.cjs — Two jobs:
22
//
3-
// 1. Auto-replay: clone + patch CCC on first `pnpm install` (if pins exist).
3+
// 1. Auto-replay: clone + patch managed forks on first `pnpm install` (if pins exist).
44
// replay.sh handles git clone, merge replay, lockfile removal, and source
55
// patching (jq exports rewrite). It does NOT run pnpm install
6-
// internally — the root workspace install handles CCC deps alongside
6+
// internally — the root workspace install handles fork deps alongside
77
// everything else.
88
//
9-
// 2. readPackage hook: rewrite CCC deps from catalog ranges to workspace:*.
10-
// CCC packages live in pnpm-workspace.yaml, so you'd expect pnpm to link
9+
// 2. readPackage hook: rewrite fork deps from catalog ranges to workspace:*.
10+
// Fork packages live in pnpm-workspace.yaml, so you'd expect pnpm to link
1111
// them automatically. It doesn't — catalog: specifiers resolve to a semver
1212
// range (e.g. ^1.12.2) BEFORE workspace linking is considered, so pnpm
1313
// fetches from the registry even with link-workspace-packages = true.
1414
// This hook intercepts every package.json at resolution time and forces
15-
// workspace:* for any dep whose name matches a local CCC package.
16-
// When CCC is not cloned, hasCcc is false and the hook is a no-op, so
17-
// the catalog range falls through to the registry normally.
15+
// workspace:* for any dep whose name matches a local fork package.
16+
// When no forks are cloned, the hook is a no-op, so catalog ranges fall
17+
// through to the registry normally.
1818

19-
const { execSync } = require("child_process");
19+
const { execFileSync } = require("child_process");
2020
const { existsSync, readdirSync, readFileSync } = require("fs");
2121
const { join } = require("path");
2222

23-
const cccCache = join(__dirname, "ccc-dev", "ccc");
24-
const cccRefs = join(__dirname, "ccc-dev", "pins", "REFS");
23+
// Discover all *-fork/ directories with config.json
24+
const forkDirs = [];
25+
for (const entry of readdirSync(__dirname, { withFileTypes: true })) {
26+
if (!entry.isDirectory() || !entry.name.endsWith("-fork")) continue;
27+
const configPath = join(__dirname, entry.name, "config.json");
28+
if (existsSync(configPath)) {
29+
const config = JSON.parse(readFileSync(configPath, "utf8"));
30+
if (!config.cloneDir) continue;
31+
forkDirs.push({
32+
name: entry.name,
33+
dir: join(__dirname, entry.name),
34+
config,
35+
});
36+
}
37+
}
2538

26-
// 1. Auto-replay CCC pins on first pnpm install
27-
// Skip when ccc:record is running — it rebuilds pins from scratch.
39+
// 1. Auto-replay fork pins on first pnpm install
40+
// Skip when fork:record is running — it rebuilds pins from scratch.
2841
// Detect via argv since pnpmfile loads before npm_lifecycle_event is set.
29-
const isCccRecord = process.argv.some((a) => a === "ccc:record");
30-
if (!isCccRecord && !existsSync(cccCache) && existsSync(cccRefs)) {
31-
try {
32-
execSync("bash ccc-dev/replay.sh", {
33-
cwd: __dirname,
34-
stdio: ["ignore", "pipe", "pipe"],
35-
});
36-
} catch (err) {
37-
process.stderr.write("Replaying CCC pins…\n");
38-
process.stderr.write(err.stdout?.toString() ?? "");
39-
process.stderr.write(err.stderr?.toString() ?? "");
40-
throw err;
42+
const isRecord = process.argv.some((a) => a === "fork:record");
43+
if (!isRecord) {
44+
for (const fork of forkDirs) {
45+
const cloneDir = join(fork.dir, fork.config.cloneDir);
46+
const hasPins = existsSync(join(fork.dir, "pins", "manifest"));
47+
if (!existsSync(cloneDir) && hasPins) {
48+
try {
49+
execFileSync("bash", ["fork-scripts/replay.sh", fork.name], {
50+
cwd: __dirname,
51+
stdio: ["ignore", "pipe", "pipe"],
52+
});
53+
} catch (err) {
54+
process.stderr.write(`Replaying ${fork.name} pins…\n`);
55+
process.stderr.write(err.stdout?.toString() ?? "");
56+
process.stderr.write(err.stderr?.toString() ?? "");
57+
throw err;
58+
}
59+
}
4160
}
4261
}
4362

44-
// 2. Discover local CCC packages and build the override map
45-
const cccPkgs = join(cccCache, "packages");
63+
// 2. Discover local fork packages and build the override map
4664
const localOverrides = {};
47-
if (existsSync(cccPkgs)) {
48-
for (const dir of readdirSync(cccPkgs, { withFileTypes: true })) {
49-
if (!dir.isDirectory()) continue;
50-
const pkgJsonPath = join(cccPkgs, dir.name, "package.json");
51-
if (!existsSync(pkgJsonPath)) continue;
52-
const { name } = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
53-
if (name) {
54-
localOverrides[name] = "workspace:*";
65+
for (const fork of forkDirs) {
66+
const cloneDir = join(fork.dir, fork.config.cloneDir);
67+
if (!existsSync(cloneDir)) continue;
68+
const includes = fork.config.workspace?.include ?? [];
69+
const excludes = new Set(fork.config.workspace?.exclude ?? []);
70+
for (const pattern of includes) {
71+
// Simple glob: only supports trailing /* (e.g. "packages/*")
72+
const base = pattern.replace(/\/\*$/, "");
73+
const pkgsRoot = join(cloneDir, base);
74+
if (!existsSync(pkgsRoot)) continue;
75+
for (const dir of readdirSync(pkgsRoot, { withFileTypes: true })) {
76+
if (!dir.isDirectory()) continue;
77+
const relPath = `${base}/${dir.name}`;
78+
if (excludes.has(relPath)) continue;
79+
const pkgJsonPath = join(pkgsRoot, dir.name, "package.json");
80+
if (!existsSync(pkgJsonPath)) continue;
81+
const { name } = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
82+
if (name) {
83+
localOverrides[name] = "workspace:*";
84+
}
5585
}
5686
}
5787
}
5888

59-
const hasCcc = Object.keys(localOverrides).length > 0;
89+
const hasOverrides = Object.keys(localOverrides).length > 0;
6090

6191
function readPackage(pkg) {
62-
if (!hasCcc) return pkg;
92+
if (!hasOverrides) return pkg;
6393

6494
for (const field of [
6595
"dependencies",

AGENTS.md

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,32 +18,45 @@
1818
1919
## PR Workflow
2020
21-
1. **Routine Pre-PR Validation**: `pnpm check:full`, it wipes derived state and regenerates from scratch. If `ccc-dev/ccc/` has pending work, the wipe is skipped to prevent data loss — re-record or push CCC changes first for a clean validation
21+
1. **Routine Pre-PR Validation**: `pnpm check:full`, it wipes derived state and regenerates from scratch. If any fork clone has pending work, the wipe is skipped to prevent data loss — re-record or push fork changes first for a clean validation
2222
2. **Open a PR**: Run `pnpm changeset` to generate a changeset entry, then push the branch and present a clickable markdown link `[title](url)` where the URL is a GitHub compare URL (`quick_pull=1`). Base branch is `master`. Prefill "title" (concise, under 70 chars) and "body" (markdown with ## Why and ## Changes sections)
2323
3. **Fetch PR review comments**: Use the GitHub REST API via curl. Fetch all three comment types (issue comments, reviews, and inline comments). Categorize feedback by actionability (action required / informational), not by source (human / bot). Reviewers reply asynchronously — poll every minute until comments arrive
2424
25-
## CCC Local Development (ccc-dev/)
25+
## Fork Management (fork-scripts/ + *-fork/)
2626
27-
The `ccc-dev/` system uses a record/replay mechanism for deterministic builds of a local CCC fork:
27+
The `fork-scripts/` system uses a record/replay mechanism for deterministic builds of external repo forks. Each fork lives in a `<name>-fork/` directory with a `config.json` specifying upstream URL, fork URL, merge refs, and workspace config. Scripts in `fork-scripts/` are generic and accept the fork directory as their first argument.
2828
29-
- `ccc-dev/pins/` is **committed** to git (base SHAs, merge refs, conflict resolutions, local patches), regenerated by `pnpm ccc:record`
30-
- `ccc-dev/ccc/` is **not in git** — it is rebuilt from pins on `pnpm install`
31-
- The developer may have **pending work** in `ccc-dev/ccc/`. Run `pnpm ccc:status` (exit 0 = safe to wipe, exit 1 = has custom work) before any operation that would destroy it. `pnpm ccc:record`, `pnpm ccc:clean`, and `pnpm ccc:reset` already guard against this automatically
32-
- `.pnpmfile.cjs` silently rewrites all `@ckb-ccc/*` dependencies to `workspace:*` when `ccc-dev/ccc/` exists. Local CCC packages override published ones without any visible change in package.json files
33-
- `pnpm install` has a side effect: if `ccc-dev/pins/REFS` exists but `ccc-dev/ccc/` does not, it automatically runs `ccc-dev/replay.sh` to rebuild CCC from pins. This is intentional
34-
- `ccc-dev/patch.sh` rewrites CCC package exports to point at `.ts` source instead of `.d.ts`, then creates a deterministic git commit (fixed author/date) so record and replay produce the same `pins/HEAD` hash. This is why imports from `@ckb-ccc/*` resolve to TypeScript source files inside `node_modules` — it is not a bug
35-
- `ccc-dev/tsgo-filter.sh` is a bash wrapper around `tsgo` that filters out diagnostics originating from `ccc-dev/ccc/`. CCC source does not satisfy this repo's strict tsconfig (`verbatimModuleSyntax`, `noUncheckedIndexedAccess`, `noImplicitOverride`), so the wrapper suppresses those errors while still reporting errors in stack source
29+
### Per-fork directory structure
3630
37-
### Opening a CCC upstream PR
31+
Each `<name>-fork/` contains:
32+
- `config.json` — upstream URL, fork URL, refs to merge, cloneDir, workspace include/exclude
33+
- `pins/` — **committed** to git (manifest + counted resolutions + local patches), regenerated by `pnpm fork:record <name>-fork`
34+
- `pins/HEAD` — expected final SHA after full replay
35+
- `pins/manifest` — base SHA + merge refs (TSV, one per line)
36+
- `pins/res-N.resolution` — conflict resolution for merge step N (counted format: `--- path` file headers, `CONFLICT ours=N base=M theirs=K resolution=R` conflict headers followed by R resolution lines; parser is purely positional — reads counts and skips lines, never inspects content)
37+
- `pins/local-*.patch` — local development patches (applied after merges + patch.sh)
38+
- `<cloneDir>/` — **not in git** — rebuilt from pins on `pnpm install`
3839
39-
In `ccc-dev/ccc/`, branch off `origin/master` (or relevant branch), push to fork (`phroi/ccc`), open PR against `ckb-devrel/ccc`. Before pushing, run the CCC CI steps (`ccc-dev/ccc/.github/workflows/check.yaml`) with `CI=true`.
40+
### Key behaviors
4041
41-
Once the PR is open, replace the local patch with a merge ref:
42+
- The developer may have **pending work** in a fork clone. Run `pnpm fork:status <name>-fork` (exit 0 = safe to wipe, exit 1 = has custom work) before any operation that would destroy it. `fork:record`, `fork:clean`, and `fork:reset` already guard against this automatically
43+
- `.pnpmfile.cjs` scans all `*-fork/config.json` directories and silently rewrites matching dependencies to `workspace:*` when clones exist. Local fork packages override published ones without any visible change in package.json files
44+
- `pnpm install` has a side effect: if `<name>-fork/pins/manifest` exists but the clone does not, it automatically runs `fork-scripts/replay.sh` to rebuild from pins. This is intentional
45+
- `fork-scripts/patch.sh` rewrites fork package exports to point at `.ts` source instead of `.d.ts`, then creates a deterministic git commit (fixed author/date) so record and replay produce the same HEAD hash. This is why imports from fork packages resolve to TypeScript source files — it is not a bug
46+
- `fork-scripts/tsgo-filter.sh` is a bash wrapper around `tsgo` that filters out diagnostics originating from all `*-fork/` clone paths. Fork source may not satisfy this repo's strict tsconfig, so the wrapper suppresses those errors while still reporting errors in stack source
47+
- `pnpm fork:save <name>-fork [description]` captures local work as a patch in `pins/`. Patches survive re-records and replays
48+
- `pnpm fork:record` regenerates the fork workspace entries in `pnpm-workspace.yaml` (between `@generated` markers) from all `*-fork/config.json` files — manual edits to that section are overwritten on re-record
4249
43-
1. Delete the patch from `ccc-dev/pins/local/`
44-
2. Add the PR number to `ccc:record` in `package.json` — order PRs by target branch from upstream to downstream, so each group merges cleanly onto its base before the next layer begins
45-
3. Run `pnpm ccc:record`
46-
4. Run `pnpm check:full` to verify the merge ref reproduces what the local patch achieved
50+
### CCC upstream contributions
51+
52+
Work locally via `ccc-fork/` first. Only push to the fork (`phroi/ccc`) when changes are validated against the stack. Do not open PRs against `ckb-devrel/ccc` prematurely — keep changes on the fork until they are production-ready and the maintainer decides to upstream.
53+
54+
1. Develop and test in `ccc-fork/ccc/` on the `wip` branch
55+
2. When ready, use `pnpm fork:push ccc-fork` to cherry-pick commits onto a PR branch
56+
3. Push the PR branch to `phroi/ccc` for review
57+
4. Add the PR number to `refs` in `ccc-fork/config.json` — order PRs by target branch from upstream to downstream, so each group merges cleanly onto its base before the next layer begins
58+
5. Run `pnpm fork:record ccc-fork` and `pnpm check:full` to verify
59+
6. Only open an upstream PR against `ckb-devrel/ccc` when the maintainer explicitly decides to upstream
4760
4861
## Reference Repos
4962

README.md

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -54,17 +54,17 @@ graph TD;
5454
click F "https://github.com/ickb/stack/tree/master/packages/sdk" "Go to @ickb/sdk"
5555
```
5656

57-
## Develop CCC
57+
## Develop with Forks
5858

59-
When `ccc-dev/pins/REFS` is committed, `pnpm install` automatically sets up the CCC local development environment on first run (by replaying pinned merges via `ccc-dev/replay.sh`). No manual setup step is needed — just clone and install:
59+
When `<name>-fork/pins/manifest` is committed, `pnpm install` automatically sets up the local fork development environment on first run (by replaying pinned merges via `fork-scripts/replay.sh`). No manual setup step is needed — just clone and install:
6060

6161
```bash
6262
git clone git@github.com:ickb/stack.git && cd stack && pnpm install
6363
```
6464

65-
To redo the setup from scratch: `pnpm ccc:clean && pnpm install`.
65+
To redo the setup from scratch: `pnpm fork:clean-all && pnpm install`.
6666

67-
See [ccc-dev/README.md](ccc-dev/README.md) for recording new pins, developing CCC PRs, and the full workflow.
67+
See [ccc-fork/README.md](ccc-fork/README.md) for recording new pins, developing CCC PRs, and the full workflow.
6868

6969
## Reference
7070

@@ -81,15 +81,17 @@ This clones two repos into the project root (both are git-ignored and made read-
8181

8282
## Developer Scripts
8383

84-
| Command | Description |
85-
| ------------------- | ------------------------------------------------------------------------------------- |
86-
| `pnpm coworker` | Launch an interactive AI Coworker session (full autonomy, opus model). |
87-
| `pnpm coworker:ask` | One-shot AI query for scripting (sonnet model, stateless). Used by `pnpm ccc:record`. |
88-
| `pnpm ccc:status` | Check if CCC clone matches pinned state. Exit 0 = safe to wipe. |
89-
| `pnpm ccc:record` | Record CCC pins (clone, merge refs, build). Guarded against pending work. |
90-
| `pnpm ccc:clean` | Remove CCC clone, keep pins (guarded). Re-replay on next `pnpm install`. |
91-
| `pnpm ccc:reset` | Remove CCC clone and pins (guarded). Restores published CCC packages. |
92-
| `pnpm check:full` | Wipe derived state and validate from scratch. Skips wipe if CCC has pending work. |
84+
| Command | Description |
85+
| -------------------------------- | --------------------------------------------------------------------------------- |
86+
| `pnpm coworker` | Launch an interactive AI Coworker session (full autonomy, opus model). |
87+
| `pnpm coworker:ask` | One-shot AI query for scripting (sonnet model, stateless). Used by fork:record. |
88+
| `pnpm fork:status <name>-fork` | Check if fork clone matches pinned state. Exit 0 = safe to wipe. |
89+
| `pnpm fork:record <name>-fork` | Record fork pins (clone, merge refs, build). Guarded against pending work. |
90+
| `pnpm fork:save <name>-fork` | Capture local fork work as a patch in pins/ (survives re-records and replays). |
91+
| `pnpm fork:push <name>-fork` | Cherry-pick commits from wip branch onto a PR branch for pushing to the fork. |
92+
| `pnpm fork:clean <name>-fork` | Remove fork clone, keep pins (guarded). Re-replay on next `pnpm install`. |
93+
| `pnpm fork:reset <name>-fork` | Remove fork clone and pins (guarded). Restores published packages. |
94+
| `pnpm check:full` | Wipe derived state and validate from scratch. Skips wipe if forks have pending work.|
9395

9496
## Epoch Semantic Versioning
9597

apps/faucet/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
"scripts": {
3232
"test": "vitest",
3333
"test:ci": "vitest run",
34-
"build": "[ -d ../../ccc-dev/ccc ] && exec bash ../../ccc-dev/tsgo-filter.sh || tsgo",
34+
"build": "bash ../../fork-scripts/tsgo-filter.sh",
3535
"lint": "eslint ./src",
3636
"clean": "rm -fr dist",
3737
"clean:deep": "rm -fr dist node_modules",

apps/interface/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"type": "module",
1414
"scripts": {
1515
"dev": "vite",
16-
"build": "([ -d ../../ccc-dev/ccc ] && exec bash ../../ccc-dev/tsgo-filter.sh || tsgo) && vite build",
16+
"build": "bash ../../fork-scripts/tsgo-filter.sh && vite build",
1717
"preview": "vite preview",
1818
"lint": "eslint ./src",
1919
"clean": "rm -fr dist",

apps/interface/vite.config.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,26 @@ import { defineConfig } from "vite";
22
import tailwindcss from "@tailwindcss/vite";
33
import react from "@vitejs/plugin-react";
44
import basicSsl from '@vitejs/plugin-basic-ssl'
5-
import { existsSync } from "fs";
5+
import { existsSync, readdirSync, readFileSync } from "fs";
6+
import { join } from "path";
67

7-
const hasCccSource = existsSync("../../ccc-dev/ccc");
8+
// Detect if any managed fork clones are present
9+
const root = join(__dirname, "../..");
10+
const hasForkSource = (() => {
11+
try {
12+
for (const entry of readdirSync(root, { withFileTypes: true })) {
13+
if (!entry.isDirectory() || !entry.name.endsWith("-fork")) continue;
14+
const configPath = join(root, entry.name, "config.json");
15+
if (!existsSync(configPath)) continue;
16+
const { cloneDir } = JSON.parse(readFileSync(configPath, "utf8"));
17+
if (!cloneDir) continue;
18+
if (existsSync(join(root, entry.name, cloneDir))) return true;
19+
}
20+
} catch (err) {
21+
console.error("Failed to detect fork sources:", err);
22+
}
23+
return false;
24+
})();
825

926
// https://vitejs.dev/config/
1027
export default defineConfig({
@@ -14,8 +31,8 @@ export default defineConfig({
1431
plugins: [
1532
tailwindcss(),
1633
react({
17-
// CCC source uses decorators — skip babel, let esbuild handle them
18-
...(hasCccSource && { exclude: [/\/ccc-dev\/ccc\//] }),
34+
// Fork source uses decorators — skip babel, let esbuild handle them
35+
...(hasForkSource && { exclude: [/\w+-fork\/\w+\//] }),
1936
babel: {
2037
plugins: [["babel-plugin-react-compiler"]],
2138
},
@@ -24,10 +41,10 @@ export default defineConfig({
2441
],
2542
build: {
2643
rollupOptions: {
27-
// CCC source uses `export { SomeType }` instead of `export type { SomeType }`.
44+
// Fork source uses `export { SomeType }` instead of `export type { SomeType }`.
2845
// esbuild strips the type declarations but can't strip value-looking re-exports,
2946
// so rollup sees missing exports. Shimming is safe — they're never used at runtime.
30-
...(hasCccSource && { shimMissingExports: true }),
47+
...(hasForkSource && { shimMissingExports: true }),
3148
},
3249
},
3350
});

apps/sampler/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
"scripts": {
3232
"test": "vitest",
3333
"test:ci": "vitest run",
34-
"build": "[ -d ../../ccc-dev/ccc ] && exec bash ../../ccc-dev/tsgo-filter.sh || tsgo",
34+
"build": "bash ../../fork-scripts/tsgo-filter.sh",
3535
"lint": "eslint ./src",
3636
"clean": "rm -fr dist",
3737
"clean:deep": "rm -fr dist node_modules",

0 commit comments

Comments
 (0)