Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
25 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
398ffb2
Merge branch 'main' into per-project-settings
shivamhwp May 12, 2026
88a01c6
Merge remote-tracking branch 'origin/main' into per-project-settings
shivamhwp May 13, 2026
1c7b196
Merge branch 'main' into per-project-settings
shivamhwp May 15, 2026
b007aa0
Preserve cleared draft state when reopening project default
shivamhwp May 17, 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 @@ -11,6 +11,7 @@ import type {
GitActionProgressEvent,
GitPreparePullRequestThreadInput,
ModelSelection,
SourceControlProviderInfo,
ThreadId,
} from "@t3tools/contracts";

Expand Down Expand Up @@ -643,6 +644,8 @@ function preparePullRequestThread(

function makeManager(input?: {
ghScenario?: FakeGhScenario;
sourceControlProviderContext?: SourceControlProviderRegistry.SourceControlProviderHandle["context"];
sourceControlProviderContextSource?: SourceControlProviderRegistry.SourceControlProviderHandle["contextSource"];
textGeneration?: Partial<FakeGitTextGeneration>;
setupScriptRunner?: ProjectSetupScriptRunnerShape;
}) {
Expand All @@ -665,7 +668,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 @@ -700,6 +708,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 @@ -742,6 +762,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 @@ -790,6 +790,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,
}),
),
);
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.

Missing provider field in error fallback object

Low Severity

When sourceControlProviders.resolveHandle({ cwd }) fails, the Effect.catch fallback returns { context: null, contextSource: null } which is missing the provider field from SourceControlProviderHandle. The code only accesses .contextSource and .context from this value so it works at runtime, but it relies on structural typing leniency rather than matching the declared interface. If future code accesses .provider from this result, it'll be undefined.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b007aa0. Configure here.

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 @@ -798,7 +810,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
15 changes: 15 additions & 0 deletions apps/server/src/project/Layers/ProjectSetupScriptRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Effect, Layer, Option } from "effect";
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 @@ -47,6 +48,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 @@ -106,6 +108,18 @@ describe("ProjectSetupScriptRunner", () => {
Effect.provide(
ProjectSetupScriptRunnerLive.pipe(
Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)),
Layer.provideMerge(
ServerSettingsService.layerTest({
projectSettings: {
[project.id]: {
remoteOverride: null,
actionEnvironment: {
API_BASE_URL: "https://api.example.test",
},
},
},
}),
),
Layer.provideMerge(
Layer.succeed(TerminalManager, {
open,
Expand Down Expand Up @@ -143,6 +157,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 @@ -3,6 +3,7 @@ import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/pro
import { Effect, Layer, Option } from "effect";

import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { TerminalManager } from "../../terminal/Services/Manager.ts";
import {
type ProjectSetupScriptRunnerShape,
Expand All @@ -11,6 +12,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 @@ -41,9 +43,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
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 { Deferred, Effect, Fiber, Option, Ref, Stream } from "effect";

Expand All @@ -20,10 +21,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 {
Data,
Deferred,
Expand Down Expand Up @@ -154,10 +153,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
77 changes: 77 additions & 0 deletions apps/server/src/sourceControl/RemoteOverride.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import type {
ProjectRemoteOverride,
SourceControlProviderInfo,
SourceControlProviderKind,
} from "@t3tools/contracts";

import * as SourceControlProvider from "./SourceControlProvider.ts";

export function parseRemoteHost(remoteUrl: string): string | null {
const trimmed = remoteUrl.trim();
if (trimmed.startsWith("git@")) {
const hostWithPath = trimmed.slice("git@".length);
const separatorIndex = hostWithPath.search(/[:/]/);
return separatorIndex > 0 ? hostWithPath.slice(0, separatorIndex).toLowerCase() : null;
}

try {
const hostname = new URL(trimmed).hostname.toLowerCase();
return hostname || null;
} catch {
return null;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
}
}

export function parseBaseUrl(value: string): string | null {
try {
const url = new URL(value);
return `${url.protocol}//${url.host}`;
} catch {
const host = parseRemoteHost(value);
return host ? `https://${host}` : null;
}
}

export function providerName(kind: SourceControlProviderKind, baseUrl: string | null): string {
switch (kind) {
case "github":
return baseUrl === "https://github.com" ? "GitHub" : "GitHub Self-Hosted";
case "gitlab":
return baseUrl === "https://gitlab.com" ? "GitLab" : "GitLab Self-Hosted";
case "azure-devops":
return "Azure DevOps";
case "bitbucket":
return baseUrl === "https://bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted";
case "unknown":
return parseRemoteHost(baseUrl ?? "") ?? "Source control";
}
}

export function providerInfoFromOverride(
override: ProjectRemoteOverride,
): SourceControlProviderInfo | null {
const baseUrl = override.webUrl
? parseBaseUrl(override.webUrl)
: parseBaseUrl(override.remoteUrl);
if (!baseUrl) {
return null;
}
return {
kind: override.provider,
name: providerName(override.provider, baseUrl),
baseUrl,
};
}

export function providerContextFromOverride(
override: ProjectRemoteOverride,
): SourceControlProvider.SourceControlProviderContext | null {
const provider = providerInfoFromOverride(override);
return provider
? {
provider,
remoteName: override.remoteName ?? "origin",
remoteUrl: override.remoteUrl,
}
: null;
}
6 changes: 6 additions & 0 deletions apps/server/src/sourceControl/SourceControlDiscovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { ChildProcessSpawner } from "effect/unstable/process";
import { VcsProcessSpawnError } from "@t3tools/contracts";

import { ServerConfig } from "../config.ts";
import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts";
import { ServerSettingsService } from "../serverSettings.ts";
import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
import * as VcsProcess from "../vcs/VcsProcess.ts";
import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
Expand All @@ -28,6 +30,10 @@ const sourceControlProviderRegistryTestLayer = (input: {
Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),
Layer.mock(GitHubCli.GitHubCli)({}),
Layer.mock(GitLabCli.GitLabCli)({}),
Layer.mock(ProjectionSnapshotQuery)({
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
}),
ServerSettingsService.layerTest(),
Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),
Layer.mock(VcsProcess.VcsProcess)(input.process),
),
Expand Down
Loading
Loading