diff --git a/.changeset/config.json b/.changeset/config.json index d34de4d36..9cf13acd8 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,5 +1,5 @@ { - "$schema": "https://unpkg.com/@changesets/config@2.1.1/schema.json", + "$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, "fixed": [], @@ -7,5 +7,8 @@ "access": "restricted", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": ["web", "@example/*"] + "ignore": ["web", "@example/*"], + "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { + "onlyUpdatePeerDependentsWhenOutOfRange": true + } } diff --git a/.changeset/gentle-pumas-eat.md b/.changeset/gentle-pumas-eat.md index d93769d8b..71ce04232 100644 --- a/.changeset/gentle-pumas-eat.md +++ b/.changeset/gentle-pumas-eat.md @@ -6,7 +6,7 @@ "@evolu/react": major "@evolu/vue": major "@evolu/web": major -"@evolu/relay": major +"relay": major --- Updated minimum Node.js version from 22 to 24 (current LTS) diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 000000000..91cf63b83 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,25 @@ +{ + "mode": "pre", + "tag": "next", + "initialVersions": { + "relay": "2.0.8", + "web": "2.0.0", + "@example/angular-vite-pwa": "0.0.0", + "@example/react-electron": "0.0.0", + "@example/react-expo": "1.0.0", + "@example/react-nextjs": "0.1.0", + "@example/react-vite-pwa": "0.0.0", + "@example/svelte-vite-pwa": "0.0.0", + "@example/vue-vite-pwa": "0.0.0", + "@evolu/common": "7.4.1", + "@evolu/nodejs": "2.4.0", + "@evolu/react": "10.4.0", + "@evolu/react-native": "14.3.0", + "@evolu/react-web": "2.4.0", + "@evolu/svelte": "2.4.0", + "@evolu/typescript-config": "0.0.2", + "@evolu/vue": "1.4.0", + "@evolu/web": "2.4.0" + }, + "changesets": [] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d1b202949..d9402e036 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,10 +24,13 @@ bun run verify ## Upstream Sync Rules -- keep compatibility with `upstream/common-v8` unless an explicit fork decision exists +- keep compatibility with `upstream/main` unless an explicit fork decision exists - avoid dependency downgrades during sync work - keep fork-specific behavior isolated and documented -- always run final gate before sync PR: `bun verify` +- always run final sync guards before sync PR: + - `bun run sync:guard:upstream:strict` + - `bun run sync:guard:common-v8:strict` (deprecated alias, temporary) +- always run final gate before sync PR: `bun run verify:fast` ## Pull Requests diff --git a/UPSTREAM_DIFF.md b/UPSTREAM_DIFF.md index e3c2555af..3b34ce8f1 100644 --- a/UPSTREAM_DIFF.md +++ b/UPSTREAM_DIFF.md @@ -1,35 +1,59 @@ -# Evolu Plan B vs Upstream Evolu (High-Level) +# Evolu Plan B vs Upstream Evolu -This document summarizes the main differences between `SQLoot/evolu-plan-b` and upstream `evoluhq/evolu`. +This document tracks the current upstream sync baseline and the remaining fork +delta for `SQLoot/evolu-plan-b`. -Scope: high-level product and engineering deltas, not a full commit-by-commit changelog. +## Canonical References -## What Is Different +- upstream baseline: `upstream/main@e201eeb5` +- common-v8 merge anchor in upstream: `5aed29ff` +- current fork main after this sync wave: `origin/main` plus post-wave commits from + `sync/upstream-main-2026-04-03` -| Area | Plan B Delta | Why | -| --- | --- | --- | -| Tooling baseline | Bun-first monorepo workflows (`bun install`, `bun run ...`) with Turborepo orchestration. | Faster local workflows and a single runtime/tooling story. | -| Formatting and linting | Biome-first formatting/linting policy. | Reduce tooling complexity and keep style/lint fast and consistent. | -| Upstream sync process | Added sync guard tooling and explicit compatibility tracking for `common-v8` sync waves. | Keep upstream parity while avoiding accidental regressions in fork-specific work. | -| Coverage governance | Added file-level coverage gates for critical local-first paths (`Sync`, `Db`, `Worker`, `DbWorker`, etc.). | Enforce reliability on highest-risk runtime paths before merges. | -| Bun runtime adapter | Added Bun-specific worker/db adapter package (`@evolu/bun`, currently private). | Native Bun runtime support and experimentation without changing upstream APIs. | -| Test expansion | Extra tests for sync/worker/sqlite/refactor edge cases, including runtime adapter races (`DbWorker initPromise` cleanup, Relay WS lifecycle/broadcast flows). | Protect against regressions during aggressive sync and refactor work. | +## Post-Merge Upstream Commits Synced In This Wave -## What Is Intentionally the Same +- `a3cf8bf3` Rename `@evolu/relay` package to `relay` +- `9143c9f4` Bump changesets schema; remove assemble patch +- `aa5cbbe8` Update `bun.lock` +- `e201eeb5` Create `.changeset/pre.json` -| Area | Compatibility Target | -| --- | --- | -| Public local-first API | Keep API compatibility with upstream where possible. | -| Protocol and schema direction | Follow upstream `common-v8` refactor direction and naming. | -| Core behavior | Preserve upstream semantics unless explicitly documented as fork-only behavior. | +## Current Audit Summary -## What Is Extra in Plan B +### Root / Tooling -- Integration coverage dashboards/gates in fork workflow. -- Bun-focused adapter experiments and tests. -- SQLoot-specific maintenance/docs structure for sync operations. +- `same`: changesets schema/pre mode now match upstream post-merge baseline. +- `merge-both`: relay naming follows upstream `relay`, while Bun-first scripts, + coverage gates and fork-specific maintenance scripts remain intact. +- `fork-intentional`: sync guard is generalized to `upstream/main`, but the + legacy `common-v8` command stays as a deprecated alias for one wave. -## Non-Goals +### Runtime / API Parity -- This fork is not intended to fragment protocol behavior from upstream. -- This file is not a replacement for release notes. +- No post-merge upstream code delta was found in: + `packages/common`, `packages/nodejs`, `packages/web`, `packages/react`, + `packages/react-web`, `packages/react-native`, `packages/vue`, `packages/svelte`. +- Result for this wave: no additional runtime/API cherry-picks are required + before rebasing SQLoot compat metadata to the new upstream baseline. + +### Remaining Fork Delta + +- `fork-intentional` + - Bun-first monorepo workflow and dependency policy. + - Extra coverage gates and compat tree-shaking checks. + - Bun-specific adapter/runtime experimentation and related tests. + - SQLoot-facing maintenance/docs structure. +- `fork-suspect` + - None identified by this post-merge sync sweep in compat-relevant package + paths. +- `deferred` + - Broader historical fork-vs-upstream drift outside this post-merge wave is + still handled by targeted sync work, not by this summary file. + +## Maintainer Rules + +- Treat `upstream/main` as the canonical semantic baseline. +- Treat `evolu-plan-b/main` as the operational baseline for downstream compat + consumers. +- If a compat-relevant package diff is not explained by upstream history or an + explicit fork decision, classify it as `fork-suspect` and fix it in this repo + before propagating it downstream. diff --git a/apps/relay/CHANGELOG.md b/apps/relay/CHANGELOG.md index abd76912a..742b73d49 100644 --- a/apps/relay/CHANGELOG.md +++ b/apps/relay/CHANGELOG.md @@ -1,4 +1,4 @@ -# @evolu/relay +# relay ## 2.0.8 diff --git a/apps/relay/Dockerfile b/apps/relay/Dockerfile index a693459e6..a1e9cfeb7 100644 --- a/apps/relay/Dockerfile +++ b/apps/relay/Dockerfile @@ -6,8 +6,8 @@ FROM base AS builder WORKDIR /app COPY . . -# Generate a partial monorepo with a pruned lockfile for the relay workspace -RUN bunx turbo@2.8.2 prune @evolu/relay --docker +# Generate a partial monorepo for the relay workspace. +RUN bunx turbo@2.8.2 prune relay --docker # ------------------------------------------------------------ # Installer stage - build the pruned workspace @@ -22,10 +22,10 @@ RUN bun install --frozen-lockfile COPY --from=builder /app/out/full/ . # Ensure README.md is available at the root for the build process COPY --from=builder /app/README.md ./README.md -RUN bunx turbo@2.8.2 run build +RUN bunx turbo@2.8.2 run build --filter relay -# Prune dev dependencies to create a production-ready environment -RUN bun install --production +# Keep only production dependencies for relay runtime. +RUN bun install --frozen-lockfile --production --filter relay # ------------------------------------------------------------ # Runner stage - minimal runtime image @@ -59,4 +59,4 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ CMD bun -e "const net=require('net');const s=net.connect(4000,'127.0.0.1',()=>{s.end();process.exit(0)});s.on('error',()=>process.exit(1))" # Start the application -CMD ["bun", "apps/relay/dist/index.js"] \ No newline at end of file +CMD ["bun", "apps/relay/dist/index.js"] diff --git a/apps/relay/package.json b/apps/relay/package.json index 33ac234a5..2d532cf30 100644 --- a/apps/relay/package.json +++ b/apps/relay/package.json @@ -1,5 +1,5 @@ { - "name": "@evolu/relay", + "name": "relay", "version": "2.0.8", "private": true, "type": "module", diff --git a/bun.lock b/bun.lock index c1d46ffc2..add9dccee 100644 --- a/bun.lock +++ b/bun.lock @@ -30,7 +30,7 @@ }, }, "apps/relay": { - "name": "@evolu/relay", + "name": "relay", "version": "2.0.8", "dependencies": { "@evolu/common": "*", @@ -460,9 +460,6 @@ }, }, }, - "patchedDependencies": { - "@changesets/assemble-release-plan@6.0.9": "patches/@changesets__assemble-release-plan.patch", - }, "packages": { "7zip-bin": ["7zip-bin@5.2.0", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="], @@ -948,8 +945,6 @@ "@evolu/react-web": ["@evolu/react-web@workspace:packages/react-web"], - "@evolu/relay": ["@evolu/relay@workspace:apps/relay"], - "@evolu/sqlite-wasm": ["@evolu/sqlite-wasm@2.2.4", "", { "bin": { "sqlite-wasm": "bin/index.js" } }, "sha512-/JOYGFN93QspD2C8HVxVgBUlFWqJ1IpaVuIhEB53u4+ZvE+D3LjpNHDYiwZgf0n7VaH2U85OY5eV3wUrWc3scg=="], "@evolu/svelte": ["@evolu/svelte@workspace:packages/svelte"], @@ -1654,7 +1649,7 @@ "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], - "@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + "@types/node": ["@types/node@24.12.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-v6Ct1W1Fdz7xg5jYCg4FTrbNcIqzds2jv/HL6+5Rs/Cyjf0oljAgW59zvDZXyYG7nt9MLrAFJv9erP/fLjwt+g=="], "@types/plist": ["@types/plist@3.0.5", "", { "dependencies": { "@types/node": "*", "xmlbuilder": ">=11.0.1" } }, "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA=="], @@ -1748,25 +1743,25 @@ "@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="], - "@vue/compiler-core": ["@vue/compiler-core@3.5.31", "", { "dependencies": { "@babel/parser": "^7.29.2", "@vue/shared": "3.5.31", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ=="], + "@vue/compiler-core": ["@vue/compiler-core@3.5.32", "", { "dependencies": { "@babel/parser": "^7.29.2", "@vue/shared": "3.5.32", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ=="], - "@vue/compiler-dom": ["@vue/compiler-dom@3.5.31", "", { "dependencies": { "@vue/compiler-core": "3.5.31", "@vue/shared": "3.5.31" } }, "sha512-BMY/ozS/xxjYqRFL+tKdRpATJYDTTgWSo0+AJvJNg4ig+Hgb0dOsHPXvloHQ5hmlivUqw1Yt2pPIqp4e0v1GUw=="], + "@vue/compiler-dom": ["@vue/compiler-dom@3.5.32", "", { "dependencies": { "@vue/compiler-core": "3.5.32", "@vue/shared": "3.5.32" } }, "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q=="], - "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.31", "", { "dependencies": { "@babel/parser": "^7.29.2", "@vue/compiler-core": "3.5.31", "@vue/compiler-dom": "3.5.31", "@vue/compiler-ssr": "3.5.31", "@vue/shared": "3.5.31", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.8", "source-map-js": "^1.2.1" } }, "sha512-M8wpPgR9UJ8MiRGjppvx9uWJfLV7A/T+/rL8s/y3QG3u0c2/YZgff3d6SuimKRIhcYnWg5fTfDMlz2E6seUW8Q=="], + "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.32", "", { "dependencies": { "@babel/parser": "^7.29.2", "@vue/compiler-core": "3.5.32", "@vue/compiler-dom": "3.5.32", "@vue/compiler-ssr": "3.5.32", "@vue/shared": "3.5.32", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.8", "source-map-js": "^1.2.1" } }, "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg=="], - "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.31", "", { "dependencies": { "@vue/compiler-dom": "3.5.31", "@vue/shared": "3.5.31" } }, "sha512-h0xIMxrt/LHOvJKMri+vdYT92BrK3HFLtDqq9Pr/lVVfE4IyKZKvWf0vJFW10Yr6nX02OR4MkJwI0c1HDa1hog=="], + "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.32", "", { "dependencies": { "@vue/compiler-dom": "3.5.32", "@vue/shared": "3.5.32" } }, "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw=="], "@vue/language-core": ["@vue/language-core@3.2.6", "", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.0.0", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.2" } }, "sha512-xYYYX3/aVup576tP/23sEUpgiEnujrENaoNRbaozC1/MA9I6EGFQRJb4xrt/MmUCAGlxTKL2RmT8JLTPqagCkg=="], - "@vue/reactivity": ["@vue/reactivity@3.5.31", "", { "dependencies": { "@vue/shared": "3.5.31" } }, "sha512-DtKXxk9E/KuVvt8VxWu+6Luc9I9ETNcqR1T1oW1gf02nXaZ1kuAx58oVu7uX9XxJR0iJCro6fqBLw9oSBELo5g=="], + "@vue/reactivity": ["@vue/reactivity@3.5.32", "", { "dependencies": { "@vue/shared": "3.5.32" } }, "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ=="], - "@vue/runtime-core": ["@vue/runtime-core@3.5.31", "", { "dependencies": { "@vue/reactivity": "3.5.31", "@vue/shared": "3.5.31" } }, "sha512-AZPmIHXEAyhpkmN7aWlqjSfYynmkWlluDNPHMCZKFHH+lLtxP/30UJmoVhXmbDoP1Ng0jG0fyY2zCj1PnSSA6Q=="], + "@vue/runtime-core": ["@vue/runtime-core@3.5.32", "", { "dependencies": { "@vue/reactivity": "3.5.32", "@vue/shared": "3.5.32" } }, "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ=="], - "@vue/runtime-dom": ["@vue/runtime-dom@3.5.31", "", { "dependencies": { "@vue/reactivity": "3.5.31", "@vue/runtime-core": "3.5.31", "@vue/shared": "3.5.31", "csstype": "^3.2.3" } }, "sha512-xQJsNRmGPeDCJq/u813tyonNgWBFjzfVkBwDREdEWndBnGdHLHgkwNBQxLtg4zDrzKTEcnikUy1UUNecb3lJ6g=="], + "@vue/runtime-dom": ["@vue/runtime-dom@3.5.32", "", { "dependencies": { "@vue/reactivity": "3.5.32", "@vue/runtime-core": "3.5.32", "@vue/shared": "3.5.32", "csstype": "^3.2.3" } }, "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ=="], - "@vue/server-renderer": ["@vue/server-renderer@3.5.31", "", { "dependencies": { "@vue/compiler-ssr": "3.5.31", "@vue/shared": "3.5.31" }, "peerDependencies": { "vue": "3.5.31" } }, "sha512-GJuwRvMcdZX/CriUnyIIOGkx3rMV3H6sOu0JhdKbduaeCji6zb60iOGMY7tFoN24NfsUYoFBhshZtGxGpxO4iA=="], + "@vue/server-renderer": ["@vue/server-renderer@3.5.32", "", { "dependencies": { "@vue/compiler-ssr": "3.5.32", "@vue/shared": "3.5.32" }, "peerDependencies": { "vue": "3.5.32" } }, "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ=="], - "@vue/shared": ["@vue/shared@3.5.31", "", {}, "sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw=="], + "@vue/shared": ["@vue/shared@3.5.32", "", {}, "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg=="], "@vue/tsconfig": ["@vue/tsconfig@0.8.1", "", { "peerDependencies": { "typescript": "5.x", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g=="], @@ -3352,6 +3347,8 @@ "rehype-recma": ["rehype-recma@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "hast-util-to-estree": "^3.0.0" } }, "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw=="], + "relay": ["relay@workspace:apps/relay"], + "remark": ["remark@15.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A=="], "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], @@ -3826,7 +3823,7 @@ "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], - "vue": ["vue@3.5.31", "", { "dependencies": { "@vue/compiler-dom": "3.5.31", "@vue/compiler-sfc": "3.5.31", "@vue/runtime-dom": "3.5.31", "@vue/server-renderer": "3.5.31", "@vue/shared": "3.5.31" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q=="], + "vue": ["vue@3.5.32", "", { "dependencies": { "@vue/compiler-dom": "3.5.32", "@vue/compiler-sfc": "3.5.32", "@vue/runtime-dom": "3.5.32", "@vue/server-renderer": "3.5.32", "@vue/shared": "3.5.32" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw=="], "vue-tsc": ["vue-tsc@3.2.6", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.2.6" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-gYW/kWI0XrwGzd0PKc7tVB/qpdeAkIZLNZb10/InizkQjHjnT8weZ/vBarZoj4kHKbUTZT/bAVgoOr8x4NsQ/Q=="], @@ -4022,7 +4019,7 @@ "@expo/metro-config/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], - "@expo/metro-config/hermes-parser": ["hermes-parser@0.32.0", "", { "dependencies": { "hermes-estree": "0.32.0" } }, "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw=="], + "@expo/metro-config/hermes-parser": ["hermes-parser@0.32.1", "", { "dependencies": { "hermes-estree": "0.32.1" } }, "sha512-175dz634X/W5AiwrpLdoMl/MOb17poLHyIqgyExlE8D9zQ1OPnoORnGMB5ltRKnpvQzBjMYvT2rN/sHeIfZW5Q=="], "@expo/metro-config/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], @@ -4502,7 +4499,7 @@ "@expo/metro-config/glob/path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], - "@expo/metro-config/hermes-parser/hermes-estree": ["hermes-estree@0.32.0", "", {}, "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ=="], + "@expo/metro-config/hermes-parser/hermes-estree": ["hermes-estree@0.32.1", "", {}, "sha512-ne5hkuDxheNBAikDjqvCZCwihnz0vVu9YsBzAEO1puiyFR4F1+PAz/SiPHSsNTuOveCYGRMX8Xbx4LOubeC0Qg=="], "@inquirer/core/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], diff --git a/package.json b/package.json index cf0e64149..0ada70090 100755 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "examples/*" ], "scripts": { - "dev": "turbo --filter @evolu/relay dev", - "relay": "turbo --filter @evolu/relay dev", + "dev": "turbo --filter relay dev", + "relay": "turbo --filter relay dev", "build": "bun run clean:ts && turbo --filter @evolu/* build", "build:web": "bun run build:docs", "build:docs": "bun run docs:generate:api", @@ -57,6 +57,8 @@ "lint": "biome check", "lint-monorepo": "bunx sherif@1.6.1", "format": "biome format --write .", + "sync:guard:upstream": "bun ./scripts/sync-guard-upstream.mts", + "sync:guard:upstream:strict": "bun ./scripts/sync-guard-upstream.mts --strict", "sync:guard:common-v8": "bun ./scripts/sync-guard-common-v8.mts", "sync:guard:common-v8:strict": "bun ./scripts/sync-guard-common-v8.mts --strict", "verify:fast": "bun run build && bun run test && bun run lint && bun run lint-monorepo", @@ -79,7 +81,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.10", - "@changesets/cli": "^2.29.8", + "@changesets/cli": "^2.30.0", "@vitest/browser": "^4.1.2", "@vitest/browser-playwright": "^4.1.2", "@vitest/coverage-istanbul": "^4.1.2", diff --git a/patches/@changesets__assemble-release-plan.patch b/patches/@changesets__assemble-release-plan.patch deleted file mode 100644 index 4967232db..000000000 --- a/patches/@changesets__assemble-release-plan.patch +++ /dev/null @@ -1,26 +0,0 @@ -diff --git a/dist/changesets-assemble-release-plan.cjs.js b/dist/changesets-assemble-release-plan.cjs.js -index e07ba6e793021b6cfdec898afca517e293386ddb..88d80a95fbe739996918ef4883601b4388926123 100644 ---- a/dist/changesets-assemble-release-plan.cjs.js -+++ b/dist/changesets-assemble-release-plan.cjs.js -@@ -215,7 +215,7 @@ function determineDependents({ - preInfo, - onlyUpdatePeerDependentsWhenOutOfRange: config.___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH.onlyUpdatePeerDependentsWhenOutOfRange - })) { -- type = "major"; -+ type = "minor"; - } else if ((!releases.has(dependent) || releases.get(dependent).type === "none") && (config.___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH.updateInternalDependents === "always" || !semverSatisfies__default["default"](incrementVersion(nextRelease, preInfo), versionRange))) { - switch (depType) { - case "dependencies": -diff --git a/dist/changesets-assemble-release-plan.esm.js b/dist/changesets-assemble-release-plan.esm.js -index ea2be567403c4ef94a65f3218ccb683cf5cb4bc1..b62b66628d8887618b02ee35359faf70cbe685ad 100644 ---- a/dist/changesets-assemble-release-plan.esm.js -+++ b/dist/changesets-assemble-release-plan.esm.js -@@ -204,7 +204,7 @@ function determineDependents({ - preInfo, - onlyUpdatePeerDependentsWhenOutOfRange: config.___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH.onlyUpdatePeerDependentsWhenOutOfRange - })) { -- type = "major"; -+ type = "minor"; - } else if ((!releases.has(dependent) || releases.get(dependent).type === "none") && (config.___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH.updateInternalDependents === "always" || !semverSatisfies(incrementVersion(nextRelease, preInfo), versionRange))) { - switch (depType) { - case "dependencies": diff --git a/scripts/sync-guard-common-v8.mts b/scripts/sync-guard-common-v8.mts index ae4082f11..ead3359c8 100644 --- a/scripts/sync-guard-common-v8.mts +++ b/scripts/sync-guard-common-v8.mts @@ -1,343 +1,5 @@ -import { spawnSync } from "node:child_process"; +process.stderr.write( + "[deprecated] sync-guard-common-v8.mts now forwards to sync-guard-upstream.mts; use bun run sync:guard:upstream instead.\n", +); -type Severity = "error" | "warn"; - -interface BranchIssue { - readonly ref: string; - readonly tip: string; -} - -interface DanglingIssue { - readonly severity: Severity; - readonly sha: string; - readonly dateIso: string; - readonly subject: string; - readonly reason: string; -} - -interface GuardOptions { - readonly mainRef: string; - readonly upstreamRef: string; - readonly days: number; - readonly maxCommitsForPatchMap: number; - readonly strict: boolean; - readonly json: boolean; -} - -interface GuardReport { - readonly options: GuardOptions; - readonly branchIssues: ReadonlyArray; - readonly danglingIssues: ReadonlyArray; -} - -const parseOptions = (): GuardOptions => { - const args = process.argv.slice(2); - - let strict = false; - let json = false; - let days = Number(process.env.SYNC_GUARD_DAYS ?? "7"); - - for (let i = 0; i < args.length; i += 1) { - const arg = args[i]; - if (arg === "--strict") { - strict = true; - continue; - } - if (arg === "--json") { - json = true; - continue; - } - if (arg === "--days") { - const next = args[i + 1]; - if (!next) throw new Error("Missing value for --days"); - days = Number(next); - i += 1; - continue; - } - throw new Error(`Unknown argument: ${arg}`); - } - - if (!Number.isFinite(days) || days <= 0) { - throw new Error(`Invalid --days value: ${String(days)}`); - } - - return { - mainRef: process.env.SYNC_GUARD_MAIN_REF ?? "main", - upstreamRef: process.env.SYNC_GUARD_UPSTREAM_REF ?? "upstream/common-v8", - days, - maxCommitsForPatchMap: Number( - process.env.SYNC_GUARD_PATCH_MAP_DEPTH ?? "900", - ), - strict, - json, - }; -}; - -const runGit = ( - args: ReadonlyArray, - options?: { - readonly allowFailure?: boolean; - readonly stdin?: string; - }, -): string => { - const result = spawnSync("git", args, { - encoding: "utf8", - input: options?.stdin, - }); - - if (result.status === 0) return result.stdout; - if (options?.allowFailure) return ""; - - const stderr = result.stderr.trim(); - throw new Error( - `git ${args.join(" ")} failed${ - stderr ? `: ${stderr}` : "" - }`, - ); -}; - -const runGitLines = ( - args: ReadonlyArray, - options?: { - readonly allowFailure?: boolean; - readonly stdin?: string; - }, -): ReadonlyArray => - runGit(args, options) - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); - -const getCurrentBranchShortName = (): string | null => { - const current = runGit(["branch", "--show-current"], { allowFailure: true }).trim(); - return current.length > 0 ? current : null; -}; - -const isAncestor = (candidateRef: string, targetRef: string): boolean => { - const result = spawnSync("git", ["merge-base", "--is-ancestor", candidateRef, targetRef], { - encoding: "utf8", - }); - return result.status === 0; -}; - -const commitPatchId = (sha: string): string | null => { - const patch = runGit(["show", sha, "--pretty=format:"], { allowFailure: true }); - if (!patch.trim()) return null; - - const patchIdOutput = runGit(["patch-id", "--stable"], { - stdin: patch, - allowFailure: true, - }); - const patchId = patchIdOutput.trim().split(/\s+/)[0]; - return patchId?.length ? patchId : null; -}; - -const buildPatchMap = ( - ref: string, - maxCount: number, -): ReadonlyMap => { - const commits = runGitLines(["rev-list", `--max-count=${String(maxCount)}`, ref], { - allowFailure: true, - }); - const map = new Map(); - for (const sha of commits) { - const patchId = commitPatchId(sha); - if (!patchId) continue; - if (!map.has(patchId)) map.set(patchId, sha); - } - return map; -}; - -const getCommonV8BranchIssues = (mainRef: string): ReadonlyArray => { - const refs = runGitLines([ - "for-each-ref", - "--format=%(refname:short)", - "refs/heads", - "refs/remotes/origin", - ]); - const currentBranch = getCurrentBranchShortName(); - - const candidateByCanonicalRef = new Map(); - for (const ref of refs) { - if (!/^(origin\/)?sync\/common-v8/.test(ref)) continue; - if (ref.endsWith("/HEAD")) continue; - - const canonicalRef = ref.startsWith("origin/") ? ref.slice(7) : ref; - // Prefer local branch ref over origin/* mirror to avoid duplicate reports. - if (candidateByCanonicalRef.has(canonicalRef) && ref.startsWith("origin/")) { - continue; - } - candidateByCanonicalRef.set(canonicalRef, ref); - } - - const candidates = [...candidateByCanonicalRef.values()]; - - return candidates - .filter((ref) => { - const canonicalRef = ref.startsWith("origin/") ? ref.slice(7) : ref; - - // Active sync branch is expected to be unmerged while work is in progress. - if ( - currentBranch && - canonicalRef === currentBranch && - /^sync\/common-v8/.test(canonicalRef) - ) { - return false; - } - - return !isAncestor(ref, mainRef); - }) - .map((ref) => ({ - ref, - tip: runGit(["show", "-s", "--format=%h %cs %s", ref]).trim(), - })); -}; - -const getUnreachableCommits = (): ReadonlyArray => - runGitLines(["fsck", "--full", "--no-reflogs", "--unreachable"], { - allowFailure: true, - }) - .map((line) => { - const match = line.match(/^unreachable commit ([0-9a-f]{40})$/); - return match?.[1] ?? null; - }) - .filter((sha): sha is string => sha !== null); - -const getDanglingIssues = (options: GuardOptions): ReadonlyArray => { - const nowSeconds = Math.floor(Date.now() / 1000); - const minTimestamp = nowSeconds - options.days * 24 * 60 * 60; - const unreachableCommits = getUnreachableCommits(); - if (unreachableCommits.length === 0) return []; - - const mainPatchMap = buildPatchMap(options.mainRef, options.maxCommitsForPatchMap); - const upstreamPatchMap = buildPatchMap( - options.upstreamRef, - options.maxCommitsForPatchMap, - ); - - const syncLabel = /common-v8|upstream\/common-v8|cherry-pick/i; - const issues: Array = []; - - for (const sha of unreachableCommits) { - const timestamp = Number(runGit(["show", "-s", "--format=%ct", sha])); - if (!Number.isFinite(timestamp) || timestamp < minTimestamp) continue; - - const dateIso = runGit(["show", "-s", "--format=%cI", sha]).trim(); - const subject = runGit(["show", "-s", "--format=%s", sha]).trim(); - const body = runGit(["show", "-s", "--format=%b", sha]).trim(); - // Ignore synthetic stash commits (WIP/index) that are not actionable sync history. - if (/^(WIP on |index on )/.test(subject)) continue; - // Ignore temporary local sync markers created during manual conflict work. - if (/temp-before-common-v8-wave\d+/i.test(subject)) continue; - - const patchId = commitPatchId(sha); - const inMain = patchId ? mainPatchMap.get(patchId) : undefined; - const inUpstream = patchId ? upstreamPatchMap.get(patchId) : undefined; - const hasSyncLabel = syncLabel.test(`${subject}\n${body}`); - - if (!inUpstream && !hasSyncLabel) { - continue; - } - - if (inMain) { - issues.push({ - severity: "warn", - sha, - dateIso, - subject, - reason: `dangling commit already represented in ${options.mainRef} as ${inMain.slice( - 0, - 8, - )}`, - }); - continue; - } - - if (inUpstream) { - issues.push({ - severity: "error", - sha, - dateIso, - subject, - reason: `dangling commit matches upstream patch ${inUpstream.slice(0, 8)} but is not in ${options.mainRef}`, - }); - continue; - } - - issues.push({ - severity: "error", - sha, - dateIso, - subject, - reason: "dangling commit is labeled as common-v8/cherry-pick and is not in main", - }); - } - - return issues.sort((a, b) => b.dateIso.localeCompare(a.dateIso)); -}; - -const renderHuman = (report: GuardReport): string => { - const lines: Array = []; - const { options, branchIssues, danglingIssues } = report; - const errorDangling = danglingIssues.filter((issue) => issue.severity === "error"); - const warnDangling = danglingIssues.filter((issue) => issue.severity === "warn"); - - lines.push( - `[sync-guard] main=${options.mainRef} upstream=${options.upstreamRef} window=${String(options.days)}d strict=${String(options.strict)}`, - ); - - if (branchIssues.length === 0) { - lines.push("[sync-guard] branch check: OK"); - } else { - lines.push( - `[sync-guard] branch check: ${String(branchIssues.length)} branch(es) with 'common-v8' are not merged into ${options.mainRef}`, - ); - for (const issue of branchIssues) { - lines.push(` ERROR ${issue.ref} -> ${issue.tip}`); - } - } - - if (danglingIssues.length === 0) { - lines.push("[sync-guard] dangling check: OK"); - } else { - lines.push( - `[sync-guard] dangling check: ${String(errorDangling.length)} error(s), ${String(warnDangling.length)} warning(s)`, - ); - for (const issue of danglingIssues) { - lines.push( - ` ${issue.severity.toUpperCase()} ${issue.sha.slice(0, 8)} ${issue.dateIso} ${issue.subject}`, - ); - lines.push(` ${issue.reason}`); - } - } - - const hasErrors = - branchIssues.length > 0 || (options.strict ? danglingIssues.length > 0 : errorDangling.length > 0); - lines.push(`[sync-guard] result: ${hasErrors ? "FAIL" : "OK"}`); - return lines.join("\n"); -}; - -const main = (): number => { - const options = parseOptions(); - const branchIssues = getCommonV8BranchIssues(options.mainRef); - const danglingIssues = getDanglingIssues(options); - const report: GuardReport = { - options, - branchIssues, - danglingIssues, - }; - - const errorDangling = danglingIssues.filter((issue) => issue.severity === "error"); - const hasErrors = - branchIssues.length > 0 || (options.strict ? danglingIssues.length > 0 : errorDangling.length > 0); - - if (options.json) { - process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); - } else { - process.stdout.write(`${renderHuman(report)}\n`); - } - - return hasErrors ? 1 : 0; -}; - -process.exitCode = main(); +import "./sync-guard-upstream.mts"; diff --git a/scripts/sync-guard-upstream.mts b/scripts/sync-guard-upstream.mts new file mode 100644 index 000000000..e489d5c07 --- /dev/null +++ b/scripts/sync-guard-upstream.mts @@ -0,0 +1,364 @@ +import { spawnSync } from "node:child_process"; + +type Severity = "error" | "warn"; + +interface BranchIssue { + readonly ref: string; + readonly tip: string; +} + +interface DanglingIssue { + readonly severity: Severity; + readonly sha: string; + readonly dateIso: string; + readonly subject: string; + readonly reason: string; +} + +interface GuardOptions { + readonly mainRef: string; + readonly upstreamRef: string; + readonly days: number; + readonly maxCommitsForPatchMap: number; + readonly strict: boolean; + readonly json: boolean; +} + +interface GuardReport { + readonly options: GuardOptions; + readonly branchIssues: ReadonlyArray; + readonly danglingIssues: ReadonlyArray; +} + +const syncBranchPattern = /^(origin\/)?sync\/(common-v8|upstream-main)/; +const syncLabelPattern = /common-v8|upstream\/common-v8|upstream-main|upstream\/main|cherry-pick/i; +const temporaryMarkerPattern = /temp-before-(common-v8|upstream-main)-wave\d+/i; + +const parseOptions = (): GuardOptions => { + const args = process.argv.slice(2); + + let strict = false; + let json = false; + let days = Number(process.env.SYNC_GUARD_DAYS ?? "7"); + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === "--strict") { + strict = true; + continue; + } + if (arg === "--json") { + json = true; + continue; + } + if (arg === "--days") { + const next = args[i + 1]; + if (!next) throw new Error("Missing value for --days"); + days = Number(next); + i += 1; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + + if (!Number.isFinite(days) || days <= 0) { + throw new Error(`Invalid --days value: ${String(days)}`); + } + + return { + mainRef: process.env.SYNC_GUARD_MAIN_REF ?? "main", + upstreamRef: process.env.SYNC_GUARD_UPSTREAM_REF ?? "upstream/main", + days, + maxCommitsForPatchMap: Number( + process.env.SYNC_GUARD_PATCH_MAP_DEPTH ?? "900", + ), + strict, + json, + }; +}; + +const runGit = ( + args: ReadonlyArray, + options?: { + readonly allowFailure?: boolean; + readonly stdin?: string; + }, +): string => { + const result = spawnSync("git", args, { + encoding: "utf8", + input: options?.stdin, + }); + + if (result.status === 0) return result.stdout; + if (options?.allowFailure) return ""; + + const stderr = result.stderr.trim(); + throw new Error( + `git ${args.join(" ")} failed${stderr ? `: ${stderr}` : ""}`, + ); +}; + +const runGitLines = ( + args: ReadonlyArray, + options?: { + readonly allowFailure?: boolean; + readonly stdin?: string; + }, +): ReadonlyArray => + runGit(args, options) + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + +const getCurrentBranchShortName = (): string | null => { + const current = runGit(["branch", "--show-current"], { + allowFailure: true, + }).trim(); + return current.length > 0 ? current : null; +}; + +const isAncestor = (candidateRef: string, targetRef: string): boolean => { + const result = spawnSync( + "git", + ["merge-base", "--is-ancestor", candidateRef, targetRef], + { + encoding: "utf8", + }, + ); + return result.status === 0; +}; + +const commitPatchId = (sha: string): string | null => { + const patch = runGit(["show", sha, "--pretty=format:"], { + allowFailure: true, + }); + if (!patch.trim()) return null; + + const patchIdOutput = runGit(["patch-id", "--stable"], { + stdin: patch, + allowFailure: true, + }); + const patchId = patchIdOutput.trim().split(/\s+/)[0]; + return patchId?.length ? patchId : null; +}; + +const buildPatchMap = ( + ref: string, + maxCount: number, +): ReadonlyMap => { + const commits = runGitLines( + ["rev-list", `--max-count=${String(maxCount)}`, ref], + { + allowFailure: true, + }, + ); + const map = new Map(); + for (const sha of commits) { + const patchId = commitPatchId(sha); + if (!patchId) continue; + if (!map.has(patchId)) map.set(patchId, sha); + } + return map; +}; + +const getSyncBranchIssues = (mainRef: string): ReadonlyArray => { + const refs = runGitLines([ + "for-each-ref", + "--format=%(refname:short)", + "refs/heads", + "refs/remotes/origin", + ]); + const currentBranch = getCurrentBranchShortName(); + const originCanonicalRefs = new Set( + refs + .filter((ref) => ref.startsWith("origin/")) + .map((ref) => ref.slice(7)) + .filter((ref) => syncBranchPattern.test(ref)), + ); + + const candidateByCanonicalRef = new Map(); + for (const ref of refs) { + if (!syncBranchPattern.test(ref)) continue; + if (ref.endsWith("/HEAD")) continue; + + const canonicalRef = ref.startsWith("origin/") ? ref.slice(7) : ref; + if ( + !ref.startsWith("origin/") && + canonicalRef !== currentBranch && + !originCanonicalRefs.has(canonicalRef) + ) { + continue; + } + if (candidateByCanonicalRef.has(canonicalRef) && ref.startsWith("origin/")) { + continue; + } + candidateByCanonicalRef.set(canonicalRef, ref); + } + + return [...candidateByCanonicalRef.values()] + .filter((ref) => { + const canonicalRef = ref.startsWith("origin/") ? ref.slice(7) : ref; + + if ( + currentBranch && + canonicalRef === currentBranch && + syncBranchPattern.test(canonicalRef) + ) { + return false; + } + + return !isAncestor(ref, mainRef); + }) + .map((ref) => ({ + ref, + tip: runGit(["show", "-s", "--format=%h %cs %s", ref]).trim(), + })); +}; + +const getUnreachableCommits = (): ReadonlyArray => + runGitLines(["fsck", "--full", "--no-reflogs", "--unreachable"], { + allowFailure: true, + }) + .map((line) => { + const match = line.match(/^unreachable commit ([0-9a-f]{40})$/); + return match?.[1] ?? null; + }) + .filter((sha): sha is string => sha !== null); + +const getDanglingIssues = (options: GuardOptions): ReadonlyArray => { + const nowSeconds = Math.floor(Date.now() / 1000); + const minTimestamp = nowSeconds - options.days * 24 * 60 * 60; + const unreachableCommits = getUnreachableCommits(); + if (unreachableCommits.length === 0) return []; + + const mainPatchMap = buildPatchMap(options.mainRef, options.maxCommitsForPatchMap); + const upstreamPatchMap = buildPatchMap( + options.upstreamRef, + options.maxCommitsForPatchMap, + ); + + const issues: Array = []; + + for (const sha of unreachableCommits) { + const timestamp = Number(runGit(["show", "-s", "--format=%ct", sha])); + if (!Number.isFinite(timestamp) || timestamp < minTimestamp) continue; + + const dateIso = runGit(["show", "-s", "--format=%cI", sha]).trim(); + const subject = runGit(["show", "-s", "--format=%s", sha]).trim(); + const body = runGit(["show", "-s", "--format=%b", sha]).trim(); + if (/^(WIP on |index on )/.test(subject)) continue; + if (temporaryMarkerPattern.test(subject)) continue; + + const patchId = commitPatchId(sha); + const inMain = patchId ? mainPatchMap.get(patchId) : undefined; + const inUpstream = patchId ? upstreamPatchMap.get(patchId) : undefined; + const hasSyncLabel = syncLabelPattern.test(`${subject}\n${body}`); + + if (!inUpstream && !hasSyncLabel) { + continue; + } + + if (inMain) { + issues.push({ + severity: "warn", + sha, + dateIso, + subject, + reason: `dangling commit already represented in ${options.mainRef} as ${inMain.slice( + 0, + 8, + )}`, + }); + continue; + } + + if (inUpstream) { + issues.push({ + severity: "error", + sha, + dateIso, + subject, + reason: `dangling commit matches upstream patch ${inUpstream.slice(0, 8)} but is not in ${options.mainRef}`, + }); + continue; + } + + issues.push({ + severity: "error", + sha, + dateIso, + subject, + reason: "dangling commit is labeled as upstream sync/cherry-pick work and is not in main", + }); + } + + return issues.sort((a, b) => b.dateIso.localeCompare(a.dateIso)); +}; + +const renderHuman = (report: GuardReport): string => { + const lines: Array = []; + const { options, branchIssues, danglingIssues } = report; + const errorDangling = danglingIssues.filter((issue) => issue.severity === "error"); + const warnDangling = danglingIssues.filter((issue) => issue.severity === "warn"); + + lines.push( + `[sync-guard] main=${options.mainRef} upstream=${options.upstreamRef} window=${String(options.days)}d strict=${String(options.strict)}`, + ); + + if (branchIssues.length === 0) { + lines.push("[sync-guard] branch check: OK"); + } else { + lines.push( + `[sync-guard] branch check: ${String(branchIssues.length)} sync branch(es) are not merged into ${options.mainRef}`, + ); + for (const issue of branchIssues) { + lines.push(` ERROR ${issue.ref} -> ${issue.tip}`); + } + } + + if (danglingIssues.length === 0) { + lines.push("[sync-guard] dangling check: OK"); + } else { + lines.push( + `[sync-guard] dangling check: ${String(errorDangling.length)} error(s), ${String(warnDangling.length)} warning(s)`, + ); + for (const issue of danglingIssues) { + lines.push( + ` ${issue.severity.toUpperCase()} ${issue.sha.slice(0, 8)} ${issue.dateIso} ${issue.subject}`, + ); + lines.push(` ${issue.reason}`); + } + } + + const hasErrors = + branchIssues.length > 0 || + (options.strict ? danglingIssues.length > 0 : errorDangling.length > 0); + lines.push(`[sync-guard] result: ${hasErrors ? "FAIL" : "OK"}`); + return lines.join("\n"); +}; + +const main = (): number => { + const options = parseOptions(); + const branchIssues = getSyncBranchIssues(options.mainRef); + const danglingIssues = getDanglingIssues(options); + const report: GuardReport = { + options, + branchIssues, + danglingIssues, + }; + + const errorDangling = danglingIssues.filter((issue) => issue.severity === "error"); + const hasErrors = + branchIssues.length > 0 || + (options.strict ? danglingIssues.length > 0 : errorDangling.length > 0); + + if (options.json) { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + } else { + process.stdout.write(`${renderHuman(report)}\n`); + } + + return hasErrors ? 1 : 0; +}; + +process.exitCode = main();