diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 666f32556bf..f6437fb1fff 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -136,6 +136,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { setDockIcon: () => Effect.void, getAppMetrics: Effect.succeed([]), appendCommandLineSwitch: () => Effect.void, + removeCommandLineSwitch: () => Effect.void, onBeforeQuitForUpdate: () => Effect.void, on: () => Effect.void as any, } satisfies ElectronApp.ElectronApp["Service"]); diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index ea2a9b19eff..7219c103425 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -673,18 +673,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer () => providerOptionsConfigurationLabel(providerOptionDescriptors), [providerOptionDescriptors], ); - const modelMenuActions = useMemo( - () => { - const actions = buildModelMenuActions(providerGroups, currentModelSelection); - if (!currentUsageNote) return actions; - return actions.map((action) => - action.subtitle === undefined - ? action - : { ...action, subtitle: `${action.subtitle} · ${currentUsageNote}` }, - ); - }, - [providerGroups, currentModelSelection, currentUsageNote], - ); + const modelMenuActions = useMemo(() => { + const actions = buildModelMenuActions(providerGroups, currentModelSelection); + if (!currentUsageNote) return actions; + return actions.map((action) => + action.subtitle === undefined + ? action + : { ...action, subtitle: `${action.subtitle} · ${currentUsageNote}` }, + ); + }, [providerGroups, currentModelSelection, currentUsageNote]); // ── Options menu ───────────────────────────────────────── const optionsMenuActions = useMemo( diff --git a/apps/server/src/checkpointing/CheckpointCaptureBackoff.test.ts b/apps/server/src/checkpointing/CheckpointCaptureBackoff.test.ts index ab1b3351a97..ad454d741da 100644 --- a/apps/server/src/checkpointing/CheckpointCaptureBackoff.test.ts +++ b/apps/server/src/checkpointing/CheckpointCaptureBackoff.test.ts @@ -47,6 +47,7 @@ function makeAlwaysTimingOutRegistry( kind: "git" as const, rootPath: CWD, metadataPath: `${CWD}/.git`, + bare: false, freshness: { source: "cache" as const, checkedAt: 0 }, }, driver: { checkpoints } as unknown as VcsDriver.VcsDriver["Service"], diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 28b4f33f97c..3adcbcf4f90 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -1,6 +1,8 @@ import { assert, describe, expect, it, vi } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import { VcsRepositoryDetectionError } from "@t3tools/contracts"; @@ -8,6 +10,7 @@ import * as GitManager from "./GitManager.ts"; import * as GitWorkflowService from "./GitWorkflowService.ts"; import * as ProjectLifecycleScriptRunner from "../project/ProjectLifecycleScriptRunner.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as VcsDriver from "../vcs/VcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; const lifecycleScriptRunnerMock = Layer.mock( @@ -19,19 +22,46 @@ const lifecycleScriptRunnerMock = Layer.mock( function makeLayer(input: { readonly detect: VcsDriverRegistry.VcsDriverRegistry["Service"]["detect"]; + readonly resolve?: VcsDriverRegistry.VcsDriverRegistry["Service"]["resolve"]; + readonly driver?: Record; }) { return GitWorkflowService.layer.pipe( Layer.provide( Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ detect: input.detect, + ...(input.resolve ? { resolve: input.resolve } : {}), }), ), - Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), + Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)(input.driver ?? {})), Layer.provide(Layer.mock(GitManager.GitManager)({})), Layer.provide(lifecycleScriptRunnerMock), ); } +const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); + +/** + * A repository with no checkout of its own — the shape a bare worktree source + * repo (or a repo whose `core.bare` says so) detects as. + */ +function bareHandle(cwd: string): VcsDriverRegistry.VcsDriverHandle { + return { + kind: "git", + repository: { + kind: "git", + rootPath: `${cwd}/.git`, + metadataPath: `${cwd}/.git`, + bare: true, + freshness: { + source: "live-local", + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + }, + driver: {} as unknown as VcsDriver.VcsDriver["Service"], + }; +} + describe("GitWorkflowService", () => { it.effect("returns an empty local status when no VCS repository is detected", () => Effect.gen(function* () { @@ -199,4 +229,116 @@ describe("GitWorkflowService", () => { ), ); }); + + describe("bare repositories", () => { + it.effect("creates a worktree from a bare repository", () => { + // The service builds driver effects eagerly, so execution has to be + // recorded from inside the effect rather than from a call count. + let ran = false; + const createWorktree = () => + Effect.sync(() => { + ran = true; + return { worktree: { path: "/worktrees/feature", refName: "feature" } }; + }); + + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const result = yield* workflow.createWorktree({ + cwd: "/bare-repo", + refName: "main", + newRefName: "feature", + path: null, + }); + + assert.deepStrictEqual(result.worktree, { + path: "/worktrees/feature", + refName: "feature", + }); + assert.isTrue(ran); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.succeed(bareHandle("/bare-repo")), + resolve: () => Effect.succeed(bareHandle("/bare-repo")), + driver: { createWorktree }, + }), + ), + ); + }); + + it.effect("fetches into a bare repository", () => { + let ran = false; + const fetchRemote = () => + Effect.sync(() => { + ran = true; + }); + + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + yield* workflow.fetchRemote({ cwd: "/bare-repo", remoteName: "origin" }); + + assert.isTrue(ran); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.succeed(bareHandle("/bare-repo")), + resolve: () => Effect.succeed(bareHandle("/bare-repo")), + driver: { fetchRemote }, + }), + ), + ); + }); + + it.effect("rejects a checkout-dependent command with an actionable reason", () => { + let ran = false; + const switchRef = () => + Effect.sync(() => { + ran = true; + return { refName: "main" }; + }); + + return Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const error = yield* workflow + .switchRef({ cwd: "/bare-repo", refName: "main" }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "GitCommandError", + operation: "GitWorkflowService.switchRef", + command: "vcs-route", + cwd: "/bare-repo", + }); + expect(error.detail).toContain("needs a working tree"); + expect(error.detail).toContain("bare Git repository"); + // The gate must short-circuit before the driver command runs. + assert.isFalse(ran); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.succeed(bareHandle("/bare-repo")), + resolve: () => Effect.succeed(bareHandle("/bare-repo")), + driver: { switchRef }, + }), + ), + ); + }); + + it.effect("reports a bare repository as having no working tree status", () => + Effect.gen(function* () { + const workflow = yield* GitWorkflowService.GitWorkflowService; + const status = yield* workflow.localStatus({ cwd: "/bare-repo" }); + + assert.equal(status.isRepo, false); + assert.equal(status.hasWorkingTreeChanges, false); + }).pipe( + Effect.provide( + makeLayer({ + detect: () => Effect.succeed(bareHandle("/bare-repo")), + resolve: () => Effect.succeed(bareHandle("/bare-repo")), + }), + ), + ), + ); + }); }); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index d8c210da3a1..7e0ca2fb92b 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -173,9 +173,20 @@ export const make = Effect.gen(function* () { const gitManager = yield* GitManager.GitManager; const lifecycleScriptRunner = yield* ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner; + /** + * Bare repositories have no checkout, but stay valid sources for ref, fetch, + * and `worktree add` plumbing — which is exactly what starting a thread needs, + * since the thread then runs in the worktree it just created. Such routes opt + * in with `allowBare: true`; everything that touches a checkout keeps the + * default and reports this reason instead of a blanket routing failure. + */ + const bareRepositoryDetail = (operation: string, cwd: string) => + `The ${operation} operation needs a working tree, but ${cwd} is a bare Git repository (no checkout of its own). Run it inside a worktree, or give the project a checkout.`; + const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* ( operation: string, cwd: string, + options?: { readonly allowBare?: boolean }, ) { const handle = yield* registry.resolve({ cwd }).pipe( Effect.mapError( @@ -195,11 +206,19 @@ export const make = Effect.gen(function* () { detail: `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}. (${cwd})`, }); } + if (handle.repository.bare && options?.allowBare !== true) { + return yield* new GitManagerError({ + operation, + cwd, + detail: bareRepositoryDetail(operation, cwd), + }); + } }); const ensureGitCommand = Effect.fn("GitWorkflowService.ensureGitCommand")(function* ( operation: string, cwd: string, + options?: { readonly allowBare?: boolean }, ) { const handle = yield* registry.resolve({ cwd }).pipe( Effect.mapError( @@ -223,6 +242,15 @@ export const make = Effect.gen(function* () { detail: `The ${operation} command currently supports Git repositories only; detected ${handle.kind}.`, }); } + if (handle.repository.bare && options?.allowBare !== true) { + return yield* new GitCommandError({ + operation, + command: "vcs-route", + cwd, + failureKind: "unknown", + detail: bareRepositoryDetail(operation, cwd), + }); + } }); const detectGitRepositoryForStatus = Effect.fn("GitWorkflowService.detectGitRepositoryForStatus")( @@ -248,6 +276,12 @@ export const make = Effect.gen(function* () { detail: `The ${operation} workflow currently supports Git repositories only; detected ${handle.kind}. (${cwd})`, }); } + // Status describes a working tree, and a bare repository has none. These + // paths are polled continuously, so report "no workspace here" instead of + // failing every poll with an error nobody can act on. + if (handle.repository.bare) { + return false; + } return true; }, ); @@ -341,20 +375,22 @@ export const make = Effect.gen(function* () { isGitRepository ? git.listRefs(input) : Effect.succeed(nonRepositoryListRefs()), ), ), + // `git worktree add` is the whole point of a bare source repository: the + // thread gets its own checkout, so the source never needs one. createWorktree: (input) => - ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe( + ensureGitCommand("GitWorkflowService.createWorktree", input.cwd, { allowBare: true }).pipe( Effect.andThen(git.createWorktree(input)), ), fetchRemote: (input) => - ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd).pipe( + ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd, { allowBare: true }).pipe( Effect.andThen(git.fetchRemote(input)), ), resolveRemoteTrackingCommit: (input) => - ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd).pipe( - Effect.andThen(git.resolveRemoteTrackingCommit(input)), - ), + ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd, { + allowBare: true, + }).pipe(Effect.andThen(git.resolveRemoteTrackingCommit(input))), removeWorktree: (input) => - ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( + ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd, { allowBare: true }).pipe( Effect.andThen( Effect.gen(function* () { // Prefer the PR associated with the worktree branch (status cwd = worktree path). @@ -395,15 +431,17 @@ export const make = Effect.gen(function* () { ), ), createRef: (input) => - ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe( - Effect.andThen(git.createRef(input)), - ), + ensureGitCommand("GitWorkflowService.createRef", input.cwd, { + // Creating the branch is pure ref plumbing; only checking it out after + // creation needs a working tree. + allowBare: input.switchRef !== true, + }).pipe(Effect.andThen(git.createRef(input))), switchRef: (input) => ensureGitCommand("GitWorkflowService.switchRef", input.cwd).pipe( Effect.andThen(Effect.scoped(git.switchRef(input))), ), renameBranch: (input) => - ensureGit("GitWorkflowService.renameBranch", input.cwd).pipe( + ensureGit("GitWorkflowService.renameBranch", input.cwd, { allowBare: true }).pipe( Effect.andThen(git.renameBranch(input)), ), }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 88d7dca4971..8fc41cce25e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1939,7 +1939,7 @@ const make = Effect.gen(function* () { } }); -yield* forkParked(Stream.runForEach(orchestrationEngine.streamDomainEvents, processEvent)); + yield* forkParked(Stream.runForEach(orchestrationEngine.streamDomainEvents, processEvent)); // The domain event stream is hot, so work pending before this reactor // starts cannot be resumed. Correlated completions only clear the request diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 0e2e0bf0f9c..4feb3aa166f 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -476,6 +476,7 @@ const buildAppUnderTest = (options?: { kind: "git" as const, rootPath: input.cwd, metadataPath: null, + bare: false, freshness: { source: "live-local" as const, observedAt: TEST_EPOCH, @@ -505,6 +506,7 @@ const buildAppUnderTest = (options?: { input.requestedKind === "auto" || !input.requestedKind ? "git" : input.requestedKind, rootPath: input.cwd, metadataPath: null, + bare: false, freshness: { source: "live-local", observedAt: TEST_EPOCH, diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 180368df5ef..6f470f393e1 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -8,7 +8,9 @@ import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as ServerRuntime from "@t3tools/shared/serverRuntime"; import * as ServerConfig from "./config.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; @@ -368,3 +370,29 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []); }).pipe(Effect.provide(NodeServices.layer)), ); + +it.effect("writes a runtime descriptor the desktop client can decode", () => + Effect.gen(function* () { + const descriptor = { + version: 1 as const, + pid: 4242, + stateDir: "/state", + httpBaseUrl: "http://127.0.0.1:3773", + startedAt: "2026-08-04T10:00:00.000Z", + }; + + const contents = yield* ServerRuntimeStartup.encodeServerRuntimeDescriptorFile(descriptor); + + // The file is newline-terminated and holds a single JSON document. + assert.isTrue(contents.endsWith("\n")); + assert.equal(contents.trimEnd().split("\n").length, 1); + + // DesktopExistingBackend decodes the file with this same shared schema, so + // the round trip through it is the cross-process contract. + const decoded = Schema.decodeUnknownExit( + Schema.fromJsonString(ServerRuntime.ServerRuntimeDescriptor), + )(contents); + assert.equal(decoded._tag, "Success"); + assert.deepStrictEqual(decoded._tag === "Success" ? decoded.value : null, descriptor); + }), +); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index b2679ad0e7b..46d1dd74cdc 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -15,6 +15,7 @@ import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -22,9 +23,10 @@ import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; -import * as NodeFSP from "node:fs/promises"; -import * as NodePath from "node:path"; -import { SERVER_RUNTIME_DESCRIPTOR_FILE } from "@t3tools/shared/serverRuntime"; +import { + SERVER_RUNTIME_DESCRIPTOR_FILE, + ServerRuntimeDescriptor, +} from "@t3tools/shared/serverRuntime"; import * as ServerConfig from "./config.ts"; import * as Keybindings from "./keybindings.ts"; @@ -296,7 +298,6 @@ const runStartupPhase = (phase: string, effect: Effect.Effect) Effect.withSpan(`server.startup.${phase}`), ); - /** * Pure helper for session rows that claimed to be live across a process restart. * Only marks **Wake Required** (`interrupted`) when a turn was actually in flight. @@ -334,9 +335,25 @@ interface StartupOptions { readonly abort?: (error: ServerRuntimeStartupError) => Effect.Effect; } +const ServerRuntimeDescriptorJson = Schema.fromJsonString(ServerRuntimeDescriptor); +const encodeServerRuntimeDescriptor = Schema.encodeEffect(ServerRuntimeDescriptorJson); +const decodeServerRuntimeDescriptor = Schema.decodeEffect(ServerRuntimeDescriptorJson); + +/** + * Exact contents written to the runtime descriptor file. The desktop client + * reads this file back and decodes it with the same schema + * (`DesktopExistingBackend`), so the on-disk shape is a cross-process contract. + */ +export const encodeServerRuntimeDescriptorFile = ( + descriptor: ServerRuntimeDescriptor, +): Effect.Effect => + encodeServerRuntimeDescriptor(descriptor).pipe(Effect.map((json) => `${json}\n`)); + export const make = (options?: StartupOptions) => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const keybindings = yield* Keybindings.Keybindings; const orchestrationReactor = yield* OrchestrationReactor.OrchestrationReactor; const providerSessionReaper = yield* ProviderSessionReaper.ProviderSessionReaper; @@ -484,7 +501,7 @@ export const make = (options?: StartupOptions) => yield* Effect.logDebug("startup phase: waiting for http listener"); yield* runStartupPhase("http.wait", Deferred.await(httpListening)); - const runtimeDescriptorPath = NodePath.join( + const runtimeDescriptorPath = path.join( serverConfig.stateDir, SERVER_RUNTIME_DESCRIPTOR_FILE, ); @@ -493,31 +510,28 @@ export const make = (options?: StartupOptions) => ? "127.0.0.1" : serverConfig.host; const runtimeStartedAt = DateTime.formatIso(yield* DateTime.now); - yield* Effect.promise(() => - NodeFSP.writeFile( - runtimeDescriptorPath, - `${JSON.stringify({ - version: 1, - pid: process.pid, - stateDir: serverConfig.stateDir, - httpBaseUrl: `http://${formatHostForUrl(descriptorHost)}:${serverConfig.port}`, - startedAt: runtimeStartedAt, - })}\n`, - { mode: 0o600 }, - ), - ); + const runtimeDescriptorContents = yield* encodeServerRuntimeDescriptorFile({ + version: 1, + pid: process.pid, + stateDir: serverConfig.stateDir, + httpBaseUrl: `http://${formatHostForUrl(descriptorHost)}:${serverConfig.port}`, + startedAt: runtimeStartedAt, + }).pipe(Effect.orDie); + yield* fileSystem + .writeFileString(runtimeDescriptorPath, runtimeDescriptorContents, { mode: 0o600 }) + .pipe(Effect.orDie); yield* Effect.addFinalizer(() => - Effect.promise(async () => { - try { - const current = JSON.parse(await NodeFSP.readFile(runtimeDescriptorPath, "utf8")) as { - pid?: unknown; - }; - if (current.pid === process.pid) - await NodeFSP.rm(runtimeDescriptorPath, { force: true }); - } catch { - /* best-effort cleanup */ - } - }), + // Best-effort cleanup: only this process's own descriptor is removed, + // and a missing, unreadable, or malformed file must not fail shutdown. + fileSystem.readFileString(runtimeDescriptorPath).pipe( + Effect.flatMap(decodeServerRuntimeDescriptor), + Effect.flatMap((current) => + current.pid === process.pid + ? fileSystem.remove(runtimeDescriptorPath, { force: true }) + : Effect.void, + ), + Effect.ignore, + ), ); yield* runStartupPhase( "auxiliary-roots.parked", diff --git a/apps/server/src/sourceControl/BitbucketApi.test.ts b/apps/server/src/sourceControl/BitbucketApi.test.ts index 141d1cef4c9..08ac294556c 100644 --- a/apps/server/src/sourceControl/BitbucketApi.test.ts +++ b/apps/server/src/sourceControl/BitbucketApi.test.ts @@ -135,6 +135,7 @@ function makeLayer(input: { kind: "git", rootPath: "/repo", metadataPath: null, + bare: false, freshness: { source: "live-local" as const, observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"), diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 5c4d27e46f9..9a43450bee4 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -68,6 +68,7 @@ function makeRegistry(input: { kind: "git", rootPath: "/repo", metadataPath: null, + bare: false, freshness: { source: "live-local" as const, observedAt: TEST_EPOCH, diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 89f7c55d586..0d43e0ff3b8 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -5,11 +5,12 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { assert, it } from "@effect/vitest"; +import { assert, describe, it } from "@effect/vitest"; import { GitCommandError } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; +import * as VcsDriver from "./VcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; import { runVcsDriverContractSuite } from "./testing/VcsDriverContractHarness.ts"; @@ -65,6 +66,96 @@ runVcsDriverContractSuite({ }, }); +const withTempDir = (use: (cwd: string) => Effect.Effect) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-git-bare-" }); + return yield* use(cwd); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)); + +describe("GitVcsDriver bare repositories", () => { + it.effect("detects a bare repository instead of reporting no repository", () => + withTempDir((cwd) => + Effect.gen(function* () { + const path = yield* Path.Path; + const driver = yield* VcsDriver.VcsDriver; + yield* runGit(cwd, ["init", "--bare"]); + + const identity = yield* driver.detectRepository(cwd); + + assert.equal(identity?.kind, "git"); + assert.equal(identity?.bare, true); + // A bare repository has no toplevel, so the metadata directory is the root. + assert.equal(identity?.rootPath, path.normalize(cwd)); + assert.isFalse(yield* driver.isInsideWorkTree(cwd)); + }), + ), + ); + + it.effect("detects a repository whose config marks an existing checkout bare", () => + withTempDir((cwd) => + Effect.gen(function* () { + const driver = yield* VcsDriver.VcsDriver; + yield* runGit(cwd, ["init"]); + // The shape a repository lands in when `core.bare` is flipped under a + // populated checkout: every Git route used to fail detection outright. + yield* runGit(cwd, ["config", "core.bare", "true"]); + + const identity = yield* driver.detectRepository(cwd); + + assert.equal(identity?.kind, "git"); + assert.equal(identity?.bare, true); + }), + ), + ); + + it.effect("still reports no repository outside one", () => + withTempDir((cwd) => + Effect.gen(function* () { + const driver = yield* VcsDriver.VcsDriver; + assert.equal(yield* driver.detectRepository(cwd), null); + }), + ), + ); + + it.effect("creates a usable worktree from a bare repository", () => + withTempDir((root) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* VcsDriver.VcsDriver; + const gitDriver = yield* GitVcsDriver.GitVcsDriver; + + const source = path.join(root, "source"); + const bare = path.join(root, "bare.git"); + const worktree = path.join(root, "worktree"); + yield* fileSystem.makeDirectory(source, { recursive: true }); + yield* runGit(source, ["init"]); + yield* runGit(source, ["config", "user.email", "test@test.com"]); + yield* runGit(source, ["config", "user.name", "Test"]); + yield* runGit(source, ["commit", "--allow-empty", "-m", "init"]); + // Explicit branch name: `git init` defaults vary by host config. + yield* runGit(source, ["branch", "t3-base"]); + yield* runGit(root, ["clone", "--bare", source, bare]); + + const result = yield* gitDriver.createWorktree({ + cwd: bare, + refName: "t3-base", + newRefName: "feature", + path: worktree, + }); + + assert.equal(result.worktree.path, worktree); + assert.equal(result.worktree.refName, "feature"); + // The thread's checkout is a real working tree even though its source has none. + assert.isTrue(yield* driver.isInsideWorkTree(worktree)); + const worktreeIdentity = yield* driver.detectRepository(worktree); + assert.equal(worktreeIdentity?.bare, false); + }), + ), + ); +}); + it.effect("GitVcsDriver forwards execute env to the VCS process", () => { let observedEnv: NodeJS.ProcessEnv | undefined; let observedAppendTruncationMarker: boolean | undefined; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 5174abe3bd0..3636dc2c823 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -406,18 +406,41 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ignoreClassifier: "native" as const, }; - const isInsideWorkTree: VcsDriver.VcsDriver["Service"]["isInsideWorkTree"] = (cwd) => + /** + * A single `rev-parse` separates the three states we care about: outside any + * repository (non-zero exit), a bare repository (no working tree of its own), + * and an ordinary checkout. Keep it one call — repeated rev-parse probes on + * every status poll were a major VCS load contributor. + */ + const probeRepository = (cwd: string) => gitCommand( vcsProcess, - "GitVcsDriver.isInsideWorkTree", + "GitVcsDriver.probeRepository", cwd, - ["rev-parse", "--is-inside-work-tree"], + ["rev-parse", "--is-bare-repository", "--is-inside-work-tree"], { allowNonZeroExit: true, timeoutMs: 5_000, maxOutputBytes: 4_096, }, - ).pipe(Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true")); + ).pipe( + Effect.map((result) => { + if (result.exitCode !== 0) { + return null; + } + const [bare, insideWorkTree] = result.stdout + .trim() + .split("\n") + .map((line) => line.trim()); + return { + bare: bare === "true", + insideWorkTree: insideWorkTree === "true", + }; + }), + ); + + const isInsideWorkTree: VcsDriver.VcsDriver["Service"]["isInsideWorkTree"] = (cwd) => + probeRepository(cwd).pipe(Effect.map((probe) => probe?.insideWorkTree === true)); const execute: VcsDriver.VcsDriver["Service"]["execute"] = (input) => gitCommand(vcsProcess, input.operation, input.cwd, input.args, { @@ -434,25 +457,51 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( const detectRepository: VcsDriver.VcsDriver["Service"]["detectRepository"] = Effect.fn( "detectRepository", )(function* (cwd) { - if (!(yield* isInsideWorkTree(cwd))) { + const probe = yield* probeRepository(cwd); + if (!probe) { + return null; + } + if (!probe.insideWorkTree && !probe.bare) { + // Inside the metadata directory of a non-bare repository (e.g. `.git/`), + // which is not a workspace any driver operation can act on. return null; } - const root = yield* gitCommand(vcsProcess, "GitVcsDriver.detectRepository.root", cwd, [ - "rev-parse", - "--show-toplevel", - ]); const gitCommonDir = yield* gitCommand( vcsProcess, "GitVcsDriver.detectRepository.commonDir", cwd, ["rev-parse", "--git-common-dir"], ).pipe(Effect.orElseSucceed(() => null)); + const metadataPath = gitCommonDir?.stdout.trim() || null; + + // A bare repository has no toplevel to report, but it is still a complete + // repository: `fetch`, ref plumbing, and `worktree add` all work against + // it, so it is a valid source for thread worktrees. Report its metadata + // directory as the root rather than rejecting it as "not a repository". + if (!probe.insideWorkTree) { + const commonDir = metadataPath ?? "."; + return { + kind: "git" as const, + rootPath: path.normalize( + path.isAbsolute(commonDir) ? commonDir : path.resolve(cwd, commonDir), + ), + metadataPath, + bare: true, + freshness: yield* nowFreshness(), + }; + } + + const root = yield* gitCommand(vcsProcess, "GitVcsDriver.detectRepository.root", cwd, [ + "rev-parse", + "--show-toplevel", + ]); return { kind: "git" as const, rootPath: root.stdout.trim(), - metadataPath: gitCommonDir?.stdout.trim() || null, + metadataPath, + bare: false, freshness: yield* nowFreshness(), }; }); diff --git a/apps/server/src/vcs/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts index 9134d2f7af2..b95a8d5ce91 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.test.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -61,8 +61,8 @@ describe("VcsDriverRegistry", () => { const normalizedArgs = input.args[0] === "-C" && input.args.length >= 2 ? input.args.slice(2) : input.args; const command = normalizedArgs.join(" "); - if (command === "rev-parse --is-inside-work-tree") { - return processOutput("true\n"); + if (command === "rev-parse --is-bare-repository --is-inside-work-tree") { + return processOutput("false\ntrue\n"); } if (command === "rev-parse --show-toplevel") { return processOutput("/repo\n"); @@ -86,16 +86,16 @@ describe("VcsDriverRegistry", () => { assert.deepStrictEqual( calls.map((call) => normalizeGitArgs(call.args).join(" ")), [ - "rev-parse --is-inside-work-tree", - "rev-parse --show-toplevel", + "rev-parse --is-bare-repository --is-inside-work-tree", "rev-parse --git-common-dir", + "rev-parse --show-toplevel", ], ); }).pipe(Effect.provide(layer)); }); it.effect("detects a repository created after a negative lookup", () => { - let insideWorkTreeChecks = 0; + let probeChecks = 0; const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( Layer.provide(NodeServices.layer), Layer.provide( @@ -108,15 +108,15 @@ describe("VcsDriverRegistry", () => { run: (input) => Effect.sync(() => { const command = normalizeGitArgs(input.args).join(" "); - if (command === "rev-parse --is-inside-work-tree") { - insideWorkTreeChecks += 1; - return insideWorkTreeChecks === 1 + if (command === "rev-parse --is-bare-repository --is-inside-work-tree") { + probeChecks += 1; + return probeChecks === 1 ? { ...processOutput(""), exitCode: ChildProcessSpawner.ExitCode(128), stderr: "fatal: not a git repository", } - : processOutput("true\n"); + : processOutput("false\ntrue\n"); } if (command === "rev-parse --show-toplevel") { return processOutput("/repo\n"); @@ -137,7 +137,9 @@ describe("VcsDriverRegistry", () => { // Negative detects are TTL-cached (15s); advance so a later repo creation is noticed. yield* TestClock.adjust("16 seconds"); assert.equal((yield* registry.detect({ cwd: "/repo" }))?.repository.rootPath, "/repo"); - assert.equal(insideWorkTreeChecks, 2); + // One probe per detect: the combined rev-parse keeps negative detection + // to a single git call. + assert.equal(probeChecks, 2); }).pipe(Effect.provide(Layer.mergeAll(layer, TestClock.layer()))); }); }); diff --git a/apps/server/src/vcs/testing/VcsDriverContractHarness.ts b/apps/server/src/vcs/testing/VcsDriverContractHarness.ts index fd474283590..695fedd2ff5 100644 --- a/apps/server/src/vcs/testing/VcsDriverContractHarness.ts +++ b/apps/server/src/vcs/testing/VcsDriverContractHarness.ts @@ -76,6 +76,7 @@ export function runVcsDriverContractSuite(input: VcsDriverContractSuiteInp normalizePathForComparison(cwd), ), ); + assert.equal(identity?.bare, false); assert.equal(identity?.freshness.source, "live-local"); assert.isTrue(DateTime.isDateTime(identity?.freshness.observedAt)); assert.isTrue(Option.isNone(identity?.freshness.expiresAt ?? Option.none())); diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts index c1090f4f39a..df0cb9ef84f 100644 --- a/packages/contracts/src/vcs.ts +++ b/packages/contracts/src/vcs.ts @@ -33,6 +33,13 @@ export const VcsRepositoryIdentity = Schema.Struct({ kind: VcsDriverKind, rootPath: TrimmedNonEmptyString, metadataPath: Schema.NullOr(TrimmedNonEmptyString), + /** + * True when the repository has no working tree of its own (Git: `core.bare`). + * Such a repository is still a complete repository — fetch, ref, and worktree + * plumbing all work against it — so detection reports it rather than treating + * it as "no repository here". `rootPath` is then the metadata directory. + */ + bare: Schema.Boolean, freshness: VcsFreshness, }); export type VcsRepositoryIdentity = typeof VcsRepositoryIdentity.Type;