diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 611d4cf44f75..f324ab456396 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ concurrency: jobs: check: name: Check - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -75,7 +75,7 @@ jobs: # limit stays at the default 4 so peak load per runner is unchanged. test: name: Test - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -118,7 +118,7 @@ jobs: # isolation that flag buys is preserved exactly. test_server: name: Test Server ${{ matrix.shard }} - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 strategy: fail-fast: false @@ -185,7 +185,7 @@ jobs: # for checks that take under 3s, on the critical path of every PR. rust: name: Rust - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout @@ -219,7 +219,7 @@ jobs: # the diff cannot be resolved, the lint runs. mobile_native_changes: name: Mobile Native Changes - runs-on: blacksmith-2vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: contents: read @@ -297,7 +297,7 @@ jobs: # Skip only on an explicit "no": a gate job that failed or errored leaves the # output empty, and that must run the lint rather than silently skip it. if: ${{ !cancelled() && needs.mobile_native_changes.outputs.changed != 'false' }} - runs-on: blacksmith-6vcpu-macos-26 + runs-on: macos-15 timeout-minutes: 10 steps: - name: Checkout @@ -325,7 +325,7 @@ jobs: release_smoke: name: Release Smoke - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - name: Checkout diff --git a/.github/workflows/fork.yml b/.github/workflows/fork.yml new file mode 100644 index 000000000000..db2c92400fe2 --- /dev/null +++ b/.github/workflows/fork.yml @@ -0,0 +1,33 @@ +name: Fork + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +# Runs alongside upstream's own CI: ci.yml covers the project's checks and +# tests, this workflow covers the fork-stack model. Both must be green. +jobs: + fork-model: + name: Fork Stack Model + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + # Check the PR head itself; the default PR merge ref is a merge commit. + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Fetch upstream base + run: | + git remote add upstream https://github.com/pingdotgg/t3code.git 2>/dev/null || true + git fetch upstream main --depth 100 + + - name: Check fork stack + run: bash scripts/fork-check.sh --upstream upstream diff --git a/.github/workflows/mobile-fingerprint-check.yml b/.github/workflows/mobile-fingerprint-check.yml index b8b872f019f6..5849b71ddbb8 100644 --- a/.github/workflows/mobile-fingerprint-check.yml +++ b/.github/workflows/mobile-fingerprint-check.yml @@ -24,7 +24,7 @@ concurrency: jobs: fingerprint: name: Native fingerprint diff - runs-on: blacksmith-8vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 permissions: contents: read issues: write diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index ce24afcb6284..ad2dd7371375 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -610,7 +610,7 @@ export const PullRequestsToolkitRegistrationLive = McpServer.toolkit(PullRequest Layer.provide(PullRequestsToolkitHandlersLive), ); -export const ThreadsToolkitRegistrationLive = McpServer.toolkit(ThreadsToolkit).pipe( +const ThreadsToolkitRegistrationLive = McpServer.toolkit(ThreadsToolkit).pipe( Layer.provide(ThreadsToolkitHandlersLive), ); diff --git a/apps/web/src/bundledDev.test.ts b/apps/web/src/bundledDev.test.ts index 599b45d22524..e5324e003db8 100644 --- a/apps/web/src/bundledDev.test.ts +++ b/apps/web/src/bundledDev.test.ts @@ -32,6 +32,10 @@ it("initializes React refresh before a shared UI chunk runs in bundled dev", asy for (const file of ["index.html", "src/bootstrap.ts", "src/lib/bootError.ts"]) { await NodeFSP.copyFile(new URL(`../${file}`, import.meta.url), NodePath.join(root, file)); } + await NodeFSP.writeFile( + NodePath.join(root, "src/branding.ts"), + 'export const APP_BASE_NAME = "Chromeria";\n', + ); await NodeFSP.writeFile( NodePath.join(root, "src/shared.tsx"), "export function Shared() { return
ready
; }", diff --git a/docs/fork.md b/docs/fork.md new file mode 100644 index 000000000000..39e199336c54 --- /dev/null +++ b/docs/fork.md @@ -0,0 +1,245 @@ +# Fork maintenance + +This repo (`toolboxmd/t3code`) is a thin, rebasable layer on upstream +`pingdotgg/t3code`. Upstream `main` is the base; fork work is a small stack of +topic commits kept on top of it. This document describes the model, the +maintenance routine, and the upstream extension points that would shrink the +patch set further. + +## Model + +- **Base.** Upstream `main` (`https://github.com/pingdotgg/t3code.git`, + remote name `upstream`). Fork `main` is that base plus the stack below. +- **Stack.** Fork work lives as topic commits on top of the current upstream + base. A topic PR is squash-merged, so each lands as one stack commit. No + merge commits: upstream updates are absorbed by rebasing the stack, never + by merging `upstream/main` into the fork. +- **Absorption moves `main`.** Rebasing the stack rewrites `main`, which no + PR merge can do: squash or rebase merges would copy upstream commits under + new hashes and break the stack. An absorption is proven on its own branch + and then lands with `--force-with-lease` (see Routine). That push is a + human-approved step. +- **New files first.** Features go in new files and new packages. Edits to + upstream-owned files stay minimal and every edited file is listed under + "Upstream edits" below and in the machine-checked allowlist + `scripts/fork-upstream-edits.txt` (one path per line). +- **Remotes.** `origin` is `toolboxmd/t3code`; `upstream` is + `pingdotgg/t3code`. Never push to `upstream`, never send fork commits there + (offering extension points upstream is a separate human decision, out of + scope for routine maintenance). + +## Fork-only content + +New files owned by the fork (no upstream counterpart, always allowed): + +- `VISION.md`, `MISSION.md`, `OBJECTIVE.md`, `GLOSSARY.md`: Project Direction + and project language. +- `assets/chromeria/`, `apps/web/public/chromeria-mark.png`: Chromeria icons. +- `docs/fork.md`: this document. +- `scripts/fork-rebase.sh`: the rebase routine. +- `scripts/fork-check.sh`: the stack-model check run by CI. +- `scripts/fork-maintenance.test.ts`: tests driving both scripts against + throwaway git fixtures (runs with upstream's own test suite). +- `scripts/fork-upstream-edits.txt`: allowlist of upstream files the fork + may modify. +- `.github/workflows/fork.yml`: fork CI (the stack check). + +## Upstream edits + +Upstream-owned files modified by the fork. `scripts/fork-check.sh` fails on +any modified upstream file missing from `scripts/fork-upstream-edits.txt`. + +- Chromeria branding (#12, #13): `apps/desktop/package.json` (product + name), `apps/desktop/src/app/DesktopEnvironment.ts` (names, userData + directory), `scripts/build-desktop-artifact.ts` (app id, artifact name, no + update feed, icons), `scripts/lib/brand-assets.ts` (icon paths), and the + expectations in `apps/desktop/src/app/DesktopAppIdentity.test.ts`, + `apps/desktop/src/app/DesktopPreReadyPlatform.test.ts`, + `scripts/build-desktop-artifact.test.ts` and + `scripts/lib/brand-assets.test.ts`. +- Chromeria name in the web UI (#14): `apps/web/index.html`, + `apps/web/src/branding.ts`, `apps/web/src/lib/bootError.ts`, and the + branding fixture in `apps/web/src/bundledDev.test.ts`, plus the + components `T3Wordmark.tsx`, `chat/MessagesTimeline.tsx`, + `onboarding/WelcomeWizard.tsx`, `settings/IntegrationsSettings.tsx`, + `settings/ThemePreviewCircles.tsx` and `sidebar/SidebarChrome.tsx` under + `apps/web/src/components/`, with expectations in + `apps/web/src/bootstrap.test.ts` and `apps/web/src/branding.test.ts`. +- Child threads (#8, #10): `apps/server/src/mcp/McpHttpServer.ts` + registers the threads MCP toolkit; `apps/web/src/components/AgentsPanel.tsx` + links agents to their child threads; `apps/web/src/components/Sidebar.tsx` + and `apps/web/src/components/LegacySidebar.tsx` hide child threads from the + sidebar. +- `scripts/build-desktop-artifact.ts` (#10): the packaged-bundle + self-containment probe clears an inherited `ELECTRON_RUN_AS_NODE`. +- `apps/server/src/entrypoint.test.ts` (#10): resolves the fixture directory + so the test passes under macOS's symlinked `TMPDIR`. +- `.github/workflows/ci.yml`, `.github/workflows/mobile-fingerprint-check.yml`: + `blacksmith-*-ubuntu-2404` runners become `ubuntu-24.04` and + `blacksmith-*-macos-*` becomes `macos-15` (see CI). + +## Chromeria desktop app + +Chromeria is the fork's desktop build: bundle id `md.toolbox.chromeria`, +Electron userData `~/Library/Application Support/chromeria`, no update feed. +It installs next to upstream "T3 Code (Alpha)" and reads the same T3 home +(`T3CODE_HOME`, else `~/.t3`, state in `/userdata`), so its threads +are the upstream app's threads. + +**Never run Chromeria and T3 Code (Alpha) on the same T3 home at once.** Both +would start a server on one database. Quit one fully (Cmd-Q, not just close +the window) before opening the other. To try Chromeria while the upstream +app keeps running, give Chromeria its own home, which starts with no threads: + +```bash +open -na /Applications/Chromeria.app --env T3CODE_HOME="$HOME/.t3-chromeria" +``` + +Build (Apple Silicon, from a checkout of fork `main`): + +```bash +pnpm install --frozen-lockfile +pnpm dist:desktop:dmg:arm64 # writes release/Chromeria--arm64.dmg +``` + +Install or update (quit Chromeria first): + +```bash +hdiutil attach release/Chromeria-*-arm64.dmg -nobrowse -mountpoint /tmp/chromeria-dmg +rm -rf /Applications/Chromeria.app +ditto /tmp/chromeria-dmg/Chromeria.app /Applications/Chromeria.app +hdiutil detach /tmp/chromeria-dmg +codesign --force --deep --sign - /Applications/Chromeria.app +open /Applications/Chromeria.app +``` + +Gotchas: + +- The local build is unsigned. Without the ad-hoc `codesign` step macOS + reports the app as damaged or refuses to open it. +- Each build has a new ad-hoc signature, so macOS may ask again for + permissions granted to the previous build (screen recording, folders). +- There is no auto-update. Updating is: pull fork `main`, rebuild, + reinstall as above. +- Both apps register the `t3code://` URL scheme, so a browser sign-in + callback may open either app. +- Both apps prefer backend port 3773; whichever starts second takes the next + free port. + +## Routine + +`scripts/fork-rebase.sh` fetches `upstream`, rebases the current branch's +stack onto `upstream/main`, runs the fork check, optionally runs the full +proof, and optionally pushes. It reports conflicts per upstream file and never +force-pushes a published branch without proof passing in the same run. + +The proof runs in a host-neutral environment, because upstream's tests assume +CI's Linux host. On a Mac, run from a T3 thread, about 50 upstream tests +otherwise fail without any fork change: T3 Code desktop leaks +`ELECTRON_RUN_AS_NODE=1` into agent shells, macOS `TMPDIR` sits behind the +`/var` to `/private/var` symlink, and Homebrew's `brew` on `PATH` adds +provider probes. To run the proof by hand: + +```bash +env -u ELECTRON_RUN_AS_NODE TMPDIR="$(cd "$TMPDIR" && pwd -P)/" \ + PATH="$(printf '%s' "$PATH" | tr ':' '\n' | grep -v '^/opt/homebrew' | paste -sd: -)" \ + sh -c 'pnpm install --frozen-lockfile && pnpm exec vp run -r typecheck && pnpm exec vp lint && pnpm exec vp run -r test && pnpm exec vp fmt --check' +``` + +```bash +# Report the stack and what an absorption would replay, without changing anything. +scripts/fork-rebase.sh --dry-run + +# Absorb upstream updates (fetch + rebase + fork check). +scripts/fork-rebase.sh + +# Absorb and run the full fork proof (required before pushing a rebased stack). +scripts/fork-rebase.sh --proof + +# Absorb, prove, and publish (refuses unless --proof passed in the same run; +# uses --force-with-lease, never --force; refuses on main). +scripts/fork-rebase.sh --proof --push +``` + +An absorption, end to end: + +```bash +git fetch origin +git switch -c fork/absorb- origin/main +scripts/fork-rebase.sh --proof --push +# Open a PR to main for CI and review. Do not merge it through GitHub. +# After approval, land the proven branch on main: +git push --force-with-lease=main: origin fork/absorb-:main +``` + +Open topic PRs then rebase onto the new `main` (`git rebase --onto +origin/main `). + +On conflict the script stops at the failing commit, prints the conflicted +upstream files (`git diff --name-only --diff-filter=U`), and exits non-zero +with the rebase left in progress for manual resolution (`git status` to see +the files, resolve, `git rebase --continue`, then re-run the script). Record +the conflicted files and the resolution in the absorption log below and in the +PR. + +Rules: + +- Never `git push --force`. A published rebased branch moves only via + `git push --force-with-lease` after the same proof passed. +- `main` moves only by squash-merging a topic PR, or by the approved + `--force-with-lease` landing of a proven absorption branch. +- Bind proof to the final candidate: any rebase or conflict resolution + invalidates earlier proof, so the proof must run last. + +## CI + +Upstream's jobs run on Blacksmith runners the fork does not have, so they +would queue forever. The fork remaps the PR and `main` workflows (`ci.yml`, +`mobile-fingerprint-check.yml`) to GitHub-hosted runners, which are free for +public repositories. Release, deploy, preview and Windows workflows keep +their Blacksmith labels: they need upstream secrets and must not run here. + +`.github/workflows/fork.yml` runs on every pull request and on pushes to +`main`, alongside `ci.yml`. It adds the `upstream` remote, fetches it, and +runs `scripts/fork-check.sh`, which verifies: + +- the stack on top of the upstream base contains no merge commits, +- the stack is small (at most 20 commits), +- every modification to an upstream-owned file is allowlisted in + `scripts/fork-upstream-edits.txt` (new fork-only files are always fine), +- this document exists. + +## Absorption log + +One entry per absorbed upstream update: exact base and candidate commits, +elapsed time, conflicts by file (or "none"), and proof. + +## Candidate upstream extension points + +Generic seams the fork's planned work (Model Router threads, OpenBot mode) +will need. Each is written so that, if upstream ever adopted it, the stated +fork patch would disappear. Offering any of them upstream is a human decision +and explicitly out of scope for this maintenance work; this list only records +what each would remove. + +- **`parentThreadId` on `thread.create`.** `ThreadCreateCommand` + (`packages/contracts/src/orchestration.ts`) and `ThreadCreatedPayload` carry + no parent link, so Model Router job threads would need a fork patch that + smuggles the parent linkage (via title prefix, metadata, or a parallel + fork-owned map). A real optional `parentThreadId` field would remove that + patch entirely. +- **Thread visibility independent of archive state.** Threads are listed or + hidden by existing lifecycle flags; there is no "hidden from the sidebar + but reachable from the Agents panel" concept, so Model Router job threads + would need a fork patch in the sidebar query plus the Agents panel + (`apps/web/src/components/AgentsPanel.tsx`). A first-class visibility flag + would remove both edits. +- **Right-panel tab registry.** `RightPanelSurface` + (`apps/web/src/rightPanelStore.ts`) is a closed union and + `apps/web/src/components/RightPanelTabs.tsx` renders a fixed tab set, so an + OpenBot Computer tab would require editing both upstream files. A registry + (fork registers a tab kind without touching the union or renderer) would + remove those edits. +- **Thread persona/owner marker.** `ThreadCreatedPayload` carries no agent + identity, so a Bot-as-thread-with-persona would need a fork patch to attach + and render it. An optional upstream persona field would remove that patch. diff --git a/scripts/fork-check.sh b/scripts/fork-check.sh new file mode 100755 index 000000000000..9eeab392e583 --- /dev/null +++ b/scripts/fork-check.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# fork-check.sh: verify the fork stack model on the current branch. +# Usage: scripts/fork-check.sh [--base ] [--upstream ] [--max-commits ] +# Exits 0 when the stack is a small, merge-free stack whose upstream-file +# modifications are all allowlisted in scripts/fork-upstream-edits.txt. +set -euo pipefail + +BASE="" +UPSTREAM="upstream" +MAX_COMMITS=20 + +while [[ $# -gt 0 ]]; do + case "$1" in + --base) BASE="$2"; shift 2 ;; + --upstream) UPSTREAM="$2"; shift 2 ;; + --max-commits) MAX_COMMITS="$2"; shift 2 ;; + -h|--help) + sed -n '2,6p' "$0" + exit 0 + ;; + *) + echo "fork-check: unknown argument: $1" >&2 + exit 2 + ;; + esac +done + +ROOT="$(git rev-parse --show-toplevel)" +cd "$ROOT" + +if [[ ! -f docs/fork.md ]]; then + echo "fork-check: FAIL: docs/fork.md is missing." >&2 + exit 1 +fi + +if [[ -z "$BASE" ]]; then + if git rev-parse --verify --quiet "$UPSTREAM/main" >/dev/null; then + BASE="$(git merge-base HEAD "$UPSTREAM/main")" + else + BASE="$(git merge-base HEAD origin/main)" + fi +fi + +echo "fork-check: base: $(git log --oneline -1 "$BASE")" + +MERGES="$(git log --merges --format='%h %s' "$BASE"..HEAD)" +if [[ -n "$MERGES" ]]; then + echo "fork-check: FAIL: merge commits in the fork stack (rebase, do not merge):" >&2 + echo "$MERGES" >&2 + exit 1 +fi + +COUNT="$(git rev-list --count "$BASE"..HEAD)" +echo "fork-check: stack: $COUNT commit(s) on top of base (limit $MAX_COMMITS)." +git log --oneline "$BASE"..HEAD || true +if [[ "$COUNT" -gt "$MAX_COMMITS" ]]; then + echo "fork-check: FAIL: stack exceeds $MAX_COMMITS commits; split the work." >&2 + exit 1 +fi + +# Files changed across the stack, classified against the base: a changed path +# that does not exist in the base is a new fork-only file (always fine). +# Anything else (modified or deleted upstream file) must be allowlisted. +ALLOW="$ROOT/scripts/fork-upstream-edits.txt" +ALLOWLIST="$(grep -v '^\s*#' "$ALLOW" | grep -v '^\s*$' || true)" +FAIL=0 +while IFS= read -r path; do + [[ -z "$path" ]] && continue + if git cat-file -e "$BASE:$path" 2>/dev/null; then + if printf '%s\n' "$ALLOWLIST" | grep -qxF "$path"; then + echo "fork-check: modified (allowlisted): $path" + else + echo "fork-check: FAIL: upstream file modified but not allowlisted: $path" >&2 + FAIL=1 + fi + else + echo "fork-check: new fork file: $path" + fi +done < <(git diff --name-only "$BASE"..HEAD) + +if [[ "$FAIL" -ne 0 ]]; then + echo "fork-check: FAIL: list the file in scripts/fork-upstream-edits.txt and docs/fork.md, or move the change to a new file." >&2 + exit 1 +fi + +echo "fork-check: OK." diff --git a/scripts/fork-maintenance.test.ts b/scripts/fork-maintenance.test.ts new file mode 100644 index 000000000000..41ea31c64b44 --- /dev/null +++ b/scripts/fork-maintenance.test.ts @@ -0,0 +1,148 @@ +// Fork maintenance routine: drives the real scripts against tiny git fixtures. +// @effect-diagnostics nodeBuiltinImport:off - Drives the real bash/sh routines against throwaway git fixtures. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { describe, expect, it } from "vite-plus/test"; + +const scriptsDir = import.meta.dirname; +const forkCheck = NodePath.join(scriptsDir, "fork-check.sh"); +const forkRebase = NodePath.join(scriptsDir, "fork-rebase.sh"); + +function git(cwd: string, ...args: Array): string { + return NodeChildProcess.execFileSync("git", args, { cwd, encoding: "utf8" }); +} + +function makeFixture(): string { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-fork-check-")); + git(root, "init", "-q", "-b", "work"); + git(root, "config", "user.email", "fork-check@test"); + git(root, "config", "user.name", "fork-check"); + NodeFS.mkdirSync(NodePath.join(root, "docs"), { recursive: true }); + NodeFS.mkdirSync(NodePath.join(root, "scripts"), { recursive: true }); + NodeFS.writeFileSync(NodePath.join(root, "docs", "fork.md"), "# Fork maintenance\n"); + NodeFS.writeFileSync( + NodePath.join(root, "scripts", "fork-upstream-edits.txt"), + "# allowlist\nscripts/fork-upstream-edits.txt\n", + ); + NodeFS.writeFileSync(NodePath.join(root, "upstream-owned.txt"), "base\n"); + git(root, "add", "-A"); + git(root, "commit", "-qm", "base"); + return root; +} + +function runCheck(cwd: string, ...args: Array): { status: number; output: string } { + try { + const output = NodeChildProcess.execFileSync("bash", [forkCheck, ...args], { + cwd, + encoding: "utf8", + }); + return { status: 0, output }; + } catch (error) { + const result = error as { status?: number; stdout?: string; stderr?: string }; + return { + status: result.status ?? 1, + output: `${result.stdout ?? ""}${result.stderr ?? ""}`, + }; + } +} + +function baseOf(root: string): string { + return git(root, "rev-parse", "HEAD").trim(); +} + +describe("fork-check", () => { + it("passes for fork-only new files on top of the base", () => { + const root = makeFixture(); + const base = baseOf(root); + NodeFS.writeFileSync(NodePath.join(root, "docs", "new-fork-doc.md"), "fork only\n"); + git(root, "add", "-A"); + git(root, "commit", "-qm", "fork: new file"); + const result = runCheck(root, "--base", base); + expect(result.status).toBe(0); + expect(result.output).toContain("fork-check: OK."); + }); + + it("fails on unallowlisted upstream edits and passes once allowlisted", () => { + const root = makeFixture(); + const base = baseOf(root); + NodeFS.writeFileSync(NodePath.join(root, "upstream-owned.txt"), "forked\n"); + git(root, "add", "-A"); + git(root, "commit", "-qm", "fork: touch upstream file"); + const denied = runCheck(root, "--base", base); + expect(denied.status).toBe(1); + expect(denied.output).toContain("not allowlisted: upstream-owned.txt"); + + NodeFS.appendFileSync( + NodePath.join(root, "scripts", "fork-upstream-edits.txt"), + "upstream-owned.txt\n", + ); + git(root, "add", "-A"); + git(root, "commit", "-qm", "fork: allowlist the edit"); + const allowed = runCheck(root, "--base", base); + expect(allowed.status).toBe(0); + expect(allowed.output).toContain("modified (allowlisted): upstream-owned.txt"); + }); + + it("fails on merge commits in the stack", () => { + const root = makeFixture(); + const base = baseOf(root); + git(root, "checkout", "-qb", "side"); + NodeFS.writeFileSync(NodePath.join(root, "side.txt"), "side\n"); + git(root, "add", "-A"); + git(root, "commit", "-qm", "side"); + git(root, "checkout", "-q", "work"); + NodeFS.writeFileSync(NodePath.join(root, "work.txt"), "work\n"); + git(root, "add", "-A"); + git(root, "commit", "-qm", "work"); + git(root, "merge", "--no-ff", "-m", "merge side", "side"); + const result = runCheck(root, "--base", base); + expect(result.status).toBe(1); + expect(result.output).toContain("merge commits in the fork stack"); + }); + + it("fails when docs/fork.md is missing", () => { + const root = makeFixture(); + const base = baseOf(root); + NodeFS.rmSync(NodePath.join(root, "docs", "fork.md")); + git(root, "add", "-A"); + git(root, "commit", "-qm", "fork: drop the doc"); + const result = runCheck(root, "--base", base); + expect(result.status).toBe(1); + expect(result.output).toContain("docs/fork.md is missing"); + }); +}); + +describe("fork-rebase safety rules", () => { + it("refuses to push without proof in the same run", () => { + const root = makeFixture(); + try { + NodeChildProcess.execFileSync("bash", [forkRebase, "--push"], { + cwd: root, + encoding: "utf8", + }); + expect.unreachable("fork-rebase --push without --proof must fail"); + } catch (error) { + const result = error as { status?: number; stderr?: string }; + expect(result.status).toBe(1); + expect(result.stderr).toContain("refusing to push without proof"); + } + }); + + it("refuses to push main directly", () => { + const root = makeFixture(); + git(root, "checkout", "-qb", "main"); + try { + NodeChildProcess.execFileSync("bash", [forkRebase, "--proof", "--push"], { + cwd: root, + encoding: "utf8", + }); + expect.unreachable("fork-rebase --push on main must fail"); + } catch (error) { + const result = error as { status?: number; stderr?: string }; + expect(result.status).toBe(1); + expect(result.stderr).toContain("refusing to push main directly"); + } + }); +}); diff --git a/scripts/fork-rebase.sh b/scripts/fork-rebase.sh new file mode 100755 index 000000000000..dac55e0d7c67 --- /dev/null +++ b/scripts/fork-rebase.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# fork-rebase.sh: absorb upstream updates into the fork stack. +# Usage: scripts/fork-rebase.sh [--upstream ] [--dry-run] [--proof] [--push] +# --dry-run report the stack and pending upstream commits without changing anything. +# --proof run the full fork proof after a successful rebase. +# --push publish the branch (requires --proof in the same run, uses +# --force-with-lease, never --force, and refuses on main). +# On conflict the rebase is left in progress and the conflicted upstream files +# are reported; resolve them, run `git rebase --continue`, then re-run this script. +set -euo pipefail + +UPSTREAM="upstream" +DRY_RUN=0 +PROOF=0 +PUSH=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --upstream) UPSTREAM="$2"; shift 2 ;; + --dry-run) DRY_RUN=1; shift ;; + --proof) PROOF=1; shift ;; + --push) PUSH=1; shift ;; + -h|--help) + sed -n '2,10p' "$0" + exit 0 + ;; + *) + echo "fork-rebase: unknown argument: $1" >&2 + exit 2 + ;; + esac +done + +if [[ "$PUSH" -eq 1 && "$PROOF" -ne 1 ]]; then + echo "fork-rebase: refusing to push without proof passing in the same run; re-run with --proof --push." >&2 + exit 1 +fi + +BRANCH="$(git branch --show-current)" +if [[ "$PUSH" -eq 1 && "$BRANCH" == "main" ]]; then + echo "fork-rebase: refusing to push main directly; push an absorption branch, then land it as docs/fork.md describes." >&2 + exit 1 +fi + +ROOT="$(git rev-parse --show-toplevel)" +cd "$ROOT" +START="$SECONDS" + +echo "fork-rebase: fetching $UPSTREAM..." +git fetch "$UPSTREAM" main + +TARGET="$(git rev-parse "$UPSTREAM/main")" +BASE="$(git merge-base HEAD "$TARGET")" +echo "fork-rebase: branch: $BRANCH" +echo "fork-rebase: upstream: $(git log --oneline -1 "$TARGET")" +echo "fork-rebase: base: $(git log --oneline -1 "$BASE")" +echo "fork-rebase: stack ($(( $(git rev-list --count "$BASE"..HEAD) )) commit(s)):" +git log --oneline "$BASE"..HEAD || true +PENDING="$(git rev-list --count "$BASE".."$TARGET")" +echo "fork-rebase: pending upstream commits: $PENDING" +if [[ "$PENDING" -gt 0 ]]; then + # `|| true`: head closing the pipe is not a failure under pipefail. + git log --oneline "$BASE".."$TARGET" | head -n 20 || true +fi + +if [[ "$DRY_RUN" -eq 1 ]]; then + echo "fork-rebase: dry run; nothing changed." + exit 0 +fi + +if [[ "$PENDING" -eq 0 ]]; then + echo "fork-rebase: already up to date; nothing to replay." +else + echo "fork-rebase: rebasing stack onto $UPSTREAM/main..." + if ! git rebase "$UPSTREAM/main"; then + echo "fork-rebase: CONFLICT. The rebase is left in progress." >&2 + echo "fork-rebase: conflicted upstream files:" >&2 + git diff --name-only --diff-filter=U | while IFS= read -r f; do + echo "fork-rebase: - $f" >&2 + done + echo "fork-rebase: stopped at: $(git log --oneline -1 2>/dev/null || echo '(unknown)')" >&2 + echo "fork-rebase: resolve each file (hint: git log $UPSTREAM/main -- ), run \`git rebase --continue\`, then re-run this script." >&2 + exit 1 + fi + echo "fork-rebase: rebase clean (no conflicts)." +fi + +echo "fork-rebase: running fork check..." +bash "$ROOT/scripts/fork-check.sh" --upstream "$UPSTREAM" + +if [[ "$PROOF" -eq 1 ]]; then + echo "fork-rebase: running full fork proof..." + # Run the proof in a host-neutral subshell. Upstream tests assume CI's Linux + # host: an inherited ELECTRON_RUN_AS_NODE (T3 Code desktop leaks it into + # agent shells) changes spawned command environments, macOS's /var -> + # /private/var TMPDIR symlink breaks path equality, and a Homebrew `brew` + # on PATH adds extra provider probes. + ( + unset ELECTRON_RUN_AS_NODE + TMPDIR="$(cd "${TMPDIR:-/tmp}" && pwd -P)/" + PATH="$(printf '%s' "$PATH" | tr ':' '\n' | grep -v '^/opt/homebrew' | paste -sd: -)" + export TMPDIR PATH + pnpm install --frozen-lockfile + pnpm exec vp run -r typecheck + pnpm exec vp lint + pnpm exec vp run -r test + pnpm exec vp fmt --check + ) + echo "fork-rebase: proof passed." +fi + +if [[ "$PUSH" -eq 1 ]]; then + if git rev-parse --verify --quiet "refs/remotes/origin/$BRANCH" >/dev/null; then + echo "fork-rebase: publishing rebased stack with --force-with-lease..." + git push --force-with-lease origin "$BRANCH" + else + echo "fork-rebase: publishing new branch..." + git push -u origin "$BRANCH" + fi +fi + +echo "fork-rebase: done in $((SECONDS - START))s." diff --git a/scripts/fork-upstream-edits.txt b/scripts/fork-upstream-edits.txt new file mode 100644 index 000000000000..9bedb2a279dc --- /dev/null +++ b/scripts/fork-upstream-edits.txt @@ -0,0 +1,41 @@ +# Canonical allowlist of upstream-owned files the fork may modify. +# One repo-relative path per line. Lines starting with # and blank lines are ignored. +# New fork-only files never need listing here; only modifications (or deletions) +# of files that already exist on the upstream base must be listed. +# scripts/fork-check.sh enforces this list; keep "Upstream edits" in docs/fork.md +# describing the same files. + +# Chromeria branding (#12, #13) +apps/desktop/package.json +apps/desktop/src/app/DesktopAppIdentity.test.ts +apps/desktop/src/app/DesktopEnvironment.ts +apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +scripts/build-desktop-artifact.ts +scripts/build-desktop-artifact.test.ts +scripts/lib/brand-assets.ts +scripts/lib/brand-assets.test.ts + +# Chromeria name in the web UI (#14) +apps/web/index.html +apps/web/src/bootstrap.test.ts +apps/web/src/branding.test.ts +apps/web/src/branding.ts +apps/web/src/bundledDev.test.ts +apps/web/src/components/T3Wordmark.tsx +apps/web/src/components/chat/MessagesTimeline.tsx +apps/web/src/components/onboarding/WelcomeWizard.tsx +apps/web/src/components/settings/IntegrationsSettings.tsx +apps/web/src/components/settings/ThemePreviewCircles.tsx +apps/web/src/components/sidebar/SidebarChrome.tsx +apps/web/src/lib/bootError.ts + +# Child threads in the Agents panel and threads MCP toolkit (#8, #10) +apps/server/src/entrypoint.test.ts +apps/server/src/mcp/McpHttpServer.ts +apps/web/src/components/AgentsPanel.tsx +apps/web/src/components/LegacySidebar.tsx +apps/web/src/components/Sidebar.tsx + +# CI on GitHub-hosted runners (#6) +.github/workflows/ci.yml +.github/workflows/mobile-fingerprint-check.yml