From ef5bb950068135742957bc9746109a9236651de2 Mon Sep 17 00:00:00 2001 From: macodev00 <273427913+macodev00@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:10:52 +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. Co-authored-by: maco --- .../SettingsServerControlsRouteScreen.tsx | 12 +- .../OrchestrationEngineHarness.integration.ts | 2 + ...ProviderSessionStartup.integration.test.ts | 5 +- apps/server/scripts/evaluate-thread-titles.ts | 8 +- apps/server/src/bin.test.ts | 2 + .../src/checkpointing/CheckpointStore.test.ts | 6 +- apps/server/src/cli/project.ts | 7 +- .../Layers/CheckpointReactor.test.ts | 2 + .../Layers/OrchestrationEngine.test.ts | 5 + .../Layers/ProjectionPipeline.test.ts | 2 + .../Layers/ProjectionSnapshotQuery.test.ts | 2 + .../Layers/ProviderCommandReactor.test.ts | 2 + .../Layers/ProviderRuntimeIngestion.test.ts | 2 + .../src/project/AgentSessionImporter.test.ts | 2 + .../RepositoryIdentityResolver.test.ts | 44 ++- .../src/project/RepositoryIdentityResolver.ts | 11 +- 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 | 8 + .../src/vcs/VcsStatusBroadcaster.test.ts | 251 +++++++++++++++++- apps/server/src/vcs/VcsStatusBroadcaster.ts | 127 ++++++++- .../src/vcs/VersionControlPolicy.test.ts | 120 +++++++++ apps/server/src/vcs/VersionControlPolicy.ts | 203 ++++++++++++++ .../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 | 8 + packages/shared/src/projectSettings.test.ts | 22 ++ 32 files changed, 1009 insertions(+), 51 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..61ec98cd6556 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 })} + /> + { Layer.provide(ProviderSessionRuntime.layer), Layer.provide(persistence), ); - return Layer.mergeAll(orchestration, directory); + return Layer.mergeAll(orchestration, directory).pipe( + Layer.provide(VersionControlPolicy.layerTest), + ); }; const startupDependencies = Layer.mergeAll( diff --git a/apps/server/scripts/evaluate-thread-titles.ts b/apps/server/scripts/evaluate-thread-titles.ts index 78273a9ef790..29cf939127d9 100644 --- a/apps/server/scripts/evaluate-thread-titles.ts +++ b/apps/server/scripts/evaluate-thread-titles.ts @@ -30,6 +30,7 @@ import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts"; import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts"; import * as VcsProcess from "../src/vcs/VcsProcess.ts"; import * as VcsDriverRegistry from "../src/vcs/VcsDriverRegistry.ts"; +import * as VersionControlPolicy from "../src/vcs/VersionControlPolicy.ts"; import * as VcsProjectConfig from "../src/vcs/VcsProjectConfig.ts"; import * as GitVcsDriver from "../src/vcs/GitVcsDriver.ts"; import * as ProcessRunner from "../src/processRunner.ts"; @@ -158,7 +159,12 @@ await Effect.runPromise( BitbucketApi.layer, ), ), - Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))), + Layer.provide( + VcsDriverRegistry.layer.pipe( + Layer.provide(VcsProjectConfig.layer), + Layer.provide(VersionControlPolicy.layerTest), + ), + ), Layer.provide(GitVcsDriver.layer), Layer.provide(VcsProcess.layer), Layer.provide(FetchHttpClient.layer), diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index aecf82eeac67..79c94b6e0b4c 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -44,6 +44,7 @@ import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; +import * as VersionControlPolicy from "./vcs/VersionControlPolicy.ts"; import { makePersistedServerRuntimeState, persistServerRuntimeState, @@ -130,6 +131,7 @@ const makeProjectPersistenceLayer = (config: ServerConfig.ServerConfig["Service" Layer.mergeAll( OrchestrationLayerLive.pipe( Layer.provideMerge(RepositoryIdentityResolver.layer), + Layer.provide(VersionControlPolicy.layerTest), Layer.provideMerge(SqlitePersistenceLayerLive), ), WorkspacePaths.layer, diff --git a/apps/server/src/checkpointing/CheckpointStore.test.ts b/apps/server/src/checkpointing/CheckpointStore.test.ts index 5a9b5cc5d6b0..c56e4b37b5d6 100644 --- a/apps/server/src/checkpointing/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/CheckpointStore.test.ts @@ -16,6 +16,7 @@ import { checkpointRefForThreadTurn } from "./Utils.ts"; import { parseTurnDiffFilesFromNumstat } from "./Diffs.ts"; import * as CheckpointStore from "./CheckpointStore.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as VersionControlPolicy from "../vcs/VersionControlPolicy.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as ServerConfig from "../config.ts"; @@ -23,7 +24,10 @@ const ServerConfigLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-checkpoint-store-test-", }); const VcsProcessTestLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); -const VcsDriverTestLayer = VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcessTestLayer)); +const VcsDriverTestLayer = VcsDriverRegistry.layer.pipe( + Layer.provide(VcsProcessTestLayer), + Layer.provide(VersionControlPolicy.layerTest), +); const CheckpointStoreTestLayer = CheckpointStore.layer.pipe( Layer.provideMerge(VcsDriverTestLayer), Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index cef4dc879073..db7c9ac6e326 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -30,6 +30,7 @@ import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSn import { OrchestrationLayerLive } from "../orchestration/runtimeLayer.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; +import * as VersionControlPolicy from "../vcs/VersionControlPolicy.ts"; import { clearPersistedServerRuntimeState, readPersistedServerRuntimeState, @@ -197,10 +198,14 @@ const projectCommandUuid = Crypto.Crypto.pipe( ), ); +// Offline `t3 project` does not load server settings. The explicit test layer +// keeps Git available here; the running server uses VersionControlPolicy.layer. const ProjectCliRuntimeLive = Layer.mergeAll( WorkspacePaths.layer, OrchestrationLayerLive.pipe( - Layer.provideMerge(RepositoryIdentityResolver.layer), + Layer.provideMerge( + RepositoryIdentityResolver.layer.pipe(Layer.provide(VersionControlPolicy.layerTest)), + ), Layer.provideMerge(SqlitePersistenceLayerLive), ), ); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 1d0b3da1bdbc..ec6fc9828c39 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -41,6 +41,7 @@ import * as VcsDriverRegistry from "../../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../../vcs/VcsProcess.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; +import * as VersionControlPolicy from "../../vcs/VersionControlPolicy.ts"; import { CheckpointReactorLive } from "./CheckpointReactor.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; @@ -400,6 +401,7 @@ describe("CheckpointReactor", () => { Layer.provideMerge(VcsProcess.layer), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), + Layer.provide(VersionControlPolicy.layerTest), ); runtime = ManagedRuntime.make(layer); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 9ccf78ca1744..11211292ff2f 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -42,6 +42,7 @@ import { type OrchestrationEventStoreShape, } from "../../persistence/Services/OrchestrationEventStore.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; +import * as VersionControlPolicy from "../../vcs/VersionControlPolicy.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; @@ -92,6 +93,7 @@ function makeOrchestrationLayer( Layer.provide(persistence), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), + Layer.provide(VersionControlPolicy.layerTest), ); } @@ -1519,6 +1521,7 @@ describe("OrchestrationEngine", () => { Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), + Layer.provide(VersionControlPolicy.layerTest), Layer.provide(SqlitePersistenceMemory), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), @@ -1627,6 +1630,7 @@ describe("OrchestrationEngine", () => { Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), + Layer.provide(VersionControlPolicy.layerTest), Layer.provide(SqlitePersistenceMemory), Layer.provide(NodeServices.layer), ), @@ -1776,6 +1780,7 @@ describe("OrchestrationEngine", () => { Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), + Layer.provide(VersionControlPolicy.layerTest), Layer.provide(SqlitePersistenceMemory), Layer.provide(NodeServices.layer), ), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index e8c7154f38e7..9c4dc46c360b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -34,6 +34,7 @@ import { import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; import { ProjectionStateRepository } from "../../persistence/Services/ProjectionState.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; +import * as VersionControlPolicy from "../../vcs/VersionControlPolicy.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { ORCHESTRATION_PROJECTOR_NAMES, @@ -4423,6 +4424,7 @@ const engineLayer = it.layer( Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), + Layer.provide(VersionControlPolicy.layerTest), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge( ServerConfig.layerTest(process.cwd(), { diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index cfba90297633..59d003a341f5 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -23,6 +23,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; +import * as VersionControlPolicy from "../../vcs/VersionControlPolicy.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import * as ThreadBackgroundLiveness from "../ThreadBackgroundLiveness.ts"; @@ -105,6 +106,7 @@ const projectionSnapshotLayer = it.layer( Layer.provide(ThreadBackgroundLiveness.layer), Layer.provide(ThreadPlanProgress.layer), Layer.provideMerge(RepositoryIdentityResolver.layer), + Layer.provide(VersionControlPolicy.layerTest), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(NodeServices.layer), ), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 87f9bd03d46c..449ccaf5a081 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -57,6 +57,7 @@ import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegi import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; import { TerminalManager } from "../../terminal/Manager.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; +import * as VersionControlPolicy from "../../vcs/VersionControlPolicy.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; @@ -497,6 +498,7 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), + Layer.provide(VersionControlPolicy.layerTest), ); runtime = ManagedRuntime.make(layer); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 9b5b56309749..e07a3f622dd5 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"; @@ -344,6 +345,7 @@ describe("ProviderRuntimeIngestion", () => { Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(NodeServices.layer), Layer.provideMerge(Layer.succeed(Tracer.Tracer, sqlCounter.tracer)), + Layer.provide(VersionControlPolicy.layerTest), ); const testRuntime = ManagedRuntime.make(layer); runtime = testRuntime; diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 2438edca8b1b..ec92a9f77b0f 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -59,6 +59,7 @@ import { TextGeneration } from "../textGeneration/TextGeneration.ts"; import { TerminalManager } from "../terminal/Manager.ts"; import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; +import * as VersionControlPolicy from "../vcs/VersionControlPolicy.ts"; import { importRecentAgentThreads } from "./AgentSessionImporter.ts"; import * as AgentSessionScanner from "./AgentSessionScanner.ts"; @@ -576,6 +577,7 @@ const integrationLayer = Layer.mergeAll( Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), Layer.provide(RepositoryIdentityResolver.layer), + Layer.provide(VersionControlPolicy.layerTest), Layer.provide(SqlitePersistenceMemory), Layer.provideMerge(integrationServerConfig), Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index 58f199b834e2..fa82e4697731 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -12,6 +12,7 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne import { TestClock } from "effect/testing"; import * as ProcessRunner from "../processRunner.ts"; +import * as VersionControlPolicy from "../vcs/VersionControlPolicy.ts"; import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; const normalizePathSeparators = (value: string) => value.replaceAll("\\", "/"); @@ -36,7 +37,11 @@ const makeRepositoryIdentityResolverTestLayer = (options: { cacheCapacity: 16, ...options, }), - ).pipe(Layer.provide(ProcessRunner.layer)); + ).pipe(Layer.provide(ProcessRunner.layer), Layer.provide(VersionControlPolicy.layerTest)); + +const repositoryIdentityResolverLayer = RepositoryIdentityResolver.layer.pipe( + Layer.provide(VersionControlPolicy.layerTest), +); it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { it.effect("refreshes the Git root only when requested", () => { @@ -88,7 +93,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { ); }, }), - ).pipe(Layer.provide(processRunner)); + ).pipe(Layer.provide(processRunner), Layer.provide(VersionControlPolicy.layerTest)); return Effect.gen(function* () { const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; @@ -156,7 +161,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { const resolverLayer = Layer.effect( RepositoryIdentityResolver.RepositoryIdentityResolver, RepositoryIdentityResolver.make(), - ).pipe(Layer.provide(processRunner)); + ).pipe(Layer.provide(processRunner), Layer.provide(VersionControlPolicy.layerTest)); return Effect.gen(function* () { const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; @@ -199,7 +204,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(identity?.provider).toBe("github"); expect(identity?.owner).toBe("t3tools"); expect(identity?.name).toBe("t3code"); - }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), + }).pipe(Effect.provide(repositoryIdentityResolverLayer)), ); it.effect("returns the git top-level root path when resolving from a nested workspace", () => @@ -226,7 +231,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(normalizeResolvedPath(resolvedIdentityRoot)).toBe( normalizeResolvedPath(resolvedRepoRoot), ); - }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), + }).pipe(Effect.provide(repositoryIdentityResolverLayer)), ); it.effect("returns null for non-git folders and repos without remotes", () => @@ -247,7 +252,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(nonGitIdentity).toBeNull(); expect(noRemoteIdentity).toBeNull(); - }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), + }).pipe(Effect.provide(repositoryIdentityResolverLayer)), ); it.effect.each(["add", "replace"] as const)( @@ -285,7 +290,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); expect(identity?.displayName).toBe("t3tools/t3code"); expect(yield* resolver.resolve(cwd)).toEqual(identity); - }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), + }).pipe(Effect.provide(repositoryIdentityResolverLayer)), ); it.effect("uses the last remote path segment as the repository name for nested groups", () => @@ -306,7 +311,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { expect(identity?.displayName).toBe("t3tools/platform/t3code"); expect(identity?.owner).toBe("t3tools"); expect(identity?.name).toBe("t3code"); - }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), + }).pipe(Effect.provide(repositoryIdentityResolverLayer)), ); it.effect( @@ -390,4 +395,27 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { ), ), ); + + it.effect("does not spawn Git when version control is disabled", () => { + const resolverLayer = Layer.effect( + RepositoryIdentityResolver.RepositoryIdentityResolver, + RepositoryIdentityResolver.make(), + ).pipe( + Layer.provide( + Layer.succeed(ProcessRunner.ProcessRunner, { + run: () => Effect.die("git should not run when version control is disabled"), + }), + ), + Layer.provide( + Layer.succeed(VersionControlPolicy.VersionControlPolicy, { + isEnabled: () => Effect.succeed(false), + }), + ), + ); + + return Effect.gen(function* () { + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + expect(yield* resolver.resolve("/repo")).toBeNull(); + }).pipe(Effect.provide(resolverLayer)); + }); }); diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 5acafa47e2e2..f978e03f1a7f 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -11,6 +11,7 @@ import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as ProcessRunner from "../processRunner.ts"; +import * as VersionControlPolicy from "../vcs/VersionControlPolicy.ts"; const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512; // Background sweeps resolve every project each minute. A long TTL keeps them @@ -144,6 +145,7 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( options: RepositoryIdentityResolverOptions = {}, ) { const processRunner = yield* ProcessRunner.ProcessRunner; + const versionControl = yield* VersionControlPolicy.VersionControlPolicy; const cacheCapacity = options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY; const refine = options.refine ?? Effect.succeed; // Git errors and timeouts resolve to null, so they use the negative TTL like @@ -177,10 +179,15 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( { capacity: cacheCapacity, timeToLive }, ); - // Untraced because almost every call is a cache hit. The lookups that spawn - // git keep their own spans. + /** + * Resolves repository identity. Version control off returns null before any + * Git process is spawned and does not cache that miss. + * Untraced because almost every call is a cache hit. The lookups that spawn + * git keep their own spans. + */ const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fnUntraced( function* (cwd, options) { + if (!(yield* versionControl.isEnabled(cwd))) return null; if (options?.refresh) yield* Cache.invalidate(repositoryRootCache, cwd); const cacheKey = yield* Cache.get(repositoryRootCache, cwd); if (cacheKey === null) return null; 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..466ccd5ff2b7 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -106,6 +106,7 @@ import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; +import * as VersionControlPolicy from "./vcs/VersionControlPolicy.ts"; import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.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), + // Git detection, status, and identity reads 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..54bb852e5565 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,14 @@ export const make = Effect.gen(function* () { }, ); + /** Detects a repository, or reports none when version control is off for `cwd`. */ const detect: VcsDriverRegistry["Service"]["detect"] = Effect.fn("VcsDriverRegistry.detect")( function* (input) { + // Off means there is no repository: status, fetch, worktrees, and + // checkpoints stop before they spawn Git. + 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..c6c86ad1215c 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 captured when the broadcaster started. */ + 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` in the cache 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 and after Git so a disable + * that lands mid-pull does not publish a repository snapshot. + */ 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,11 +620,17 @@ export const make = Effect.gen(function* () { { cwd }, { refreshUpstream: false, refreshMissingPullRequest: true }, ); + if (yield* stopWhenVersionControlDisabled(cwd)) return null; return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); }), ); }); + /** + * 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 makeRemoteRefreshLoop = ( cwd: string, demandCwdsRef: Ref.Ref>, @@ -533,8 +645,15 @@ export const make = Effect.gen(function* () { 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 +813,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..ad40672abe4e --- /dev/null +++ b/apps/server/src/vcs/VersionControlPolicy.ts @@ -0,0 +1,203 @@ +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 a path that starts + * 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 or any project 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 and ignore a project override. + */ +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 worktree paths that own a setting. */ + const readRoots = Effect.gen(function* () { + const [projects, worktrees] = 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 })), + ...worktrees.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 for a short interval 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 and entry points 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..c808224d52a3 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -10,6 +10,7 @@ import * as Path from "effect/Path"; import * as ServerConfig from "../config.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +import * as VersionControlPolicy from "../vcs/VersionControlPolicy.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as WorkspaceEntries from "./WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./WorkspaceFileSystem.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..c9737afc0dcc 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,11 @@ 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, so status, + * fetch, worktrees, and checkpoints do not run Git. Projects can override it. + */ + enableVersionControl: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), defaultProjectScripts: Schema.Array(ProjectScript).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), @@ -1480,6 +1487,7 @@ export const ServerSettingsPatch = Schema.Struct({ Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)), ), defaultAutoPull: Schema.optionalKey(Schema.Boolean), + enableVersionControl: Schema.optionalKey(Schema.Boolean), defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)), projectScriptOverrides: Schema.optionalKey( Schema.Record(ProjectId, Schema.NullOr(Schema.Array(ProjectScript))), 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);