Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .fork/customizations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3502,3 +3502,67 @@
- apps/web/src/__fork_guards__/forkNewAgentDraft.test.ts
- apps/web/src/custom/newAgentDraft.test.ts
- apps/web/src/custom/useNewAgentDraft.test.ts

- id: server-local-thread-branch
intent: >
A thread bootstrapped onto an existing checkout with no branch named by
the client records the branch that checkout is actually on, so web and
desktop rows show branch identity for phone-created threads.

Upstream mobile records a branch only when the picker is tapped; its
untouched "Current checkout" default displays the current branch but
sends null, and the App Store build is what the phone runs — the fork's
own mobile fence (mobile-current-checkout-branch) never reaches it because
the fork ships no mobile builds. The server has the checkout and the
status service, so it fills the label in at bootstrap time instead of
waiting on a client release; this covers every client, old or new.

Precedence: an explicit client branch always wins; a worktree being
prepared names its own branch through that flow and is left alone; an
existing worktree path is the checkout to read, the project root is the
fallback. Source is GitWorkflowService.localStatus (the same live value
the sidebar and PR badge compare against), never listRefs' cache-served
current flag. A detached HEAD, a non-repository project, a temporary
worktree placeholder (t3/…, which the checkpoint and provider reactors
also refuse to adopt), or a status failure yields null with a warning
rather than fabricating a branch or failing the turn start.

This is a real branch record, not a display label: a filled thread
crosses every gate upstream keys on thread.branch. The composer shows
the "Branch changed" banner with its Restore button when the shared
checkout moves on; the sidebar row leases a live git status; the PR
reactor resolves a pull request for the branch and the badge attributes
it. All of that is upstream's own semantics for a branch-recorded local
thread and is the point — a phone thread should behave like the same
thread started on the desktop.

Two accepted trade-offs. The status read sits on the turn-start path
before thread.create dispatches; it is the same cache-served status the
open desktop already polls for every labelled row on that checkout, so
it is warm in practice, and a cold read on a large dirty repo is the
cost of the label being present from the first event rather than
arriving in a second one. And the fill-in is one-shot: a create that
lands while another git operation holds the index stays null (web
self-heals on its next send through the mismatch path, mobile does
not), because a repeatable backfill would be a second mechanism for a
race that is rare in practice.

