Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const PAGE_TITLES: Record<SettingsPage, string> = {

const PAGE_PROJECT_KEYS: Record<SettingsPage, readonly ProjectScopedServerSettingKey[]> = {
"new-threads": ["defaultThreadEnvMode", "worktreeSubmodules", "defaultRuntimeMode"],
"source-control": ["defaultAutoPull", "newWorktreesStartFromOrigin"],
"source-control": ["enableVersionControl", "defaultAutoPull", "newWorktreesStartFromOrigin"],
"agent-behavior": ["responseStreamingMode", "enableAgentBrowserAccess"],
maintenance: ["continueThreadsAfterServerUpdate"],
};
Expand Down Expand Up @@ -325,6 +325,16 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {

{props.page === "source-control" ? (
<>
<SettingsSection title="Version control">
<FanoutSwitchRow
icon="arrow.triangle.branch"
label="Git"
subtitle="Turn off to skip Git status, fetch, and other Git commands."
value={uniform("enableVersionControl")}
disabled={disabledFor("enableVersionControl")}
onValueChange={(value) => write({ enableVersionControl: value })}
/>
</SettingsSection>
<SettingsSection title="Default branch">
<FanoutSwitchRow
icon="arrow.down.circle"
Expand Down
24 changes: 24 additions & 0 deletions apps/server/src/project/RepositoryIdentityResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("\\", "/");
Expand Down Expand Up @@ -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),
}),
);
});
});
3 changes: 3 additions & 0 deletions apps/server/src/project/RepositoryIdentityResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down
45 changes: 29 additions & 16 deletions apps/server/src/serverRuntimeStartup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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"),
Expand All @@ -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(
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/serverRuntimeStartup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
),
];
Expand Down
35 changes: 35 additions & 0 deletions apps/server/src/vcs/VcsDriverRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => ({
Expand Down Expand Up @@ -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),
}),
);
});
});
7 changes: 7 additions & 0 deletions apps/server/src/vcs/VcsDriverRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 }));
},
Expand Down
49 changes: 49 additions & 0 deletions apps/server/src/vcs/VcsStatusBroadcaster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading