diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index bc5315bec132..a92ed1a491e9 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -47,7 +47,7 @@ const PAGE_TITLES: Record = { const PAGE_PROJECT_KEYS: Record = { "new-threads": ["defaultThreadEnvMode", "worktreeSubmodules", "defaultRuntimeMode"], - "source-control": ["defaultAutoPull", "newWorktreesStartFromOrigin"], + "source-control": ["enableVersionControl", "defaultAutoPull", "newWorktreesStartFromOrigin"], "agent-behavior": ["responseStreamingMode", "enableAgentBrowserAccess"], maintenance: ["continueThreadsAfterServerUpdate"], }; @@ -325,6 +325,16 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { {props.page === "source-control" ? ( <> + + write({ enableVersionControl: value })} + /> + { }, })), ), - ).pipe(Layer.provide(VcsDriverRegistry.layer)), + ).pipe( + Layer.provide(VcsDriverRegistry.layer), + Layer.provide(VersionControlPolicy.layerTest), + ), ), Layer.provideMerge( (options?.workspaceRefresh ? Layer.mock(WorkspaceEntries.WorkspaceEntries)({ refresh: options.workspaceRefresh }) : WorkspaceEntries.layer - ).pipe(Layer.provide(WorkspacePaths.layer), Layer.provideMerge(VcsDriverRegistry.layer)), + ).pipe( + Layer.provide(WorkspacePaths.layer), + Layer.provideMerge( + VcsDriverRegistry.layer.pipe(Layer.provide(VersionControlPolicy.layerTest)), + ), + ), ), Layer.provideMerge(WorkspacePaths.layer), Layer.provideMerge(VcsProcess.layer), diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 9b5b56309749..5d63ec9cc131 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -52,6 +52,7 @@ import { import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; +import * as VersionControlPolicy from "../../vcs/VersionControlPolicy.ts"; import * as VcsProcess from "../../vcs/VcsProcess.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; @@ -338,7 +339,14 @@ describe("ProviderRuntimeIngestion", () => { ...store, isGitRepository: options?.isGitRepository ?? store.isGitRepository, })), - ).pipe(Layer.provide(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer)))), + ).pipe( + Layer.provide( + CheckpointStore.layer.pipe( + Layer.provide(VcsDriverRegistry.layer), + Layer.provide(VersionControlPolicy.layerTest), + ), + ), + ), ), Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 43595dbafce1..a90809b9ae34 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -169,6 +169,7 @@ import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as VcsDriver from "./vcs/VcsDriver.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; +import * as VersionControlPolicy from "./vcs/VersionControlPolicy.ts"; import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as GitHubCli from "./sourceControl/GitHubCli.ts"; @@ -749,7 +750,10 @@ const buildAppUnderTest = (options?: { ? Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ ...options.layers.vcsStatusBroadcaster, }) - : VcsStatusBroadcaster.layer.pipe(Layer.provide(gitWorkflowLayer)); + : VcsStatusBroadcaster.layer.pipe( + Layer.provide(gitWorkflowLayer), + Layer.provide(VersionControlPolicy.layerTest), + ); const resourceTelemetryLayer = ResourceTelemetry.layer.pipe( Layer.provide( Layer.mergeAll( @@ -1241,7 +1245,12 @@ const buildAppUnderTest = (options?: { ? FetchHttpClient.layer : Layer.succeed(HttpClient.HttpClient, options.layers.httpClient), ), - Layer.provide(GitHubCli.layer.pipe(Layer.provideMerge(VcsProcess.layer))), + Layer.provide( + Layer.mergeAll( + GitHubCli.layer.pipe(Layer.provideMerge(VcsProcess.layer)), + VersionControlPolicy.layerTest, + ), + ), Layer.provide(layerConfig), ); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 88c08fd3bdee..b938ba2a2238 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -110,6 +110,7 @@ import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; +import * as VersionControlPolicy from "./vcs/VersionControlPolicy.ts"; import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; @@ -198,6 +199,10 @@ const ServerSettingsLayerLive = ServerSettings.layer.pipe( Layer.provideMerge(SqlitePersistenceLayerLive), ); +const VersionControlPolicyLive = VersionControlPolicy.layer.pipe( + Layer.provide(ServerSettingsLayerLive), +); + const NativeTelemetryLayerLive = NativeTelemetryClient.layer.pipe( Layer.provide(ResourceMonitorBinary.layer), ); @@ -553,6 +558,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, ProjectFaviconResolverLayerLive)), Layer.provideMerge(RepositoryIdentityResolverLayerLive), + // Detection, status, and checkpoints require this. The service has no + // default, so a missing layer cannot silently leave Git on. + Layer.provideMerge(VersionControlPolicyLive), Layer.provideMerge(ServerEnvironmentLayerLive), Layer.provideMerge(AuthLayerLive), Layer.provideMerge(ServerSecretStore.layer), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 4bbdb2e9c8b5..b8cd30c2e239 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -26,16 +26,20 @@ import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; it.effect("automatic pull only updates enabled, behind, clean default-branch checkouts", () => Effect.gen(function* () { const pulled: string[] = []; + const statusCalls: string[] = []; const git = { statusDetails: (cwd: string) => - Effect.succeed({ - isRepo: true, - isDefaultBranch: cwd !== "/feature", - hasUpstream: true, - hasWorkingTreeChanges: cwd === "/dirty", - aheadCount: cwd === "/ahead" ? 1 : 0, - behindCount: cwd === "/current" ? 0 : 1, - } as never), + Effect.sync(() => { + statusCalls.push(cwd); + return { + isRepo: true, + isDefaultBranch: cwd !== "/feature", + hasUpstream: true, + hasWorkingTreeChanges: cwd === "/dirty", + aheadCount: cwd === "/ahead" ? 1 : 0, + behindCount: cwd === "/current" ? 0 : 1, + } as never; + }), pullCurrentBranch: (cwd: string) => Effect.sync(() => { pulled.push(cwd); @@ -58,6 +62,14 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che ), }); + const pullSettings = overrides({ + "/clean": true, + "/current": true, + "/dirty": true, + "/ahead": true, + "/feature": true, + "/disabled": false, + }); yield* ServerRuntimeStartup.autoPullProjects( [ project("/clean"), @@ -66,18 +78,19 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che project("/ahead"), project("/feature"), project("/disabled"), + project("/vcs-off"), ], - overrides({ - "/clean": true, - "/current": true, - "/dirty": true, - "/ahead": true, - "/feature": true, - "/disabled": false, - }), + { + ...pullSettings, + projectSettingsOverrides: { + ...pullSettings.projectSettingsOverrides, + [ProjectId.make("/vcs-off")]: { defaultAutoPull: true, enableVersionControl: false }, + }, + }, ).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); assert.deepStrictEqual(pulled, ["/clean"]); + assert.equal(statusCalls.includes("/vcs-off"), false); pulled.length = 0; yield* ServerRuntimeStartup.autoPullProjects( diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 138dbcedfa85..3da108e2ba9e 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -834,6 +834,7 @@ interface StartupOptions { readonly abort?: (error: ServerRuntimeStartupError) => Effect.Effect; } +/** Pulls clean default-branch checkouts whose version control and auto-pull settings are on. */ export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( projects: ReadonlyArray, settings: ServerSettingsValue = DEFAULT_SERVER_SETTINGS, @@ -842,7 +843,10 @@ export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( const workspaceRoots = [ ...new Set( projects - .filter((project) => resolveProjectSettings(settings, project.id).settings.defaultAutoPull) + .filter((project) => { + const resolved = resolveProjectSettings(settings, project.id).settings; + return resolved.enableVersionControl && resolved.defaultAutoPull; + }) .map((project) => project.workspaceRoot), ), ]; diff --git a/apps/server/src/vcs/VcsDriverRegistry.test.ts b/apps/server/src/vcs/VcsDriverRegistry.test.ts index 0ad2450eb192..a75eb3b8c0ab 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.test.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.test.ts @@ -6,6 +6,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import * as VcsProcess from "./VcsProcess.ts"; import * as VcsProjectConfig from "./VcsProjectConfig.ts"; +import * as VersionControlPolicy from "./VersionControlPolicy.ts"; import * as VcsDriverRegistry from "./VcsDriverRegistry.ts"; const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({ @@ -33,6 +34,7 @@ describe("VcsDriverRegistry", () => { run: () => Effect.succeed(processOutput("")), }), ), + Layer.provide(VersionControlPolicy.layerTest), ); return Effect.gen(function* () { @@ -73,6 +75,7 @@ describe("VcsDriverRegistry", () => { }), }), ), + Layer.provide(VersionControlPolicy.layerTest), ); return Effect.gen(function* () { @@ -127,6 +130,7 @@ describe("VcsDriverRegistry", () => { }), }), ), + Layer.provide(VersionControlPolicy.layerTest), ); return Effect.gen(function* () { @@ -137,4 +141,38 @@ describe("VcsDriverRegistry", () => { assert.equal(insideWorkTreeChecks, 2); }).pipe(Effect.provide(layer)); }); + + it.effect("does not probe Git when version control is disabled", () => { + const calls: string[] = []; + const layer = Layer.effect(VcsDriverRegistry.VcsDriverRegistry, VcsDriverRegistry.make).pipe( + Layer.provide(NodeServices.layer), + Layer.provide( + Layer.mock(VcsProjectConfig.VcsProjectConfig)({ + resolveKind: (input) => Effect.succeed(input.requestedKind ?? "auto"), + }), + ), + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => + Effect.sync(() => { + calls.push(input.args.join(" ")); + return processOutput("true\n"); + }), + }), + ), + Layer.provide( + Layer.succeed(VersionControlPolicy.VersionControlPolicy, { + isEnabled: () => Effect.succeed(false), + }), + ), + ); + + return Effect.gen(function* () { + const registry = yield* VcsDriverRegistry.VcsDriverRegistry; + assert.equal(yield* registry.detect({ cwd: "/repo" }), null); + const error = yield* registry.resolve({ cwd: "/repo" }).pipe(Effect.flip); + assert.strictEqual(error._tag, "VcsUnsupportedOperationError"); + assert.deepStrictEqual(calls, []); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/server/src/vcs/VcsDriverRegistry.ts b/apps/server/src/vcs/VcsDriverRegistry.ts index f40e1dadea3e..1cb76dbad71c 100644 --- a/apps/server/src/vcs/VcsDriverRegistry.ts +++ b/apps/server/src/vcs/VcsDriverRegistry.ts @@ -8,6 +8,7 @@ import * as Layer from "effect/Layer"; import type { VcsDriverKind, VcsError, VcsRepositoryIdentity } from "@t3tools/contracts"; import { VcsUnsupportedOperationError } from "@t3tools/contracts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; +import * as VersionControlPolicy from "./VersionControlPolicy.ts"; import * as VcsProjectConfig from "./VcsProjectConfig.ts"; import * as VcsDriver from "./VcsDriver.ts"; @@ -62,6 +63,7 @@ function parseDetectionCacheKey(key: string): { export const make = Effect.gen(function* () { const projectConfig = yield* VcsProjectConfig.VcsProjectConfig; + const versionControl = yield* VersionControlPolicy.VersionControlPolicy; const git = yield* GitVcsDriver.makeVcsDriver; const drivers: Partial> = { git, @@ -122,8 +124,15 @@ export const make = Effect.gen(function* () { }, ); + /** + * Detects a repository. Version control off reports none before any Git + * probe, so status, fetch, auto-pull, and checkpoints do not spawn Git. + */ const detect: VcsDriverRegistry["Service"]["detect"] = Effect.fn("VcsDriverRegistry.detect")( function* (input) { + if (!(yield* versionControl.isEnabled(input.cwd))) { + return null; + } const requestedKind = yield* projectConfig.resolveKind(input); return yield* Cache.get(detectionCache, detectionCacheKey({ cwd: input.cwd, requestedKind })); }, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index ac21a61ddf78..4a30c866aa7c 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -25,6 +25,7 @@ import type { import { GitManagerError } from "@t3tools/contracts"; import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts"; +import * as VersionControlPolicy from "./VersionControlPolicy.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; @@ -69,19 +70,25 @@ const baseStatus: VcsStatusResult = { ...baseRemoteStatus, }; -function makeTestLayer(state: { - currentLocalStatus: VcsStatusLocalResult; - currentRemoteStatus: VcsStatusRemoteResult | null; - localStatusCalls: number; - remoteStatusCalls: number; - localInvalidationCalls: number; - remoteInvalidationCalls: number; - remoteStatusRefreshUpstreamValues?: Array; - backgroundWorkEnabled?: boolean; -}) { +function makeTestLayer( + state: { + currentLocalStatus: VcsStatusLocalResult; + currentRemoteStatus: VcsStatusRemoteResult | null; + localStatusCalls: number; + remoteStatusCalls: number; + localInvalidationCalls: number; + remoteInvalidationCalls: number; + remoteStatusRefreshUpstreamValues?: Array; + backgroundWorkEnabled?: boolean; + }, + versionControl: VersionControlPolicy.VersionControlPolicy["Service"] = { + isEnabled: () => Effect.succeed(true), + }, +) { return VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), Layer.provide(makeBackgroundPolicyLayer(() => state.backgroundWorkEnabled !== false)), + Layer.provide(Layer.succeed(VersionControlPolicy.VersionControlPolicy, versionControl)), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => @@ -159,6 +166,7 @@ describe("VcsStatusBroadcaster", () => { const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide(VersionControlPolicy.layerTest), Layer.provide( Layer.succeed(VcsStatusBroadcaster.VcsAutoPullPolicy, { isEnabled: (cwd) => Effect.succeed(cwd === configuredWorkspaceRoot), @@ -275,6 +283,7 @@ describe("VcsStatusBroadcaster", () => { const layer = VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide(VersionControlPolicy.layerTest), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => Effect.succeed(baseLocalStatus), @@ -321,6 +330,7 @@ describe("VcsStatusBroadcaster", () => { Layer.provide(FileSystem.layerNoop({ realPath: (path) => Effect.succeed(path) })), Layer.provideMerge(NodeServices.layer), Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide(VersionControlPolicy.layerTest), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => Effect.succeed(baseLocalStatus), @@ -428,6 +438,7 @@ describe("VcsStatusBroadcaster", () => { const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide(VersionControlPolicy.layerTest), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => @@ -538,6 +549,7 @@ describe("VcsStatusBroadcaster", () => { const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide(VersionControlPolicy.layerTest), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: (input) => @@ -703,6 +715,7 @@ describe("VcsStatusBroadcaster", () => { const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide(VersionControlPolicy.layerTest), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => @@ -903,6 +916,7 @@ describe("VcsStatusBroadcaster", () => { const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), Layer.provide(makeBackgroundPolicyLayer(() => false)), + Layer.provide(VersionControlPolicy.layerTest), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => @@ -956,6 +970,7 @@ describe("VcsStatusBroadcaster", () => { const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide(VersionControlPolicy.layerTest), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => @@ -1027,4 +1042,220 @@ describe("VcsStatusBroadcaster", () => { assert.isTrue(Option.isSome(yield* Deferred.poll(remoteInterrupted))); }).pipe(Effect.provide(testLayer)); }); + + it.effect("does not read or refresh Git status when version control is disabled", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + let versionControlEnabled = true; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + + const loaded = yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.strictEqual(loaded.isRepo, true); + assert.equal(state.localStatusCalls, 1); + assert.equal(state.remoteStatusCalls, 1); + + versionControlEnabled = false; + + const disabled = yield* broadcaster.getStatus({ cwd: "/repo" }); + const refreshed = yield* broadcaster.refreshStatus("/repo"); + const local = yield* broadcaster.refreshLocalStatus("/repo"); + const pullRequest = yield* broadcaster.refreshPullRequestStatus("/repo"); + + assert.strictEqual(disabled.isRepo, false); + assert.strictEqual(refreshed.isRepo, false); + assert.strictEqual(local.isRepo, false); + assert.isNull(pullRequest); + assert.equal(state.localStatusCalls, 1); + assert.equal(state.remoteStatusCalls, 1); + assert.equal(state.localInvalidationCalls, 0); + assert.equal(state.remoteInvalidationCalls, 0); + + versionControlEnabled = true; + const restored = yield* broadcaster.getStatus({ cwd: "/repo" }); + assert.strictEqual(restored.isRepo, true); + assert.equal(state.localStatusCalls, 2); + assert.equal(state.remoteStatusCalls, 2); + }).pipe( + Effect.provide( + makeTestLayer(state, { + isEnabled: () => Effect.succeed(versionControlEnabled), + }), + ), + ); + }); + + it.effect( + "publishes a disabled snapshot on an open zero-interval stream and a full snapshot after re-enable", + () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + let versionControlEnabled = true; + let phase: "startup" | "disabled" | "restoring" = "startup"; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const scope = yield* Scope.make(); + const remoteUpdated = yield* Deferred.make(); + const disabledSnapshot = yield* Deferred.make(); + const restoredSnapshot = yield* Deferred.make(); + yield* Stream.runForEach( + broadcaster.streamStatus( + { cwd: "/repo" }, + { automaticRemoteRefreshInterval: Effect.succeed(Duration.zero) }, + ), + (event) => { + if (event._tag === "remoteUpdated") { + return Deferred.succeed(remoteUpdated, event).pipe(Effect.ignore); + } + if (event._tag !== "snapshot") return Effect.void; + if (phase === "disabled" && !event.local.isRepo) { + return Deferred.succeed(disabledSnapshot, event).pipe(Effect.ignore); + } + if (phase === "restoring" && event.local.isRepo) { + return Deferred.succeed(restoredSnapshot, event).pipe(Effect.ignore); + } + return Effect.void; + }, + ).pipe(Effect.forkIn(scope)); + + const initialRemote = yield* Deferred.await(remoteUpdated); + assert.deepStrictEqual(initialRemote, { + _tag: "remoteUpdated", + remote: baseRemoteStatus, + } satisfies VcsStatusStreamEvent); + const callsAfterLoad = { + local: state.localStatusCalls, + remote: state.remoteStatusCalls, + }; + + phase = "disabled"; + versionControlEnabled = false; + yield* TestClock.adjust(Duration.seconds(30)); + const disabled = yield* Deferred.await(disabledSnapshot); + assert.deepStrictEqual(disabled, { + _tag: "snapshot", + local: { + isRepo: false, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }, + remote: null, + } satisfies VcsStatusStreamEvent); + assert.equal(state.localStatusCalls, callsAfterLoad.local); + assert.equal(state.remoteStatusCalls, callsAfterLoad.remote); + + phase = "restoring"; + versionControlEnabled = true; + yield* TestClock.adjust(Duration.seconds(30)); + const restored = yield* Deferred.await(restoredSnapshot); + assert.strictEqual(restored._tag, "snapshot"); + if (restored._tag !== "snapshot") return; + assert.strictEqual(restored.local.isRepo, true); + assert.deepStrictEqual(restored.remote, baseRemoteStatus); + assert.equal(state.localStatusCalls, callsAfterLoad.local + 1); + assert.equal(state.remoteStatusCalls, callsAfterLoad.remote + 1); + + yield* Scope.close(scope, Exit.void); + }).pipe( + Effect.provide( + Layer.merge( + makeTestLayer(state, { + isEnabled: () => Effect.succeed(versionControlEnabled), + }), + TestClock.layer(), + ), + ), + ); + }, + ); + + it.effect( + "rechecks version control after the remote write lock before running queued Git work", + () => { + const releaseGit = Deferred.makeUnsafe(); + const gitEntered = Deferred.makeUnsafe(); + const preChecksDone = Deferred.makeUnsafe(); + let acceptGit = true; + let checks = 0; + let localCalls = 0; + let remoteCalls = 0; + const layer = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide( + Layer.succeed(VersionControlPolicy.VersionControlPolicy, { + isEnabled: () => + Effect.sync(() => { + checks += 1; + if (checks === 5) { + Deferred.doneUnsafe(preChecksDone, Effect.void); + } + return acceptGit; + }), + }), + ), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => + Effect.sync(() => { + localCalls += 1; + return baseLocalStatus; + }), + remoteStatus: () => + Effect.gen(function* () { + remoteCalls += 1; + if (remoteCalls === 1) { + yield* Deferred.succeed(gitEntered, undefined); + yield* Deferred.await(releaseGit); + } + return baseRemoteStatus; + }), + invalidateLocalStatus: () => Effect.void, + invalidateRemoteStatus: () => Effect.void, + invalidateStatus: () => Effect.void, + }), + ), + ); + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const first = yield* broadcaster.getStatus({ cwd: "/repo" }).pipe(Effect.forkScoped); + yield* Deferred.await(gitEntered); + + const refresh = yield* broadcaster.refreshStatus("/repo").pipe(Effect.forkScoped); + const pullRequest = yield* broadcaster + .refreshPullRequestStatus("/repo") + .pipe(Effect.forkScoped); + const second = yield* broadcaster.getStatus({ cwd: "/repo" }).pipe(Effect.forkScoped); + yield* Deferred.await(preChecksDone); + acceptGit = false; + yield* Effect.yieldNow; + yield* Deferred.succeed(releaseGit, undefined); + + assert.strictEqual((yield* Fiber.join(first)).isRepo, false); + assert.strictEqual((yield* Fiber.join(refresh)).isRepo, false); + assert.isNull(yield* Fiber.join(pullRequest)); + assert.strictEqual((yield* Fiber.join(second)).isRepo, false); + assert.equal(localCalls, 1); + assert.equal(remoteCalls, 1); + }).pipe(Effect.provide(layer), Effect.scoped); + }, + ); }); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index ae9abdd0cc4e..5d429d3c6e37 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -28,6 +28,20 @@ import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as ServerSettings from "../serverSettings.ts"; +import * as VersionControlPolicy from "./VersionControlPolicy.ts"; + +const DISABLED_LOCAL_STATUS: VcsStatusLocalResult = { + isRepo: false, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: null, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, +}; const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); @@ -220,6 +234,7 @@ const normalizeCwd = (cwd: string) => /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const autoPullPolicy = yield* VcsAutoPullPolicy; + const versionControl = yield* VersionControlPolicy.VersionControlPolicy; const workflow = yield* GitWorkflowService.GitWorkflowService; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const fs = yield* FileSystem.FileSystem; @@ -360,9 +375,54 @@ export const make = Effect.gen(function* () { return yield* updateCachedLocalStatus(cwd, local); }); + /** Reads the version-control switch for one working directory. */ + const versionControlEnabled = (cwd: string) => versionControl.isEnabled(cwd); + + const disabledStatus = mergeGitStatusParts(DISABLED_LOCAL_STATUS, null); + + /** + * Drops a cached repository snapshot and tells open streams this directory + * is not a repository. Leaving `isRepo: false` cached would keep a later + * re-enable from loading Git. + */ + const forgetCachedStatus = Effect.fn("VcsStatusBroadcaster.forgetCachedStatus")(function* ( + cwd: string, + ) { + const removed = yield* Ref.modify(cacheRef, (cache) => { + if (!cache.has(cwd)) return [false, cache] as const; + const nextCache = new Map(cache); + nextCache.delete(cwd); + return [true, nextCache] as const; + }); + if (!removed) return; + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "snapshot", + local: DISABLED_LOCAL_STATUS, + remote: null, + }, + }); + }); + + /** + * Stops Git for a disabled directory and publishes that snapshot. + * Callers re-check after acquiring the remote write lock so work queued + * while the switch was still on does not continue. + */ + const stopWhenVersionControlDisabled = Effect.fn( + "VcsStatusBroadcaster.stopWhenVersionControlDisabled", + )(function* (cwd: string) { + if (yield* versionControlEnabled(cwd)) return false; + yield* forgetCachedStatus(cwd); + return true; + }); + + /** Loads local status, or a not-a-repository snapshot when version control is off. */ const getOrLoadLocalStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadLocalStatus")(function* ( cwd: string, ) { + if (yield* stopWhenVersionControlDisabled(cwd)) return DISABLED_LOCAL_STATUS; const cached = yield* getCachedStatus(cwd); if (cached?.local) { return cached.local.value; @@ -372,10 +432,12 @@ export const make = Effect.gen(function* () { const withFileSystem = Effect.provideService(FileSystem.FileSystem, fs); + /** Returns cached status, loading Git only while version control stays enabled. */ const getStatus: VcsStatusBroadcaster["Service"]["getStatus"] = Effect.fn( "VcsStatusBroadcaster.getStatus", )(function* (input) { const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); + if (yield* stopWhenVersionControlDisabled(cwd)) return disabledStatus; const cached = yield* getCachedStatus(cwd); if (cached?.local && cached.remote) { return mergeGitStatusParts(cached.local.value, cached.remote.value); @@ -383,6 +445,7 @@ export const make = Effect.gen(function* () { return yield* withRemoteWriteLock( cwd, Effect.gen(function* () { + if (yield* stopWhenVersionControlDisabled(cwd)) return disabledStatus; const latest = yield* getCachedStatus(cwd); const [local, remote] = yield* Effect.all( [ @@ -391,32 +454,44 @@ export const make = Effect.gen(function* () { ], { concurrency: "unbounded" }, ); + if (yield* stopWhenVersionControlDisabled(cwd)) return disabledStatus; return yield* updateCachedStatus(cwd, local, remote); }), ); }); + /** Reloads local Git status unless version control was turned off during the read. */ const refreshLocalStatusCore = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusCore")( function* (cwd: string) { + if (yield* stopWhenVersionControlDisabled(cwd)) return DISABLED_LOCAL_STATUS; yield* workflow.invalidateLocalStatus(cwd); const local = yield* workflow.localStatus({ cwd }); + if (yield* stopWhenVersionControlDisabled(cwd)) return DISABLED_LOCAL_STATUS; return yield* updateCachedLocalStatus(cwd, local, { publish: true }); }, ); + /** Refreshes local status, skipping Git when version control is off. */ const refreshLocalStatus: VcsStatusBroadcaster["Service"]["refreshLocalStatus"] = Effect.fn( "VcsStatusBroadcaster.refreshLocalStatus", )(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + if (yield* stopWhenVersionControlDisabled(cwd)) return DISABLED_LOCAL_STATUS; return yield* refreshLocalStatusCore(cwd); }); + /** + * Pulls a clean default branch when policy allows it. + * Rechecks the version-control switch before Git so a disable that lands + * while this effect is queued does not pull. + */ const maybeAutoPull = Effect.fn("VcsStatusBroadcaster.maybeAutoPull")(function* ( cwd: string, remote: VcsStatusRemoteResult | null, policyCwds: ReadonlyArray, ) { return yield* Effect.gen(function* () { + if (yield* stopWhenVersionControlDisabled(cwd)) return null; const autoPullEnabled = (yield* Effect.forEach(policyCwds, autoPullPolicy.isEnabled, { concurrency: "unbounded", })).some(Boolean); @@ -440,6 +515,7 @@ export const make = Effect.gen(function* () { [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd }, { refreshUpstream: false })], { concurrency: "unbounded" }, ); + if (yield* stopWhenVersionControlDisabled(cwd)) return null; yield* updateCachedStatus(cwd, refreshedLocal, refreshedRemote, { publish: true }); return { local: refreshedLocal, remote: refreshedRemote }; }).pipe( @@ -449,6 +525,11 @@ export const make = Effect.gen(function* () { ); }); + /** + * Refreshes remote status. When the cache has no local entry, reloads local + * status too and publishes a full snapshot so a re-enable replaces the + * disabled `isRepo: false` stream state. + */ const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* ( cwd: string, options?: { @@ -459,10 +540,28 @@ export const make = Effect.gen(function* () { return yield* withRemoteWriteLock( cwd, Effect.gen(function* () { + if (yield* stopWhenVersionControlDisabled(cwd)) return null; + const cached = yield* getCachedStatus(cwd); + if (cached?.local == null) { + yield* workflow.invalidateLocalStatus(cwd); + if (options?.refreshUpstream !== false) { + yield* workflow.invalidateRemoteStatus(cwd); + } + const [local, remote] = yield* Effect.all( + [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd }, options)], + { concurrency: "unbounded" }, + ); + if (yield* stopWhenVersionControlDisabled(cwd)) return null; + const pulled = yield* maybeAutoPull(cwd, remote, options?.policyCwds ?? [cwd]); + if (pulled !== null) return pulled.remote; + yield* updateCachedStatus(cwd, local, remote, { publish: true }); + return remote; + } if (options?.refreshUpstream !== false) { yield* workflow.invalidateRemoteStatus(cwd); } const remote = yield* workflow.remoteStatus({ cwd }, options); + if (yield* stopWhenVersionControlDisabled(cwd)) return null; const pulled = yield* maybeAutoPull(cwd, remote, options?.policyCwds ?? [cwd]); if (pulled !== null) return pulled.remote; return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); @@ -470,20 +569,24 @@ export const make = Effect.gen(function* () { ); }); + /** Reloads local and remote status, skipping Git queued before a disable. */ const refreshStatus: VcsStatusBroadcaster["Service"]["refreshStatus"] = Effect.fn( "VcsStatusBroadcaster.refreshStatus", )(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + if (yield* stopWhenVersionControlDisabled(cwd)) return disabledStatus; // invalidateStatus (not the two partial invalidations) so an explicit // refresh also bypasses GitManager's slow PR-lookup cache. return yield* withRemoteWriteLock( cwd, Effect.gen(function* () { + if (yield* stopWhenVersionControlDisabled(cwd)) return disabledStatus; yield* workflow.invalidateStatus(cwd); const [local, remote] = yield* Effect.all( [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], { concurrency: "unbounded" }, ); + if (yield* stopWhenVersionControlDisabled(cwd)) return disabledStatus; const pulled = yield* maybeAutoPull(cwd, remote, [rawCwd]); if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote); return yield* updateCachedStatus(cwd, local, remote, { publish: true }); @@ -491,12 +594,15 @@ export const make = Effect.gen(function* () { ); }); + /** Refreshes a loaded pull request, skipping Git queued before a disable. */ const refreshPullRequestStatus: VcsStatusBroadcaster["Service"]["refreshPullRequestStatus"] = Effect.fn("VcsStatusBroadcaster.refreshPullRequestStatus")(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + if (yield* stopWhenVersionControlDisabled(cwd)) return null; return yield* withRemoteWriteLock( cwd, Effect.gen(function* () { + if (yield* stopWhenVersionControlDisabled(cwd)) return null; const cached = yield* getCachedStatus(cwd); if (cached?.remote?.value == null) return null; const poller = (yield* SynchronizedRef.get(pollersRef)).get(cwd); @@ -514,6 +620,7 @@ export const make = Effect.gen(function* () { { cwd }, { refreshUpstream: false, refreshMissingPullRequest: true }, ); + if (yield* stopWhenVersionControlDisabled(cwd)) return null; return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); }), ); @@ -528,13 +635,26 @@ export const make = Effect.gen(function* () { return Effect.gen(function* () { const consecutiveFailuresRef = yield* Ref.make(0); const needsInitialRefreshRef = yield* Ref.make(refreshImmediately); + /** + * Polls remote status for one cwd. A zero fetch interval still checks + * the version-control switch so an open stream publishes a disabled + * snapshot and, after re-enable, a full snapshot when local status was + * forgotten. + */ const refreshRemoteStatusIfEnabled = Effect.gen(function* () { const configuredInterval = yield* automaticRemoteRefreshInterval; const activeInterval = Duration.isZero(configuredInterval) ? DEFAULT_VCS_STATUS_REFRESH_INTERVAL : configuredInterval; + if (yield* stopWhenVersionControlDisabled(cwd)) { + return activeInterval; + } const needsInitialRefresh = yield* Ref.get(needsInitialRefreshRef); - if (Duration.isZero(configuredInterval) && !needsInitialRefresh) { + const cached = yield* getCachedStatus(cwd); + // Interval 0 skips later fetches, but a missing local snapshot means + // version control was just turned back on and the stream still shows + // `isRepo: false`. + if (Duration.isZero(configuredInterval) && !needsInitialRefresh && cached?.local != null) { return activeInterval; } @@ -694,14 +814,16 @@ export const make = Effect.gen(function* () { } }); + /** Streams status, starting from a not-a-repository snapshot when version control is off. */ const streamStatus: VcsStatusBroadcaster["Service"]["streamStatus"] = (input, options) => Stream.unwrap( Effect.gen(function* () { const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); const subscription = yield* PubSub.subscribe(changesPubSub); - const initialLocal = yield* getOrLoadLocalStatus(cwd); + const enabled = !(yield* stopWhenVersionControlDisabled(cwd)); + const initialLocal = enabled ? yield* getOrLoadLocalStatus(cwd) : DISABLED_LOCAL_STATUS; const cachedStatus = yield* getCachedStatus(cwd); - const initialRemote = cachedStatus?.remote?.value ?? null; + const initialRemote = enabled ? (cachedStatus?.remote?.value ?? null) : null; yield* retainRemotePoller( cwd, input.cwd, diff --git a/apps/server/src/vcs/VersionControlPolicy.test.ts b/apps/server/src/vcs/VersionControlPolicy.test.ts new file mode 100644 index 000000000000..71f5eabff5d1 --- /dev/null +++ b/apps/server/src/vcs/VersionControlPolicy.test.ts @@ -0,0 +1,120 @@ +import { DEFAULT_SERVER_SETTINGS, ProjectId, type ServerSettings } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { ConnectionError, SqlError } from "effect/unstable/sql/SqlError"; + +import * as ServerSettingsService from "../serverSettings.ts"; +import { + projectIdForCwd, + resolveVersionControlEnabled, + type VersionControlRoot, +} from "./VersionControlPolicy.ts"; +import * as VersionControlPolicy from "./VersionControlPolicy.ts"; + +const project = ProjectId.make("project"); +const nested = ProjectId.make("nested"); + +const roots: ReadonlyArray = [ + { projectId: project, path: "/repo" }, + { projectId: nested, path: "/repo/app" }, +]; + +const settingsWithProjectOff: ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + enableVersionControl: true, + projectSettingsOverrides: { + [project]: { enableVersionControl: false }, + }, +}; + +it.effect("projectIdForCwd picks the longest root and keeps dot-dot names inside it", () => + Effect.gen(function* () { + const path = yield* Path.Path; + assert.strictEqual(projectIdForCwd(path, "/repo/src", roots), project); + assert.strictEqual(projectIdForCwd(path, "/repo/app/src", roots), nested); + assert.strictEqual(projectIdForCwd(path, "/repo/..hidden", roots), project); + assert.strictEqual(projectIdForCwd(path, "/repo-other", roots), null); + assert.strictEqual(projectIdForCwd(path, "/elsewhere", roots), null); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("resolveVersionControlEnabled stays off when project roots cannot be loaded", () => + Effect.gen(function* () { + const path = yield* Path.Path; + assert.strictEqual( + resolveVersionControlEnabled(path, "/repo/src", settingsWithProjectOff, Option.none()), + false, + ); + assert.strictEqual( + resolveVersionControlEnabled(path, "/elsewhere", settingsWithProjectOff, Option.none()), + false, + ); + assert.strictEqual( + resolveVersionControlEnabled( + path, + "/repo/..hidden", + settingsWithProjectOff, + Option.some(roots), + ), + false, + ); + assert.strictEqual( + resolveVersionControlEnabled(path, "/elsewhere", settingsWithProjectOff, Option.some(roots)), + true, + ); + assert.strictEqual( + resolveVersionControlEnabled(path, "/repo/src", DEFAULT_SERVER_SETTINGS, Option.none()), + true, + ); + }).pipe(Effect.provide(NodeServices.layer)), +); + +const databaseUnavailable = new SqlError({ + reason: new ConnectionError({ cause: "database unavailable" }), +}); + +const sqlFrom = ( + handler: (query: string) => Effect.Effect, SqlError>, +): SqlClient.SqlClient => + ((strings: TemplateStringsArray) => handler(strings.join(" "))) as unknown as SqlClient.SqlClient; + +const policyLayer = (sql: SqlClient.SqlClient) => + VersionControlPolicy.layer.pipe( + Layer.provide( + ServerSettingsService.layerTest({ + enableVersionControl: true, + projectSettingsOverrides: { + [project]: { enableVersionControl: false }, + }, + }), + ), + Layer.provide(Layer.succeed(SqlClient.SqlClient, sql)), + Layer.provide(NodeServices.layer), + ); + +it.effect("isEnabled keeps a project opt-out when root lookup fails", () => { + const failingSql = sqlFrom(() => Effect.fail(databaseUnavailable)); + return Effect.gen(function* () { + const policy = yield* VersionControlPolicy.VersionControlPolicy; + assert.strictEqual(yield* policy.isEnabled("/repo/src"), false); + assert.strictEqual(yield* policy.isEnabled("/elsewhere"), false); + }).pipe(Effect.provide(policyLayer(failingSql))); +}); + +it.effect("isEnabled applies a project opt-out for a dot-dot directory name", () => { + const sql = sqlFrom((query) => + Effect.succeed( + query.includes("projection_projects") ? [{ projectId: project, workspaceRoot: "/repo" }] : [], + ), + ); + return Effect.gen(function* () { + const policy = yield* VersionControlPolicy.VersionControlPolicy; + assert.strictEqual(yield* policy.isEnabled("/repo/..hidden"), false); + assert.strictEqual(yield* policy.isEnabled("/elsewhere"), true); + }).pipe(Effect.provide(policyLayer(sql))); +}); diff --git a/apps/server/src/vcs/VersionControlPolicy.ts b/apps/server/src/vcs/VersionControlPolicy.ts new file mode 100644 index 000000000000..fa7485b53b28 --- /dev/null +++ b/apps/server/src/vcs/VersionControlPolicy.ts @@ -0,0 +1,209 @@ +import { DEFAULT_SERVER_SETTINGS, ProjectId, type ServerSettings } from "@t3tools/contracts"; +import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import * as ServerSettingsService from "../serverSettings.ts"; + +const ROOTS_TTL_MS = 2_000; + +export interface VersionControlRoot { + readonly projectId: ProjectId; + readonly path: string; +} + +interface VersionControlPaths { + readonly resolve: (path: string) => string; + readonly relative: (from: string, to: string) => string; + readonly isAbsolute: (path: string) => boolean; + readonly sep: string; +} + +/** + * True when `relative` is the parent directory itself or begins with a parent + * segment. A directory named `..hidden` stays inside the root. + */ +function relativeEscapesParent(path: VersionControlPaths, relative: string): boolean { + return relative === ".." || relative.startsWith(`..${path.sep}`); +} + +/** + * Longest configured root that contains `cwd`. + * A nested checkout wins over its parent. Paths such as `/repo/..hidden` stay + * inside `/repo`; only a real `..` segment escapes. + */ +export function projectIdForCwd( + path: VersionControlPaths, + cwd: string, + roots: ReadonlyArray, +): ProjectId | null { + const candidate = path.resolve(cwd); + let best: { readonly projectId: ProjectId; readonly length: number } | null = null; + for (const root of roots) { + const resolved = path.resolve(root.path); + const relative = path.relative(resolved, candidate); + const inside = + relative === "" || (!relativeEscapesParent(path, relative) && !path.isAbsolute(relative)); + if (!inside) continue; + if (best === null || resolved.length > best.length) { + best = { projectId: root.projectId, length: resolved.length }; + } + } + return best?.projectId ?? null; +} + +/** + * True when the environment default or any project override has turned + * version control off. + */ +function hasVersionControlOptOut(settings: ServerSettings): boolean { + if (!settings.enableVersionControl) return true; + for (const entry of Object.values(settings.projectSettingsOverrides)) { + if (entry.enableVersionControl === false) return true; + } + return false; +} + +/** + * Whether Git may run for `cwd`. + * No opt-out stays on without consulting roots. When an opt-out exists, a + * missing root list stays off so a failed lookup cannot inherit the enabled + * environment default. + */ +export function resolveVersionControlEnabled( + path: VersionControlPaths, + cwd: string, + settings: ServerSettings, + roots: Option.Option>, +): boolean { + if (!hasVersionControlOptOut(settings)) return true; + if (Option.isNone(roots)) return false; + const projectId = projectIdForCwd(path, cwd, roots.value); + return resolveProjectSettings(settings, projectId).settings.enableVersionControl; +} + +export class VersionControlPolicy extends Context.Service< + VersionControlPolicy, + { + /** Whether Git may run for this working directory. */ + readonly isEnabled: (cwd: string) => Effect.Effect; + } +>()("t3/vcs/VersionControlPolicy") {} + +interface RootsCache { + readonly loadedAt: number; + readonly roots: ReadonlyArray; +} + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const settingsService = yield* ServerSettingsService.ServerSettingsService; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cacheRef = yield* Ref.make(null); + + /** Canonical path plus the symlink target when they differ. */ + const pathsFor = (value: string) => + fileSystem.realPath(value).pipe( + Effect.map((realPath) => { + const resolved = path.resolve(value); + return resolved === realPath ? [resolved] : [resolved, realPath]; + }), + Effect.orElseSucceed(() => [path.resolve(value)]), + ); + + /** + * Project workspace roots and thread checkout paths that share a project's + * version-control switch. + */ + const readRoots = Effect.gen(function* () { + const [projects, checkouts] = yield* Effect.all( + [ + sql<{ readonly projectId: string; readonly workspaceRoot: string }>` + SELECT + project_id AS "projectId", + workspace_root AS "workspaceRoot" + FROM projection_projects + WHERE deleted_at IS NULL + `, + sql<{ readonly projectId: string; readonly worktreePath: string }>` + SELECT + project_id AS "projectId", + worktree_path AS "worktreePath" + FROM projection_threads + WHERE deleted_at IS NULL + AND worktree_path IS NOT NULL + `, + ], + { concurrency: "unbounded" }, + ); + const entries = [ + ...projects.map((row) => ({ projectId: row.projectId, path: row.workspaceRoot })), + ...checkouts.map((row) => ({ projectId: row.projectId, path: row.worktreePath })), + ]; + const canonical = yield* Effect.forEach( + entries, + (entry) => + pathsFor(entry.path).pipe( + Effect.map((paths) => + paths.map( + (root) => + ({ + projectId: ProjectId.make(entry.projectId), + path: root, + }) satisfies VersionControlRoot, + ), + ), + ), + { concurrency: "unbounded" }, + ); + return canonical.flat(); + }); + + /** Project roots, reused briefly so status checks stay off the database. */ + const loadRoots = Effect.gen(function* () { + const now = yield* Clock.currentTimeMillis; + const cached = yield* Ref.get(cacheRef); + if (cached !== null && now - cached.loadedAt < ROOTS_TTL_MS) { + return cached.roots; + } + const roots = yield* readRoots; + yield* Ref.set(cacheRef, { loadedAt: now, roots }); + return roots; + }); + + /** + * Resolves the version-control switch for one working directory. + * A failed project-root lookup stays disabled whenever any opt-out exists. + */ + const isEnabled = (cwd: string): Effect.Effect => + Effect.gen(function* () { + const settings = yield* settingsService.getSettings.pipe( + Effect.orElseSucceed(() => DEFAULT_SERVER_SETTINGS), + ); + if (!hasVersionControlOptOut(settings)) return true; + const roots = yield* loadRoots.pipe( + Effect.asSome, + Effect.orElseSucceed(() => Option.none>()), + ); + return resolveVersionControlEnabled(path, cwd, settings, roots); + }); + + return VersionControlPolicy.of({ isEnabled }); +}); + +/** Live policy. Consumers provide this layer; it has no always-on default. */ +export const layer = Layer.effect(VersionControlPolicy, make); + +/** Explicit always-on policy for tests that do not load this setting. */ +export const layerTest = Layer.succeed(VersionControlPolicy, { + isEnabled: () => Effect.succeed(true), +}); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index 5afc212ce172..50eb2674de33 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -9,6 +9,7 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as ServerConfig from "../config.ts"; +import * as VersionControlPolicy from "../vcs/VersionControlPolicy.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as WorkspaceEntries from "./WorkspaceEntries.ts"; @@ -26,7 +27,12 @@ const TestLayer = Layer.empty.pipe( Layer.provideMerge(ProjectLayer), Layer.provideMerge(WorkspaceEntries.layer.pipe(Layer.provide(WorkspacePaths.layer))), Layer.provideMerge(WorkspacePaths.layer), - Layer.provideMerge(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcess.layer))), + Layer.provideMerge( + VcsDriverRegistry.layer.pipe( + Layer.provide(VcsProcess.layer), + Layer.provide(VersionControlPolicy.layerTest), + ), + ), Layer.provide( ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-workspace-files-test-", diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 10f86084ea20..c6922abb934c 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -18,7 +18,11 @@ import { resolveServerBackgroundActivitySettings, } from "@t3tools/shared/backgroundActivitySettings"; -import { useScopedSettings, useUpdateScopedSettings } from "./useScopedSettings"; +import { + useScopedSettings, + useScopedSettingsMixed, + useUpdateScopedSettings, +} from "./useScopedSettings"; import { useSettingsScope } from "./SettingsScopeContext"; import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings"; import { cn } from "../../lib/utils"; @@ -64,6 +68,7 @@ import { SettingsPageContainer, SettingsSearchTarget, SettingsSection, + useSettingsSearchTarget, useSettingsSearchTargetId, } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; @@ -202,15 +207,32 @@ function SourceControlItemMark({ ); } +/** Summary under a discovery row, including whether Git is turned off. */ function itemSummary({ item, auth, authAccount, + versionControl, }: { readonly item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem; readonly auth: SourceControlProviderAuth | null; readonly authAccount: string | null; + readonly versionControl: { readonly enabled: boolean; readonly mixed: boolean } | null; }) { + if ( + versionControl !== null && + item.status === "available" && + !isProviderDiscoveryItem(item) && + item.implemented + ) { + if (versionControl.mixed) { + return Version control differs across the selected projects.; + } + if (!versionControl.enabled) { + return Version control is off. Git commands are skipped.; + } + } + if (isVcsNotReady(item)) { return Support for {item.label} is coming soon.; } @@ -258,6 +280,26 @@ function itemSummary({ return Available; } +/** Writable Git switch. Unavailable drivers stay a read-only availability indicator. */ +function VersionControlSwitch(props: { + readonly available: boolean; + readonly enabled: boolean; + readonly mixed: boolean; + readonly onEnabledChange: (enabled: boolean) => void; +}) { + return ( + { + if (typeof enabled === "boolean") props.onEnabledChange(enabled); + }} + /> + ); +} + function DiscoveryItemRow({ item, children, @@ -275,6 +317,11 @@ function DiscoveryItemRow({ const [isExpanded, setIsExpanded] = useState(false); const hasDetails = children !== undefined; const searchTargetId = useSettingsSearchTargetId(); + const versionControlEnabled = useScopedSettings((settings) => settings.enableVersionControl); + const versionControlMixed = useScopedSettingsMixed(["enableVersionControl"]); + const updateSettings = useUpdateScopedSettings(); + const searchId = item.kind === "git" ? searchableSetting("version-control").id : undefined; + const searchTargetRef = useSettingsSearchTarget(searchId); useEffect(() => { if (item.kind === "git" && searchTargetId === searchableSetting("git-fetch-interval").id) { @@ -284,6 +331,9 @@ function DiscoveryItemRow({ return (

- {itemSummary({ item, auth, authAccount })} + {itemSummary({ + item, + auth, + authAccount, + versionControl: + item.kind === "git" + ? { enabled: versionControlEnabled, mixed: versionControlMixed } + : null, + })}

@@ -326,7 +384,16 @@ function DiscoveryItemRow({ ) : null} {!isVcsNotReady(item) ? ( - + item.kind === "git" ? ( + updateSettings({ enableVersionControl: next })} + /> + ) : ( + + ) ) : null}
diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 9bd4f9906acd..36fb06205e04 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -65,6 +65,7 @@ describe("searchSettings", () => { const localeLowerCase = vi.spyOn(String.prototype, "toLocaleLowerCase").mockReturnValue("gıt"); try { expect(searchSettings("GIT")[0]?.id).toBe("git-fetch-interval"); + expect(searchSettings("disable git")[0]?.id).toBe("version-control"); expect(localeLowerCase).not.toHaveBeenCalled(); } finally { localeLowerCase.mockRestore(); @@ -400,6 +401,14 @@ describe("settings search targets", () => { expect(isSettingsSearchScopeAvailable(model.scope, "project")).toBe(true); }); + it("reaches the version control switch from project scope", () => { + const item = getSettingsSearchTargetScope("version-control")!; + expect(isSettingsSearchScopeAvailable(item.scope, "project")).toBe(true); + expect(isSettingsSearchScopeAvailable(item.scope, "checkout")).toBe(true); + expect(isSettingsSearchScopeAvailable(item.scope, "all")).toBe(true); + expect(isSettingsSearchScopeAvailable(item.scope, "environment")).toBe(true); + }); + it("reaches source control discovery and git fetch interval from the default scope", () => { for (const id of ["source-control", "git-fetch-interval"]) { const item = getSettingsSearchTargetScope(id)!; diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index c9cb76401bd6..2270cfc80d52 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -676,6 +676,13 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "project-defaults", searchTerms: ["pull request merge squash rebase last selected"], }, + { + id: "version-control", + title: "Version control", + to: "/settings/source-control", + scope: "project-defaults", + searchTerms: ["disable git turn off skip status fetch commands"], + }, { id: "source-control", title: "Source control", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index e8dc37bfc839..cfb35338d6ae 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1011,6 +1011,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "newWorktreesStartFromOrigin", "worktreeSubmodules", "defaultAutoPull", + "enableVersionControl", "defaultProjectScripts", "enableAgentBrowserAccess", "enableAgentDeviceAccess", @@ -1038,6 +1039,7 @@ export const ProjectSettingsOverrides = Schema.Struct({ newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), worktreeSubmodules: ForwardCompatibleOptional(WorktreeSubmodules), defaultAutoPull: Schema.optionalKey(Schema.Boolean), + enableVersionControl: Schema.optionalKey(Schema.Boolean), defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)), enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean), @@ -1117,6 +1119,12 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed({})), ), defaultAutoPull: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + /** + * Whether Git is used for this environment. Off skips detection, status, + * fetch, auto-pull, and checkpoints. Projects can override it. Existing + * settings stay on. + */ + enableVersionControl: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), defaultProjectScripts: Schema.Array(ProjectScript).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), @@ -1487,6 +1495,7 @@ export const ServerSettingsPatch = Schema.Struct({ projectAutoPullOverrides: Schema.optionalKey( Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)), ), + enableVersionControl: Schema.optionalKey(Schema.Boolean), defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), defaultRuntimeMode: Schema.optionalKey(RuntimeMode), /** diff --git a/packages/shared/src/projectSettings.test.ts b/packages/shared/src/projectSettings.test.ts index f59156530aa5..b705ac12a79c 100644 --- a/packages/shared/src/projectSettings.test.ts +++ b/packages/shared/src/projectSettings.test.ts @@ -20,6 +20,28 @@ const projectId = ProjectId.make("project-a"); const otherProjectId = ProjectId.make("project-b"); describe("resolveProjectSettings", () => { + it("lets a project turn version control off without affecting other projects", () => { + expect(DEFAULT_SERVER_SETTINGS.enableVersionControl).toBe(true); + const settings = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + projectSettingsOverrides: { + [projectId]: { enableVersionControl: false }, + }, + }); + expect(resolveProjectSettings(settings, projectId).settings.enableVersionControl).toBe(false); + expect(resolveProjectSettings(settings, projectId).sources.enableVersionControl).toBe( + "project", + ); + expect(resolveProjectSettings(settings, otherProjectId).settings.enableVersionControl).toBe( + true, + ); + const environmentOff = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + enableVersionControl: false, + }); + expect(resolveProjectSettings(environmentOff, projectId).settings.enableVersionControl).toBe( + false, + ); + }); + it("inherits every scopable key when the project has no overrides", () => { const resolved = resolveProjectSettings(DEFAULT_SERVER_SETTINGS, projectId); expect(resolved.settings).toBe(DEFAULT_SERVER_SETTINGS);