The policy lives in its own server file (resolveBootstrapThreadBranch),
unit-tested next to it; ws.ts carries only the import and the call at the
bootstrap create. Only that call site is wired: a bare thread.create
(web's own new-thread path, which already names its branch) stays as the
client sent it by product choice, not because the resolver could not run
there — the command carries projectId and worktreePath, so wiring it is
one more call if a future client needs it.
tier: 4
files:
- apps/server/src/git/resolveBootstrapThreadBranch.ts
- apps/server/src/git/resolveBootstrapThreadBranch.test.ts
shadows: []
watch:
- apps/server/src/ws.ts
- apps/server/src/server.test.ts
verify:
- apps/web/src/__fork_guards__/serverLocalThreadBranch.test.ts
- apps/server/src/git/resolveBootstrapThreadBranch.test.ts
- apps/server/src/server.test.ts
144 changes: 144 additions & 0 deletions apps/server/src/git/resolveBootstrapThreadBranch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { assert, describe, it } from "@effect/vitest";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import { ProjectId, ThreadId } from "@t3tools/contracts";
import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
import { vi } from "vite-plus/test";

import {
type BootstrapThreadBranchDeps,
type BootstrapThreadBranchInput,
resolveBootstrapThreadBranch,
} from "./resolveBootstrapThreadBranch.ts";

class TestFailure extends Data.TaggedError("TestFailure")<{ readonly detail: string }> {}

type Deps = BootstrapThreadBranchDeps<TestFailure, TestFailure>;

const projectId = ProjectId.make("project-1");

const makeInput = (
overrides: Partial<BootstrapThreadBranchInput> = {},
): BootstrapThreadBranchInput => ({
threadId: ThreadId.make("thread-1"),
projectId,
branch: null,
worktreePath: null,
preparingWorktree: false,
...overrides,
});

const makeDeps = (overrides: Partial<Deps> = {}) => {
const getProjectShellById = vi.fn(
(requested: ProjectId): ReturnType<Deps["getProjectShellById"]> =>
Effect.succeed(
requested === projectId ? Option.some({ workspaceRoot: "/repo" }) : Option.none(),
),
);
const localStatus = vi.fn((_input: { readonly cwd: string }): ReturnType<Deps["localStatus"]> =>
Effect.succeed({ refName: "feature/from-phone" }),
);
return {
getProjectShellById,
localStatus,
deps: { getProjectShellById, localStatus, ...overrides } satisfies Deps,
};
};

describe("resolveBootstrapThreadBranch", () => {
it.effect("keeps an explicit client branch without touching git", () =>
Effect.gen(function* () {
const { deps, getProjectShellById, localStatus } = makeDeps();
const branch = yield* resolveBootstrapThreadBranch(makeInput({ branch: "picked" }), deps);
assert.strictEqual(branch, "picked");
assert.strictEqual(getProjectShellById.mock.calls.length, 0);
assert.strictEqual(localStatus.mock.calls.length, 0);
}),
);

it.effect("leaves a worktree being prepared to name its own branch", () =>
Effect.gen(function* () {
const { deps, localStatus } = makeDeps();
const branch = yield* resolveBootstrapThreadBranch(
makeInput({ preparingWorktree: true }),
deps,
);
assert.isNull(branch);
assert.strictEqual(localStatus.mock.calls.length, 0);
}),
);

it.effect("reads the project root when the thread has no worktree", () =>
Effect.gen(function* () {
const { deps, localStatus } = makeDeps();
const branch = yield* resolveBootstrapThreadBranch(makeInput(), deps);
assert.strictEqual(branch, "feature/from-phone");
assert.deepEqual(
localStatus.mock.calls.map((call) => call[0]),
[{ cwd: "/repo" }],
);
}),
);

it.effect("reads an existing worktree instead of the project root", () =>
Effect.gen(function* () {
const { deps, getProjectShellById, localStatus } = makeDeps();
const branch = yield* resolveBootstrapThreadBranch(
makeInput({ worktreePath: "/repo-wt" }),
deps,
);
assert.strictEqual(branch, "feature/from-phone");
assert.strictEqual(getProjectShellById.mock.calls.length, 0);
assert.deepEqual(
localStatus.mock.calls.map((call) => call[0]),
[{ cwd: "/repo-wt" }],
);
}),
);

it.effect("stays null for an unknown project", () =>
Effect.gen(function* () {
const { deps, localStatus } = makeDeps();
const branch = yield* resolveBootstrapThreadBranch(
makeInput({ projectId: ProjectId.make("project-missing") }),
deps,
);
assert.isNull(branch);
assert.strictEqual(localStatus.mock.calls.length, 0);
}),
);

it.effect("stays null on a detached HEAD or non-repository checkout", () =>
Effect.gen(function* () {
const { deps } = makeDeps({ localStatus: () => Effect.succeed({ refName: null }) });
assert.isNull(yield* resolveBootstrapThreadBranch(makeInput(), deps));
}),
);

it.effect("never adopts a temporary worktree placeholder branch", () =>
Effect.gen(function* () {
const { deps } = makeDeps({
localStatus: () =>
Effect.succeed({ refName: buildTemporaryWorktreeBranchName(() => "0123abcd") }),
});
assert.isNull(yield* resolveBootstrapThreadBranch(makeInput({ worktreePath: "/wt" }), deps));
}),
);

it.effect("stays null when the status lookup fails or defects", () =>
Effect.gen(function* () {
const failing = makeDeps({
localStatus: () => Effect.fail(new TestFailure({ detail: "git exploded" })),
});
assert.isNull(yield* resolveBootstrapThreadBranch(makeInput(), failing.deps));
const dying = makeDeps({ localStatus: () => Effect.die(new Error("git exploded")) });
assert.isNull(yield* resolveBootstrapThreadBranch(makeInput(), dying.deps));
const lookupFailing = makeDeps({
getProjectShellById: () =>
Effect.fail(new TestFailure({ detail: "projection unavailable" })),
});
assert.isNull(yield* resolveBootstrapThreadBranch(makeInput(), lookupFailing.deps));
}),
);
});
73 changes: 73 additions & 0 deletions apps/server/src/git/resolveBootstrapThreadBranch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Which branch a bootstrap-created thread records — see
* `.fork/customizations.yaml#server-local-thread-branch`.
*
* Upstream mobile records a branch only when its picker is tapped, so the
* untouched "Current checkout" default arrives as null and the thread never
* shows a branch on any other surface. The server has the checkout and the
* status service, so it fills the label in at creation instead of waiting on
* a client release.
*
* Precedence: an explicit client branch always wins; a worktree being
* prepared names its own branch through that flow; an existing worktree path
* is the checkout to read, the project root is the fallback. The source is
* the live local status — the same value the sidebar and PR badge compare
* against — never listRefs' cache-served flag. A detached HEAD, a
* non-repository project, a missing project, or a status failure yields null
* with a warning rather than fabricating a branch or failing the create.
*
* A temporary worktree placeholder (`t3/…`) is never adopted, matching the
* checkpoint and provider reactors: recording it would make this thread's
* first turn rename a worktree it does not own.
*/
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import type { ProjectId, ThreadId } from "@t3tools/contracts";
import { isTemporaryWorktreeBranch } from "@t3tools/shared/git";

export interface BootstrapThreadBranchInput {
readonly threadId: ThreadId;
readonly projectId: ProjectId;
readonly branch: string | null;
readonly worktreePath: string | null;
/** True when the same bootstrap also prepares a new worktree. */
readonly preparingWorktree: boolean;
}

export interface BootstrapThreadBranchDeps<ProjectError = never, StatusError = never> {
readonly getProjectShellById: (
projectId: ProjectId,
) => Effect.Effect<Option.Option<{ readonly workspaceRoot: string }>, ProjectError>;
readonly localStatus: (input: {
readonly cwd: string;
}) => Effect.Effect<{ readonly refName: string | null }, StatusError>;
}

export const resolveBootstrapThreadBranch = <ProjectError, StatusError>(
input: BootstrapThreadBranchInput,
deps: BootstrapThreadBranchDeps<ProjectError, StatusError>,
): Effect.Effect<string | null> => {
if (input.branch !== null || input.preparingWorktree) {
return Effect.succeed(input.branch);
}
return Effect.gen(function* () {
const cwd =
input.worktreePath ??
Option.getOrNull(
Option.map(yield* deps.getProjectShellById(input.projectId), (p) => p.workspaceRoot),
);
if (cwd === null) return null;
const refName = (yield* deps.localStatus({ cwd })).refName;
return refName === null || isTemporaryWorktreeBranch(refName) ? null : refName;
}).pipe(
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.interrupt
: Effect.logWarning("bootstrap thread create could not resolve checkout branch", {
threadId: input.threadId,
cause: Cause.pretty(cause),
}).pipe(Effect.as(null)),
),
);
};
88 changes: 88 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10751,6 +10751,94 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

