Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
f795100
Add per-project settings and remote overrides
shivamhwp May 6, 2026
c5048e6
Add per-project settings navigation and remote details
shivamhwp May 7, 2026
2d09e73
Add per-project settings for scripts, env, and model defaults
shivamhwp May 7, 2026
63c54ff
Merge branch 'main' into per-project-settings
shivamhwp May 7, 2026
fc9d5ac
Add project settings sync for keybindings and env keys
shivamhwp May 7, 2026
048f9d6
Show detected project remote in settings
shivamhwp May 7, 2026
9936e01
Apply project defaults to new drafts and settings
shivamhwp May 7, 2026
64c695d
Merge upstream main into per-project-settings
shivamhwp May 7, 2026
0dbfb1b
Address project settings review feedback
shivamhwp May 7, 2026
78d4b8b
Add atomic settings updates for project patches
shivamhwp May 8, 2026
f87111b
hm
shivamhwp May 8, 2026
ee12985
Reserve T3CODE env vars for project scripts
shivamhwp May 8, 2026
b335cad
Sync project keybindings through the shared helper
shivamhwp May 8, 2026
926a605
Add hosted web deploy and tighten effect imports
shivamhwp May 8, 2026
4f7b8c0
Simplify project remote settings display
shivamhwp May 9, 2026
2640720
Add per-project automatic Git fetch interval
shivamhwp May 9, 2026
74f5678
Add per-project provider settings
shivamhwp May 9, 2026
0f2ff8c
Merge remote-tracking branch 'upstream/main' into per-project-settings
shivamhwp May 9, 2026
d232e07
Deduplicate empty project settings
shivamhwp May 9, 2026
50a5460
Merge branch 'main' into per-project-settings
shivamhwp May 10, 2026
6792721
Merge branch 'main' into per-project-settings
shivamhwp May 11, 2026
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
46 changes: 45 additions & 1 deletion apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
GitActionProgressEvent,
GitPreparePullRequestThreadInput,
ModelSelection,
SourceControlProviderInfo,
ThreadId,
} from "@t3tools/contracts";

