From 724f1a3ee430f2bbadd2bb714b83ea99cf82cf93 Mon Sep 17 00:00:00 2001 From: macodev00 <273427913+macodev00@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:50:17 +0000 Subject: [PATCH] fix(settings): let users turn version control off The Source Control switch showed Git as enabled and could not be changed, so slow repositories kept running status, fetch, and checkpoints. The switch now writes enableVersionControl, and Git work stops while it is off. Turning it back on publishes a full status snapshot. --- .../SettingsServerControlsRouteScreen.tsx | 12 +- .../OrchestrationEngineHarness.integration.ts | 10 +- apps/server/scripts/evaluate-thread-titles.ts | 8 +- .../src/checkpointing/CheckpointStore.test.ts | 6 +- .../Layers/CheckpointReactor.test.ts | 13 +- .../Layers/ProviderRuntimeIngestion.test.ts | 10 +- apps/server/src/server.test.ts | 13 +- apps/server/src/server.ts | 8 + apps/server/src/serverRuntimeStartup.test.ts | 45 ++-- apps/server/src/serverRuntimeStartup.ts | 6 +- apps/server/src/vcs/VcsDriverRegistry.test.ts | 38 +++ apps/server/src/vcs/VcsDriverRegistry.ts | 9 + .../src/vcs/VcsStatusBroadcaster.test.ts | 251 +++++++++++++++++- apps/server/src/vcs/VcsStatusBroadcaster.ts | 128 ++++++++- .../src/vcs/VersionControlPolicy.test.ts | 120 +++++++++ apps/server/src/vcs/VersionControlPolicy.ts | 209 +++++++++++++++ .../src/workspace/WorkspaceFileSystem.test.ts | 8 +- .../settings/SourceControlSettings.tsx | 73 ++++- .../settings/settingsSearch.test.ts | 9 + .../src/components/settings/settingsSearch.ts | 7 + packages/contracts/src/settings.ts | 9 + packages/shared/src/projectSettings.test.ts | 22 ++ 22 files changed, 970 insertions(+), 44 deletions(-) create mode 100644 apps/server/src/vcs/VersionControlPolicy.test.ts create mode 100644 apps/server/src/vcs/VersionControlPolicy.ts 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);