Repository navigation
fix(server): record the checked-out branch on threads created without one #124
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
7978c58
fix(server): record the checked-out branch on threads created without…
xxxxxxxxxxxxx e454fee
refactor(server): move the bootstrap branch policy into its own module
xxxxxxxxxxxxx ebd1ec3
fix(server): never adopt a temporary worktree placeholder as a thread…
xxxxxxxxxxxxx File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
144 changes: 144 additions & 0 deletions
144
apps/server/src/git/resolveBootstrapThreadBranch.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)); | ||
| }), | ||
| ); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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)), | ||
| ), | ||
| ); | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.