/* fork:begin server-local-thread-branch — see .fork/customizations.yaml#server-local-thread-branch
The policy is unit-tested next to resolveBootstrapThreadBranch; this
pins only the seam: a bootstrap create with no branch reaches the engine
with the live checkout's branch. */
it.effect("fills in the checked-out branch when a bootstrap thread names none", () =>
Comment thread
NoahHendrickson marked this conversation as resolved.
Effect.gen(function* () {
const dispatchedCommands: Array<OrchestrationCommand> = [];
yield* buildAppUnderTest({
layers: {
vcsDriver: {
isInsideWorkTree: () => Effect.succeed(true),
},
gitManager: {
localStatus: () =>
Effect.succeed({
isRepo: true,
hasPrimaryRemote: true,
isDefaultRef: false,
refName: "feature/from-phone",
hasWorkingTreeChanges: false,
workingTree: { files: [], insertions: 0, deletions: 0 },
}),
},
projectionSnapshotQuery: {
getProjectShellById: () =>
Effect.succeed(
Option.some({
id: defaultProjectId,
title: "Project",
workspaceRoot: "/tmp/project",
defaultModelSelection: null,
scripts: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
}),
),
},
orchestrationEngine: {
dispatch: (command) =>
Effect.sync(() => {
dispatchedCommands.push(command);
return { sequence: dispatchedCommands.length };
}),
readEvents: () => Stream.empty,
},
},
});

const createdAt = "2026-01-01T00:00:00.000Z";
const wsUrl = yield* getWsServerUrl("/ws");
yield* Effect.scoped(
withWsRpcClient(wsUrl, (client) =>
client[ORCHESTRATION_WS_METHODS.dispatchCommand]({
type: "thread.turn.start",
commandId: CommandId.make("cmd-bootstrap-checkout-branch"),
threadId: ThreadId.make("thread-bootstrap-checkout-branch"),
message: {
messageId: MessageId.make("msg-bootstrap-checkout-branch"),
role: "user",
text: "hello",
attachments: [],
},
modelSelection: defaultModelSelection,
runtimeMode: "full-access",
interactionMode: "default",
bootstrap: {
createThread: {
projectId: defaultProjectId,
title: "Bootstrap Thread",
modelSelection: defaultModelSelection,
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: null,
createdAt,
},
},
createdAt,
}),
),
);

const created = dispatchedCommands.find((command) => command.type === "thread.create");
assert.equal(created?.branch, "feature/from-phone");
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
/* fork:end server-local-thread-branch */

it.effect("records setup-script failures without aborting bootstrap turn start", () =>
Effect.gen(function* () {
const dispatchedCommands: Array<OrchestrationCommand> = [];
Expand Down
Loading
Loading