From 3b85ca18711598fbbea10e1b95a5db50dbcf6658 Mon Sep 17 00:00:00 2001 From: macodev00 <273427913+macodev00@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:01:17 +0000 Subject: [PATCH] fix(settings): let users turn version control off The Source Control Git switch was a read-only availability indicator, so huge or slow repositories could not stop Git work. The switch now writes enableVersionControl, and detection, status, identity sweeps, and auto-pull skip Git when it is off. --- .../SettingsServerControlsRouteScreen.tsx | 12 +- .../RepositoryIdentityResolver.test.ts | 24 +++ .../src/project/RepositoryIdentityResolver.ts | 3 + apps/server/src/server.ts | 4 + apps/server/src/serverRuntimeStartup.test.ts | 45 +++-- apps/server/src/serverRuntimeStartup.ts | 5 +- apps/server/src/vcs/VcsDriverRegistry.test.ts | 35 ++++ apps/server/src/vcs/VcsDriverRegistry.ts | 7 + .../src/vcs/VcsStatusBroadcaster.test.ts | 49 ++++++ apps/server/src/vcs/VcsStatusBroadcaster.ts | 72 +++++++- .../src/vcs/VersionControlPolicy.test.ts | 27 +++ apps/server/src/vcs/VersionControlPolicy.ts | 157 ++++++++++++++++++ .../settings/SourceControlSettings.tsx | 71 +++++++- .../settings/settingsSearch.test.ts | 9 + .../src/components/settings/settingsSearch.ts | 7 + packages/contracts/src/settings.ts | 8 + packages/shared/src/projectSettings.test.ts | 22 +++ 17 files changed, 534 insertions(+), 23 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 })} + /> + value.replaceAll("\\", "/"); @@ -390,4 +391,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"), + }), + ), + ); + + return Effect.gen(function* () { + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + expect(yield* resolver.resolve("/repo")).toBeNull(); + }).pipe( + Effect.provide(resolverLayer), + Effect.provideService(VersionControlPolicy.VersionControlPolicy, { + isEnabled: () => Effect.succeed(false), + }), + ); + }); }); diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 5acafa47e2e2..016597f5117f 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 @@ -181,6 +182,8 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( // git keep their own spans. const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fnUntraced( function* (cwd, options) { + const versionControl = yield* VersionControlPolicy.VersionControlPolicy; + 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.ts b/apps/server/src/server.ts index 88c08fd3bdee..0bec46af945c 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"; @@ -508,6 +509,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(ProviderAuthServiceLive), // Core Services Layer.provideMerge(ServerSettingsLayerLive), + // Git detection reads this from the server context. The reference default + // stays "on" for tests and the CLI. + Layer.provideMerge(VersionControlPolicy.layer), Layer.provideMerge(CheckpointingLayerLive), // `GitHubCli` is the registry's own instance, exposed because the asset route fetches // GitHub-hosted pull request media with the repository's credential. 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..fd01e61de325 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -842,7 +842,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..799ac325257d 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 => ({ @@ -137,4 +138,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"); + }), + }), + ), + ); + + 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), + Effect.provideService(VersionControlPolicy.VersionControlPolicy, { + isEnabled: () => Effect.succeed(false), + }), + ); + }); }); diff --git a/apps/server/src/vcs/VcsDriverRegistry.ts b/apps/server/src/vcs/VcsDriverRegistry.ts index f40e1dadea3e..04792fde7e39 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"; @@ -124,6 +125,12 @@ export const make = Effect.gen(function* () { const detect: VcsDriverRegistry["Service"]["detect"] = Effect.fn("VcsDriverRegistry.detect")( function* (input) { + // Off means "there is no repository": status, fetch, worktrees, and + // checkpoints all stop before they spawn Git. + const versionControl = yield* VersionControlPolicy.VersionControlPolicy; + 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..376970fc4bcc 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"; @@ -942,6 +943,54 @@ describe("VcsStatusBroadcaster", () => { }).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)), + Effect.provideService(VersionControlPolicy.VersionControlPolicy, { + isEnabled: () => Effect.succeed(versionControlEnabled), + }), + ); + }); + it.effect("stops the remote poller after the last stream subscriber disconnects", () => { const state = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index ae9abdd0cc4e..9e25566fa98e 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); @@ -360,9 +374,39 @@ export const make = Effect.gen(function* () { return yield* updateCachedLocalStatus(cwd, local); }); + const versionControlEnabled = (cwd: string) => + Effect.flatMap(VersionControlPolicy.VersionControlPolicy, (policy) => policy.isEnabled(cwd)); + + // Drop any cached repo snapshot. Leaving `isRepo: false` in the cache would + // keep status, streams, and refreshes from calling Git after the setting is + // turned back on. + 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, + }, + }); + }); + const getOrLoadLocalStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadLocalStatus")(function* ( cwd: string, ) { + if (!(yield* versionControlEnabled(cwd))) { + yield* forgetCachedStatus(cwd); + return DISABLED_LOCAL_STATUS; + } const cached = yield* getCachedStatus(cwd); if (cached?.local) { return cached.local.value; @@ -376,6 +420,10 @@ export const make = Effect.gen(function* () { "VcsStatusBroadcaster.getStatus", )(function* (input) { const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); + if (!(yield* versionControlEnabled(cwd))) { + yield* forgetCachedStatus(cwd); + return mergeGitStatusParts(DISABLED_LOCAL_STATUS, null); + } const cached = yield* getCachedStatus(cwd); if (cached?.local && cached.remote) { return mergeGitStatusParts(cached.local.value, cached.remote.value); @@ -408,6 +456,10 @@ export const make = Effect.gen(function* () { "VcsStatusBroadcaster.refreshLocalStatus", )(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + if (!(yield* versionControlEnabled(cwd))) { + yield* forgetCachedStatus(cwd); + return DISABLED_LOCAL_STATUS; + } return yield* refreshLocalStatusCore(cwd); }); @@ -459,6 +511,10 @@ export const make = Effect.gen(function* () { return yield* withRemoteWriteLock( cwd, Effect.gen(function* () { + if (!(yield* versionControlEnabled(cwd))) { + yield* forgetCachedStatus(cwd); + return null; + } if (options?.refreshUpstream !== false) { yield* workflow.invalidateRemoteStatus(cwd); } @@ -474,6 +530,10 @@ export const make = Effect.gen(function* () { "VcsStatusBroadcaster.refreshStatus", )(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + if (!(yield* versionControlEnabled(cwd))) { + yield* forgetCachedStatus(cwd); + return mergeGitStatusParts(DISABLED_LOCAL_STATUS, null); + } // invalidateStatus (not the two partial invalidations) so an explicit // refresh also bypasses GitManager's slow PR-lookup cache. return yield* withRemoteWriteLock( @@ -494,6 +554,10 @@ export const make = Effect.gen(function* () { const refreshPullRequestStatus: VcsStatusBroadcaster["Service"]["refreshPullRequestStatus"] = Effect.fn("VcsStatusBroadcaster.refreshPullRequestStatus")(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + if (!(yield* versionControlEnabled(cwd))) { + yield* forgetCachedStatus(cwd); + return null; + } return yield* withRemoteWriteLock( cwd, Effect.gen(function* () { @@ -698,10 +762,14 @@ export const make = Effect.gen(function* () { Stream.unwrap( Effect.gen(function* () { const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); + const enabled = yield* versionControlEnabled(cwd); + if (!enabled) { + yield* forgetCachedStatus(cwd); + } const subscription = yield* PubSub.subscribe(changesPubSub); - const initialLocal = yield* getOrLoadLocalStatus(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..92ae8e9bc189 --- /dev/null +++ b/apps/server/src/vcs/VersionControlPolicy.test.ts @@ -0,0 +1,27 @@ +import { ProjectId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Path from "effect/Path"; + +import { projectIdForCwd } from "./VersionControlPolicy.ts"; + +it.effect("projectIdForCwd picks the longest root that contains the cwd", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const project = ProjectId.make("project"); + const nested = ProjectId.make("nested"); + const worktree = ProjectId.make("worktree"); + const roots = [ + { projectId: project, path: "/repo" }, + { projectId: nested, path: "/repo/app" }, + { projectId: worktree, path: "/tmp/worktrees/feature" }, + ]; + + assert.strictEqual(projectIdForCwd(path, "/repo/src", roots), project); + assert.strictEqual(projectIdForCwd(path, "/repo/app/src", roots), nested); + assert.strictEqual(projectIdForCwd(path, "/tmp/worktrees/feature/packages", roots), worktree); + assert.strictEqual(projectIdForCwd(path, "/repo-other", roots), null); + assert.strictEqual(projectIdForCwd(path, "/elsewhere", roots), null); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/vcs/VersionControlPolicy.ts b/apps/server/src/vcs/VersionControlPolicy.ts new file mode 100644 index 000000000000..1f26ae058fe0 --- /dev/null +++ b/apps/server/src/vcs/VersionControlPolicy.ts @@ -0,0 +1,157 @@ +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 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; +} + +const EMPTY_ROOTS: ReadonlyArray = []; + +interface VersionControlPaths { + readonly resolve: (path: string) => string; + readonly relative: (from: string, to: string) => string; + readonly isAbsolute: (path: string) => boolean; +} + +/** Longest configured root that contains `cwd`. A nested checkout wins over its parent. */ +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 === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); + if (!inside) continue; + if (best === null || resolved.length > best.length) { + best = { projectId: root.projectId, length: resolved.length }; + } + } + return best?.projectId ?? null; +} + +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; +} + +export class VersionControlPolicy extends Context.Reference<{ + readonly isEnabled: (cwd: string) => Effect.Effect; +}>("t3/vcs/VersionControlPolicy", { + defaultValue: () => ({ + isEnabled: () => Effect.succeed(true), + }), +}) {} + +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); + + 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)]), + ); + + 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(); + }); + + 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; + }); + + const isEnabled = (cwd: string): Effect.Effect => + Effect.gen(function* () { + const settings = yield* settingsService.getSettings.pipe( + Effect.orElseSucceed(() => DEFAULT_SERVER_SETTINGS), + ); + // The usual case is "Git on everywhere"; don't touch the database then. + if (!hasVersionControlOptOut(settings)) return true; + const roots = yield* loadRoots.pipe(Effect.orElseSucceed(() => EMPTY_ROOTS)); + const projectId = projectIdForCwd(path, cwd, roots); + return resolveProjectSettings(settings, projectId).settings.enableVersionControl; + }); + + return { isEnabled }; +}); + +export const layer = Layer.effect(VersionControlPolicy, make); diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 10f86084ea20..151f1ad6c44f 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"; @@ -206,11 +211,27 @@ 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 +279,25 @@ function itemSummary({ return Available; } +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 +315,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 +329,9 @@ function DiscoveryItemRow({ return (

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

@@ -326,7 +382,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);