Expand Down Expand Up @@ -649,6 +650,8 @@ function preparePullRequestThread(

function makeManager(input?: {
ghScenario?: FakeGhScenario;
sourceControlProviderContext?: SourceControlProviderRegistry.SourceControlProviderHandle["context"];
sourceControlProviderContextSource?: SourceControlProviderRegistry.SourceControlProviderHandle["contextSource"];
textGeneration?: Partial<FakeGitTextGeneration>;
setupScriptRunner?: ProjectSetupScriptRunnerShape;
}) {
Expand All @@ -671,7 +674,12 @@ function makeManager(input?: {
Effect.map((provider) =>
SourceControlProviderRegistry.SourceControlProviderRegistry.of({
get: () => Effect.succeed(provider),
resolveHandle: () => Effect.succeed({ provider, context: null }),
resolveHandle: () =>
Effect.succeed({
provider,
context: input?.sourceControlProviderContext ?? null,
contextSource: input?.sourceControlProviderContextSource ?? null,
}),
resolve: () => Effect.succeed(provider),
discover: Effect.succeed([]),
}),
Expand Down Expand Up @@ -706,6 +714,18 @@ const GitManagerTestLayer = GitVcsDriver.layer.pipe(
Layer.provideMerge(NodeServices.layer),
);

const githubProvider = {
kind: "github",
name: "GitHub",
baseUrl: "https://github.com",
} satisfies SourceControlProviderInfo;

const gitlabProvider = {
kind: "gitlab",
name: "GitLab",
baseUrl: "https://gitlab.com",
} satisfies SourceControlProviderInfo;

it.layer(GitManagerTestLayer)("GitManager", (it) => {
it.effect("status includes PR metadata when branch already has an open PR", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -749,6 +769,30 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
}),
);

it.effect("status prefers branch remote over detected provider context", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
yield* runGit(repoDir, ["remote", "add", "origin", "git@gitlab.com:pingdotgg/t3code.git"]);
yield* runGit(repoDir, ["remote", "add", "upstream", "git@github.com:pingdotgg/t3code.git"]);
yield* runGit(repoDir, ["checkout", "-b", "branch-remote"]);
yield* runGit(repoDir, ["config", "branch.branch-remote.remote", "upstream"]);

const { manager } = yield* makeManager({
sourceControlProviderContext: {
provider: gitlabProvider,
remoteName: "origin",
remoteUrl: "git@gitlab.com:pingdotgg/t3code.git",
},
sourceControlProviderContextSource: "detected",
});

const status = yield* manager.localStatus({ cwd: repoDir });

expect(status.sourceControlProvider).toEqual(githubProvider);
}),
);

it.effect("status trims PR metadata returned by gh before publishing it", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
Expand Down
21 changes: 20 additions & 1 deletion apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,18 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
cwd: string,
branch: string | null,
) {
const providerHandle = yield* sourceControlProviders.resolveHandle({ cwd }).pipe(
Effect.catch(() =>
Effect.succeed({
context: null,
contextSource: null,
}),
),
);
if (providerHandle.contextSource === "override" && providerHandle.context) {
return providerHandle.context.provider;
}
Comment thread
shivamhwp marked this conversation as resolved.

const preferredRemoteName =
branch === null
? "origin"
Expand All @@ -796,7 +808,14 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () {
(yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ??
(yield* readConfigValueNullable(cwd, "remote.origin.url"));

return remoteUrl ? detectSourceControlProviderFromGitRemoteUrl(remoteUrl) : null;
const providerFromBranchRemote = remoteUrl
? detectSourceControlProviderFromGitRemoteUrl(remoteUrl)
: null;
if (providerFromBranchRemote) {
return providerFromBranchRemote;
}

return providerHandle.context?.provider ?? null;
});

const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* (
Expand Down
17 changes: 17 additions & 0 deletions apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as Option from "effect/Option";
import { describe, expect, it, vi } from "vitest";

import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { TerminalManager } from "../../terminal/Services/Manager.ts";
import { ProjectSetupScriptRunner } from "../Services/ProjectSetupScriptRunner.ts";
import { ProjectSetupScriptRunnerLive } from "./ProjectSetupScriptRunner.ts";
Expand Down Expand Up @@ -50,6 +51,7 @@ describe("ProjectSetupScriptRunner", () => {
Effect.provide(
ProjectSetupScriptRunnerLive.pipe(
Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)),
Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(
Layer.succeed(TerminalManager, {
open,
Expand Down Expand Up @@ -109,6 +111,20 @@ describe("ProjectSetupScriptRunner", () => {
Effect.provide(
ProjectSetupScriptRunnerLive.pipe(
Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)),
Layer.provideMerge(
ServerSettingsService.layerTest({
projectSettings: {
[project.id]: {
remoteOverride: null,
automaticGitFetchInterval: null,
actionEnvironment: {
API_BASE_URL: "https://api.example.test",
},
disabledProviderInstanceIds: [],
},
},
}),
),
Layer.provideMerge(
Layer.succeed(TerminalManager, {
open,
Expand Down Expand Up @@ -146,6 +162,7 @@ describe("ProjectSetupScriptRunner", () => {
cwd: "/repo/worktrees/a",
worktreePath: "/repo/worktrees/a",
env: {
API_BASE_URL: "https://api.example.test",
T3CODE_PROJECT_ROOT: "/repo/project",
T3CODE_WORKTREE_PATH: "/repo/worktrees/a",
},
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/project/Layers/ProjectSetupScriptRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as Layer from "effect/Layer";
import * as Option from "effect/Option";

import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { TerminalManager } from "../../terminal/Services/Manager.ts";
import {
type ProjectSetupScriptRunnerShape,
Expand All @@ -14,6 +15,7 @@ import {

const makeProjectSetupScriptRunner = Effect.gen(function* () {
const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
const serverSettings = yield* ServerSettingsService;
const terminalManager = yield* TerminalManager;

const runForThread: ProjectSetupScriptRunnerShape["runForThread"] = (input) =>
Expand Down Expand Up @@ -46,9 +48,12 @@ const makeProjectSetupScriptRunner = Effect.gen(function* () {

const terminalId = input.preferredTerminalId ?? `setup-${script.id}`;
const cwd = input.worktreePath;
const settings = yield* serverSettings.getSettings;
const actionEnvironment = settings.projectSettings[project.id]?.actionEnvironment ?? {};
const env = projectScriptRuntimeEnv({
project: { cwd: project.workspaceRoot },
worktreePath: input.worktreePath,
extraEnv: actionEnvironment,
});

yield* terminalManager.open({
Expand Down
47 changes: 38 additions & 9 deletions apps/server/src/provider/Layers/ProviderRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type ServerProvider,
type ServerProviderSlashCommand,
type ServerSettings as ContractServerSettings,
type ServerSettingsPatch,
} from "@t3tools/contracts";
import * as PlatformError from "effect/PlatformError";
import { HttpClient, HttpClientResponse } from "effect/unstable/http";
Expand Down Expand Up @@ -278,19 +279,47 @@ function makeMutableServerSettingsService(
const settingsRef = yield* Ref.make(initial);
const changes = yield* PubSub.unbounded<ContractServerSettings>();

const commitSettings = (makePatch: (current: ContractServerSettings) => ServerSettingsPatch) =>
Effect.gen(function* () {
const current = yield* Ref.get(settingsRef);
const next = decodeServerSettings(
encodeServerSettings(applyServerSettingsPatch(current, makePatch(current))),
);
yield* Ref.set(settingsRef, next);
yield* PubSub.publish(changes, next);
return next;
});
Comment thread
cursor[bot] marked this conversation as resolved.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test helper lacks write semaphore unlike production code

Low Severity

The commitSettings helper in makeMutableServerSettingsService performs a non-atomic read-then-write without a semaphore. Both the production ServerSettingsService implementation and the layerTest helper use writeSemaphore.withPermits(1) to serialize writes and prevent lost updates during concurrent calls, but this test helper omits that protection.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 50a5460. Configure here.


return {
start: Effect.void,
ready: Effect.void,
getSettings: Ref.get(settingsRef),
updateSettings: (patch) =>
Effect.gen(function* () {
const current = yield* Ref.get(settingsRef);
const next = applyServerSettingsPatch(current, patch);
encodeServerSettings(next);
yield* Ref.set(settingsRef, next);
yield* PubSub.publish(changes, next);
return next;
}),
updateSettings: (patch) => commitSettings(() => patch),
updateProjectSettings: (projectId, patch) =>
commitSettings((settings) => ({
projectSettings: {
...settings.projectSettings,
[projectId]: {
...(settings.projectSettings[projectId] ?? {
remoteOverride: null,
automaticGitFetchInterval: null,
actionEnvironment: {},
disabledProviderInstanceIds: [],
}),
...patch,
},
},
})).pipe(
Effect.map(
(settings) =>
settings.projectSettings[projectId] ?? {
remoteOverride: null,
automaticGitFetchInterval: null,
actionEnvironment: {},
disabledProviderInstanceIds: [],
},
),
),
get streamChanges() {
return Stream.fromPubSub(changes);
},
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,13 @@ const buildAppUnderTest = (options?: {
ready: Effect.void,
getSettings: Effect.succeed(DEFAULT_SERVER_SETTINGS),
updateSettings: () => Effect.succeed(DEFAULT_SERVER_SETTINGS),
updateProjectSettings: () =>
Effect.succeed({
remoteOverride: null,
automaticGitFetchInterval: null,
actionEnvironment: {},
disabledProviderInstanceIds: [],
}),
streamChanges: Stream.empty,
...options?.layers?.serverSettings,
}),
Expand Down
8 changes: 3 additions & 5 deletions apps/server/src/serverRuntimeStartup.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { DEFAULT_MODEL, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
import { ProjectId, ThreadId } from "@t3tools/contracts";
import { createDefaultModelSelection } from "@t3tools/shared/model";
import { assert, it } from "@effect/vitest";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
Expand All @@ -25,10 +26,7 @@ import {
} from "./serverRuntimeStartup.ts";

it("uses the canonical Codex default for auto-bootstrapped model selection", () => {
assert.deepStrictEqual(getAutoBootstrapDefaultModelSelection(), {
instanceId: ProviderInstanceId.make("codex"),
model: DEFAULT_MODEL,
});
assert.deepStrictEqual(getAutoBootstrapDefaultModelSelection(), createDefaultModelSelection());
});

it.effect("enqueueCommand waits for readiness and then drains queued work", () =>
Expand Down
9 changes: 3 additions & 6 deletions apps/server/src/serverRuntimeStartup.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import {
CommandId,
DEFAULT_MODEL,
DEFAULT_PROVIDER_INTERACTION_MODE,
type ModelSelection,
ProjectId,
ProviderInstanceId,
ThreadId,
} from "@t3tools/contracts";
import { createDefaultModelSelection } from "@t3tools/shared/model";
import * as Data from "effect/Data";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -153,10 +152,8 @@ export const launchStartupHeartbeat = recordStartupHeartbeat.pipe(
Effect.asVoid,
);

export const getAutoBootstrapDefaultModelSelection = (): ModelSelection => ({
instanceId: ProviderInstanceId.make("codex"),
model: DEFAULT_MODEL,
});
export const getAutoBootstrapDefaultModelSelection = (): ModelSelection =>
createDefaultModelSelection();

export const resolveWelcomeBase = Effect.gen(function* () {
const serverConfig = yield* ServerConfig;
Expand Down
25 changes: 25 additions & 0 deletions apps/server/src/serverSettings.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import {
DEFAULT_SERVER_SETTINGS,
ProjectId,
ProviderDriverKind,
ProviderInstanceId,
ServerSettings,
Expand Down Expand Up @@ -141,6 +142,30 @@ it.layer(NodeServices.layer)("server settings", (it) => {
}).pipe(Effect.provide(makeServerSettingsLayer())),
);

it.effect("updates project settings from the latest persisted snapshot", () =>
Effect.gen(function* () {
const serverSettings = yield* ServerSettingsService;
const firstProjectId = ProjectId.make("project-1");
const secondProjectId = ProjectId.make("project-2");

yield* Effect.all(
[
serverSettings.updateProjectSettings(firstProjectId, {
actionEnvironment: { FIRST: "1" },
}),
serverSettings.updateProjectSettings(secondProjectId, {
actionEnvironment: { SECOND: "2" },
}),
],
{ concurrency: "unbounded" },
);

const next = yield* serverSettings.getSettings;
assert.deepEqual(next.projectSettings[firstProjectId]?.actionEnvironment, { FIRST: "1" });
assert.deepEqual(next.projectSettings[secondProjectId]?.actionEnvironment, { SECOND: "2" });
}).pipe(Effect.provide(makeServerSettingsLayer())),
);

it.effect("preserves model when switching providers via textGenerationModelSelection", () =>
Effect.gen(function* () {
const serverSettings = yield* ServerSettingsService;
Expand Down
Loading
Loading