authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-02 18:29:07-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 18:29:07-04:00
logba3cb0773859334fe9f75016295bb3893c9e6044
tree2938feb30778205e092884e266ed835c5f91e475
parent91c8d4771ccb503a9dde65190b87db642df0a6ea
signature Signed by PGP key B5690EEEBB952194

feat(projects): automatically pull clean default branches (#9277)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

20 files changed, 351 insertions(+), 2 deletions(-)

apps/server/integration/orphanedProviderSessionStartup.integration.test.ts+2
......@@ -40,6 +40,7 @@ import * as ServerLifecycleEvents from "../src/serverLifecycleEvents.ts";
4040import * as ServerRuntimeStartup from "../src/serverRuntimeStartup.ts";
4141import * as ServerSettings from "../src/serverSettings.ts";
4242import * as AnalyticsService from "../src/telemetry/AnalyticsService.ts";
43import * as GitVcsDriver from "../src/vcs/GitVcsDriver.ts";
4344
4445const providerInstanceId = ProviderInstanceId.make("codex");
4546const projectId = ProjectId.make("project-startup-orphan");
......@@ -105,6 +106,7 @@ const startupDependencies = Layer.mergeAll(
105106 }),
106107 ),
107108 AnalyticsService.layerTest,
109 Layer.mock(GitVcsDriver.GitVcsDriver)({}),
108110 Layer.succeed(ProviderService.ProviderService, {
109111 startSession: () => Effect.die("unused"),
110112 sendTurn: () => Effect.die("unused"),
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+2
......@@ -515,6 +515,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
515515 workspaceRoot: event.payload.workspaceRoot,
516516 defaultModelSelection: event.payload.defaultModelSelection,
517517 defaultThreadEnvMode: null,
518 autoPull: false,
518519 faviconPath: event.payload.faviconPath ?? null,
519520 scripts: event.payload.scripts,
520521 createdAt: event.payload.createdAt,
......@@ -542,6 +543,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
542543 ...(event.payload.defaultThreadEnvMode !== undefined
543544 ? { defaultThreadEnvMode: event.payload.defaultThreadEnvMode }
544545 : {}),
546 ...(event.payload.autoPull !== undefined ? { autoPull: event.payload.autoPull } : {}),
545547 ...(event.payload.faviconPath !== undefined
546548 ? { faviconPath: event.payload.faviconPath }
547549 : {}),
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+2
......@@ -278,6 +278,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
278278 instanceId: ProviderInstanceId.make("codex"),
279279 model: "gpt-5-codex",
280280 },
281 autoPull: false,
281282 faviconPath: null,
282283 scripts: [
283284 {
......@@ -405,6 +406,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
405406 instanceId: ProviderInstanceId.make("codex"),
406407 model: "gpt-5-codex",
407408 },
409 autoPull: false,
408410 faviconPath: null,
409411 scripts: [
410412 {
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+8
......@@ -83,6 +83,7 @@ const THREAD_DETAIL_ACTIVITY_PAYLOAD_BATCH_SIZE = 25;
8383const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(
8484 Struct.assign({
8585 defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)),
86 autoPull: Schema.Number,
8687 scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),
8788 }),
8889);
......@@ -347,6 +348,7 @@ function mapProjectShellRow(
347348 repositoryIdentity,
348349 defaultModelSelection: row.defaultModelSelection,
349350 defaultThreadEnvMode: row.defaultThreadEnvMode,
351 autoPull: row.autoPull === 1,
350352 faviconPath: row.faviconPath ?? null,
351353 scripts: row.scripts,
352354 createdAt: row.createdAt,
......@@ -439,6 +441,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
439441 workspace_root AS "workspaceRoot",
440442 default_model_selection_json AS "defaultModelSelection",
441443 default_thread_env_mode AS "defaultThreadEnvMode",
444 auto_pull AS "autoPull",
442445 favicon_path AS "faviconPath",
443446 scripts_json AS "scripts",
444447 created_at AS "createdAt",
......@@ -914,6 +917,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
914917 workspace_root AS "workspaceRoot",
915918 default_model_selection_json AS "defaultModelSelection",
916919 default_thread_env_mode AS "defaultThreadEnvMode",
920 auto_pull AS "autoPull",
917921 favicon_path AS "faviconPath",
918922 scripts_json AS "scripts",
919923 created_at AS "createdAt",
......@@ -938,6 +942,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
938942 workspace_root AS "workspaceRoot",
939943 default_model_selection_json AS "defaultModelSelection",
940944 default_thread_env_mode AS "defaultThreadEnvMode",
945 auto_pull AS "autoPull",
941946 favicon_path AS "faviconPath",
942947 scripts_json AS "scripts",
943948 created_at AS "createdAt",
......@@ -1878,6 +1883,7 @@ pending_approval_requests AS (
18781883 repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null,
18791884 defaultModelSelection: row.defaultModelSelection,
18801885 defaultThreadEnvMode: row.defaultThreadEnvMode,
1886 autoPull: row.autoPull === 1,
18811887 faviconPath: row.faviconPath ?? null,
18821888 scripts: row.scripts,
18831889 createdAt: row.createdAt,
......@@ -2013,6 +2019,7 @@ pending_approval_requests AS (
20132019 workspaceRoot: row.workspaceRoot,
20142020 defaultModelSelection: row.defaultModelSelection,
20152021 defaultThreadEnvMode: row.defaultThreadEnvMode,
2022 autoPull: row.autoPull === 1,
20162023 faviconPath: row.faviconPath ?? null,
20172024 scripts: row.scripts,
20182025 createdAt: row.createdAt,
......@@ -2534,6 +2541,7 @@ pending_approval_requests AS (
25342541 repositoryIdentity,
25352542 defaultModelSelection: option.value.defaultModelSelection,
25362543 defaultThreadEnvMode: option.value.defaultThreadEnvMode,
2544 autoPull: option.value.autoPull === 1,
25372545 faviconPath: option.value.faviconPath ?? null,
25382546 scripts: option.value.scripts,
25392547 createdAt: option.value.createdAt,
apps/server/src/orchestration/decider.projectThreadEnvMode.test.ts+22
......@@ -153,4 +153,26 @@ it.layer(NodeServices.layer)("decider project defaults", (it) => {
153153 expect(afterClear.projects[0]?.defaultThreadEnvMode).toBeNull();
154154 }),
155155 );
156
157 it.effect("propagates autoPull through meta.update into the read model", () =>
158 Effect.gen(function* () {
159 const readModel = yield* projectEvent(createEmptyReadModel(now), seedProjectCreated(1));
160 expect(readModel.projects[0]?.autoPull).toBe(false);
161
162 const result = yield* decideOrchestrationCommand({
163 command: {
164 type: "project.meta.update",
165 commandId: CommandId.make("cmd-project-auto-pull"),
166 projectId,
167 autoPull: true,
168 },
169 readModel,
170 });
171 const event = Array.isArray(result) ? result[0] : result;
172 expect((event.payload as { autoPull?: unknown }).autoPull).toBe(true);
173
174 const updated = yield* projectEvent(readModel, { ...event, sequence: 2 });
175 expect(updated.projects[0]?.autoPull).toBe(true);
176 }),
177 );
156178});
apps/server/src/orchestration/decider.ts+1
......@@ -264,6 +264,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
264264 ...(command.defaultThreadEnvMode !== undefined
265265 ? { defaultThreadEnvMode: command.defaultThreadEnvMode }
266266 : {}),
267 ...(command.autoPull !== undefined ? { autoPull: command.autoPull } : {}),
267268 ...(command.faviconPath !== undefined ? { faviconPath: command.faviconPath } : {}),
268269 ...(command.scripts !== undefined ? { scripts: command.scripts } : {}),
269270 updatedAt: occurredAt,
apps/server/src/orchestration/projector.ts+2
......@@ -215,6 +215,7 @@ export function projectEvent(
215215 workspaceRoot: payload.workspaceRoot,
216216 defaultModelSelection: payload.defaultModelSelection,
217217 defaultThreadEnvMode: null,
218 autoPull: false,
218219 faviconPath: payload.faviconPath ?? null,
219220 scripts: payload.scripts,
220221 createdAt: payload.createdAt,
......@@ -251,6 +252,7 @@ export function projectEvent(
251252 ...(payload.defaultThreadEnvMode !== undefined
252253 ? { defaultThreadEnvMode: payload.defaultThreadEnvMode }
253254 : {}),
255 ...(payload.autoPull !== undefined ? { autoPull: payload.autoPull } : {}),
254256 ...(payload.faviconPath !== undefined
255257 ? { faviconPath: payload.faviconPath }
256258 : {}),
apps/server/src/persistence/Layers/ProjectionProjects.ts+9
......@@ -2,6 +2,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient";
22import * as SqlSchema from "effect/unstable/sql/SqlSchema";
33import * as Effect from "effect/Effect";
44import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
56import * as Schema from "effect/Schema";
67import * as Struct from "effect/Struct";
78
......@@ -18,6 +19,7 @@ import {
1819const ProjectionProjectDbRow = ProjectionProject.mapFields(
1920 Struct.assign({
2021 defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)),
22 autoPull: Schema.Number,
2123 scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),
2224 }),
2325);
......@@ -36,6 +38,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
3638 workspace_root,
3739 default_model_selection_json,
3840 default_thread_env_mode,
41 auto_pull,
3942 favicon_path,
4043 scripts_json,
4144 created_at,
......@@ -48,6 +51,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
4851 ${row.workspaceRoot},
4952 ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null},
5053 ${row.defaultThreadEnvMode},
54 ${row.autoPull ? 1 : 0},
5155 ${row.faviconPath ?? null},
5256 ${JSON.stringify(row.scripts)},
5357 ${row.createdAt},
......@@ -60,6 +64,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
6064 workspace_root = excluded.workspace_root,
6165 default_model_selection_json = excluded.default_model_selection_json,
6266 default_thread_env_mode = excluded.default_thread_env_mode,
67 auto_pull = excluded.auto_pull,
6368 favicon_path = excluded.favicon_path,
6469 scripts_json = excluded.scripts_json,
6570 created_at = excluded.created_at,
......@@ -79,6 +84,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
7984 workspace_root AS "workspaceRoot",
8085 default_model_selection_json AS "defaultModelSelection",
8186 default_thread_env_mode AS "defaultThreadEnvMode",
87 auto_pull AS "autoPull",
8288 favicon_path AS "faviconPath",
8389 scripts_json AS "scripts",
8490 created_at AS "createdAt",
......@@ -100,6 +106,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
100106 workspace_root AS "workspaceRoot",
101107 default_model_selection_json AS "defaultModelSelection",
102108 default_thread_env_mode AS "defaultThreadEnvMode",
109 auto_pull AS "autoPull",
103110 favicon_path AS "faviconPath",
104111 scripts_json AS "scripts",
105112 created_at AS "createdAt",
......@@ -126,11 +133,13 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
126133
127134 const getById: ProjectionProjectRepositoryShape["getById"] = (input) =>
128135 getProjectionProjectRow(input).pipe(
136 Effect.map(Option.map((row) => ({ ...row, autoPull: row.autoPull === 1 }))),
129137 Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.getById:query")),
130138 );
131139
132140 const listAll: ProjectionProjectRepositoryShape["listAll"] = () =>
133141 listProjectionProjectRows().pipe(
142 Effect.map((rows) => rows.map((row) => ({ ...row, autoPull: row.autoPull === 1 }))),
134143 Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.listAll:query")),
135144 );
136145
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+1
......@@ -34,6 +34,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => {
3434 model: "gpt-5.4",
3535 },
3636 defaultThreadEnvMode: null,
37 autoPull: false,
3738 scripts: [],
3839 createdAt: "2026-03-24T00:00:00.000Z",
3940 updatedAt: "2026-03-24T00:00:00.000Z",
apps/server/src/persistence/Migrations.ts+2
......@@ -56,6 +56,7 @@ import Migration0041 from "./Migrations/041_AuthSessionClientConnection.ts";
5656import Migration0042 from "./Migrations/042_ProjectionThreadLinkedPullRequest.ts";
5757import Migration0043 from "./Migrations/043_ProjectionThreadsUnsettledAt.ts";
5858import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.ts";
59import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts";
5960
6061/**
6162 * Migration loader with all migrations defined inline.
......@@ -112,6 +113,7 @@ export const migrationEntries = [
112113 [42, "ProjectionThreadLinkedPullRequest", Migration0042],
113114 [43, "ProjectionThreadsUnsettledAt", Migration0043],
114115 [44, "ClearAutomaticProjectModelDefaults", Migration0044],
116 [45, "ProjectionProjectsAutoPull", Migration0045],
115117] as const;
116118
117119export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
apps/server/src/persistence/Migrations/045_ProjectionProjectsAutoPull.ts created+16
......@@ -0,0 +1,16 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6 const columns = yield* sql<{ readonly name: string }>`
7 PRAGMA table_info(projection_projects)
8 `;
9
10 if (!columns.some((column) => column.name === "auto_pull")) {
11 yield* sql`
12 ALTER TABLE projection_projects
13 ADD COLUMN auto_pull INTEGER NOT NULL DEFAULT 0
14 `;
15 }
16});
apps/server/src/persistence/Services/ProjectionProjects.ts+1
......@@ -26,6 +26,7 @@ export const ProjectionProject = Schema.Struct({
2626 workspaceRoot: Schema.String,
2727 defaultModelSelection: Schema.NullOr(ModelSelection),
2828 defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode),
29 autoPull: Schema.Boolean,
2930 faviconPath: Schema.optional(Schema.NullOr(Schema.String)),
3031 scripts: Schema.Array(ProjectScript),
3132 createdAt: IsoDateTime,
apps/server/src/server.ts+6-1
......@@ -339,7 +339,12 @@ const VcsLayerLive = Layer.empty.pipe(
339339 Layer.provideMerge(GitWorkflowLayerLive),
340340 Layer.provideMerge(ReviewLayerLive),
341341 Layer.provideMerge(SourceControlRepositoryServiceLayerLive),
342 Layer.provideMerge(VcsStatusBroadcaster.layer.pipe(Layer.provide(GitWorkflowLayerLive))),
342 Layer.provideMerge(
343 VcsStatusBroadcaster.layer.pipe(
344 Layer.provide(GitWorkflowLayerLive),
345 Layer.provide(VcsStatusBroadcaster.autoPullPolicyLayer),
346 ),
347 ),
343348);
344349
345350const CheckpointingLayerLive = Layer.empty.pipe(
apps/server/src/serverRuntimeStartup.test.ts+40
......@@ -15,6 +15,7 @@ import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngi
1515import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
1616import * as AnalyticsService from "./telemetry/AnalyticsService.ts";
1717import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
18import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
1819
1920it("uses the canonical Codex default for the auto-bootstrapped welcome thread", () => {
2021 assert.deepStrictEqual(ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), {
......@@ -23,6 +24,45 @@ it("uses the canonical Codex default for the auto-bootstrapped welcome thread",
2324 });
2425});
2526
27it.effect("automatic pull only updates enabled, behind, clean default-branch checkouts", () =>
28 Effect.gen(function* () {
29 const pulled: string[] = [];
30 const git = {
31 statusDetails: (cwd: string) =>
32 Effect.succeed({
33 isRepo: true,
34 isDefaultBranch: cwd !== "/feature",
35 hasUpstream: true,
36 hasWorkingTreeChanges: cwd === "/dirty",
37 aheadCount: cwd === "/ahead" ? 1 : 0,
38 behindCount: cwd === "/current" ? 0 : 1,
39 } as never),
40 pullCurrentBranch: (cwd: string) =>
41 Effect.sync(() => {
42 pulled.push(cwd);
43 return {
44 status: "pulled" as const,
45 refName: "main",
46 upstreamRef: "origin/main",
47 };
48 }),
49 } as unknown as GitVcsDriver.GitVcsDriver["Service"];
50 const project = (workspaceRoot: string, autoPull = true) =>
51 ({ workspaceRoot, autoPull }) as never;
52
53 yield* ServerRuntimeStartup.autoPullProjects([
54 project("/clean"),
55 project("/current"),
56 project("/dirty"),
57 project("/ahead"),
58 project("/feature"),
59 project("/disabled", false),
60 ]).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git));
61
62 assert.deepStrictEqual(pulled, ["/clean"]);
63 }),
64);
65
2666it.effect("enqueueCommand waits for readiness and then drains queued work", () =>
2767 Effect.scoped(
2868 Effect.gen(function* () {
apps/server/src/serverRuntimeStartup.ts+71
......@@ -3,6 +3,7 @@ import {
33 DEFAULT_MODEL,
44 DEFAULT_PROVIDER_INTERACTION_MODE,
55 type ModelSelection,
6 type OrchestrationProjectShell,
67 ProjectId,
78 ProviderInstanceId,
89 ThreadId,
......@@ -40,6 +41,7 @@ import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDi
4041import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts";
4142import { forkParked } from "./serverActivation.ts";
4243import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts";
44import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
4345import {
4446 formatHeadlessServeOutput,
4547 formatHostForUrl,
......@@ -634,6 +636,65 @@ interface StartupOptions {
634636 readonly abort?: (error: ServerRuntimeStartupError) => Effect.Effect<void>;
635637}
636638
639export const autoPullProjects = Effect.fn("autoPullProjects")(function* (
640 projects: ReadonlyArray<OrchestrationProjectShell>,
641) {
642 const git = yield* GitVcsDriver.GitVcsDriver;
643 const workspaceRoots = [
644 ...new Set(
645 projects
646 .filter((project) => project.autoPull === true)
647 .map((project) => project.workspaceRoot),
648 ),
649 ];
650
651 yield* Effect.forEach(
652 workspaceRoots,
653 (cwd) =>
654 Effect.gen(function* () {
655 const status = yield* git.statusDetails(cwd);
656 if (
657 !status.isRepo ||
658 !status.isDefaultBranch ||
659 !status.hasUpstream ||
660 status.hasWorkingTreeChanges ||
661 status.aheadCount > 0
662 ) {
663 yield* Effect.logDebug("Skipped automatic project pull", {
664 cwd,
665 reason: !status.isRepo
666 ? "not-a-repository"
667 : !status.isDefaultBranch
668 ? "not-on-default-branch"
669 : !status.hasUpstream
670 ? "no-upstream"
671 : status.hasWorkingTreeChanges
672 ? "working-tree-changes"
673 : "local-commits",
674 });
675 return;
676 }
677
678 if (status.behindCount <= 0) return;
679
680 const result = yield* git.pullCurrentBranch(cwd);
681 yield* Effect.logDebug("Automatic project pull completed", {
682 cwd,
683 status: result.status,
684 refName: result.refName,
685 });
686 }).pipe(
687 Effect.catch((cause) =>
688 Effect.logWarning("Automatic project pull failed", {
689 cwd,
690 cause,
691 }),
692 ),
693 ),
694 { concurrency: 4, discard: true },
695 );
696});
697
637698export const make = (options?: StartupOptions) =>
638699 Effect.gen(function* () {
639700 const serverConfig = yield* ServerConfig.ServerConfig;
......@@ -652,6 +713,13 @@ export const make = (options?: StartupOptions) =>
652713 const httpListening = yield* Deferred.make<void>();
653714 const reactorScope = yield* Scope.make("sequential");
654715
716 const syncAutoPullProjects = projectionSnapshotQuery.getShellSnapshot().pipe(
717 Effect.flatMap((snapshot) => autoPullProjects(snapshot.projects)),
718 Effect.catch((cause) =>
719 Effect.logWarning("Failed to load projects for automatic pull", { cause }),
720 ),
721 );
722
655723 yield* Effect.addFinalizer(() => Scope.close(reactorScope, Exit.void));
656724
657725 const startup = Effect.gen(function* () {
......@@ -696,6 +764,9 @@ export const make = (options?: StartupOptions) =>
696764
697765 yield* runStartupPhase("provider-sessions.reconcile", reconcileProviderSessions);
698766
767 yield* Effect.logDebug("startup phase: syncing clean projects");
768 yield* runStartupPhase("projects.auto-pull", syncAutoPullProjects);
769
699770 const welcomeBase = yield* resolveWelcomeBase;
700771 const environment = yield* serverEnvironment.getDescriptor;
701772 yield* Effect.logDebug("startup phase: preparing welcome payload");
apps/server/src/vcs/VcsStatusBroadcaster.test.ts+61
......@@ -142,6 +142,67 @@ function makeBackgroundPolicyLayer(shouldRunScopeWork: (scope: BackgroundScope)
142142}
143143
144144describe("VcsStatusBroadcaster", () => {
145 it.effect(
146 "automatically pulls an enabled clean default branch when status detects it is behind",
147 () => {
148 let remoteStatus: VcsStatusRemoteResult = { ...baseRemoteStatus, behindCount: 2 };
149 let pullCalls = 0;
150 let configuredWorkspaceRoot = "";
151 const localStatus: VcsStatusLocalResult = {
152 ...baseLocalStatus,
153 isDefaultRef: true,
154 refName: "main",
155 };
156 const testLayer = VcsStatusBroadcaster.layer.pipe(
157 Layer.provideMerge(NodeServices.layer),
158 Layer.provide(makeBackgroundPolicyLayer(() => true)),
159 Layer.provide(
160 Layer.succeed(VcsStatusBroadcaster.VcsAutoPullPolicy, {
161 isEnabled: (cwd) => Effect.succeed(cwd === configuredWorkspaceRoot),
162 }),
163 ),
164 Layer.provide(
165 Layer.mock(GitWorkflowService.GitWorkflowService)({
166 localStatus: () => Effect.succeed(localStatus),
167 remoteStatus: () => Effect.succeed(remoteStatus),
168 invalidateLocalStatus: () => Effect.void,
169 invalidateRemoteStatus: () => Effect.void,
170 invalidateStatus: () => Effect.void,
171 pullCurrentBranch: () =>
172 Effect.sync(() => {
173 pullCalls += 1;
174 remoteStatus = { ...remoteStatus, behindCount: 0 };
175 return {
176 status: "pulled" as const,
177 refName: "main",
178 upstreamRef: "origin/main",
179 };
180 }),
181 }),
182 ),
183 );
184
185 return Effect.gen(function* () {
186 const fileSystem = yield* FileSystem.FileSystem;
187 const path = yield* Path.Path;
188 const realDir = yield* fileSystem.makeTempDirectoryScoped({
189 prefix: "t3-vcs-auto-pull-real-",
190 });
191 const linkParent = yield* fileSystem.makeTempDirectoryScoped({
192 prefix: "t3-vcs-auto-pull-link-",
193 });
194 configuredWorkspaceRoot = path.join(linkParent, "repo-link");
195 yield* fileSystem.symlink(realDir, configuredWorkspaceRoot);
196
197 const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
198 const status = yield* broadcaster.refreshStatus(configuredWorkspaceRoot);
199
200 assert.equal(pullCalls, 1);
201 assert.equal(status.behindCount, 0);
202 }).pipe(Effect.provide(testLayer));
203 },
204 );
205
145206 it.effect("reuses the cached VCS status across repeated reads", () => {
146207 const state = {
147208 currentLocalStatus: baseLocalStatus,
apps/server/src/vcs/VcsStatusBroadcaster.ts+69-1
......@@ -24,6 +24,7 @@ import { mergeGitStatusParts } from "@t3tools/shared/git";
2424
2525import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
2626import * as GitWorkflowService from "../git/GitWorkflowService.ts";
27import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
2728
2829const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30);
2930const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30);
......@@ -139,6 +140,26 @@ interface StreamStatusOptions {
139140 readonly automaticRemoteRefreshInterval?: Effect.Effect<Duration.Duration, never>;
140141}
141142
143export class VcsAutoPullPolicy extends Context.Reference<{
144 readonly isEnabled: (cwd: string) => Effect.Effect<boolean, never>;
145}>("t3/vcs/VcsAutoPullPolicy", {
146 defaultValue: () => ({ isEnabled: () => Effect.succeed(false) }),
147}) {}
148
149export const autoPullPolicyLayer = Layer.effect(
150 VcsAutoPullPolicy,
151 Effect.gen(function* () {
152 const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
153 return {
154 isEnabled: (cwd: string) =>
155 snapshots.getActiveProjectByWorkspaceRoot(cwd).pipe(
156 Effect.map((project) => project._tag === "Some" && project.value.autoPull === true),
157 Effect.orElseSucceed(() => false),
158 ),
159 };
160 }),
161);
162
142163export function remoteRefreshFailureDelay(
143164 consecutiveFailures: number,
144165 configuredInterval: Duration.Duration,
......@@ -181,6 +202,7 @@ const normalizeCwd = (cwd: string) =>
181202 );
182203
183204export const make = Effect.gen(function* () {
205 const autoPullPolicy = yield* VcsAutoPullPolicy;
184206 const workflow = yield* GitWorkflowService.GitWorkflowService;
185207 const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;
186208 const fs = yield* FileSystem.FileSystem;
......@@ -354,14 +376,57 @@ export const make = Effect.gen(function* () {
354376 return yield* refreshLocalStatusCore(cwd);
355377 });
356378
379 const maybeAutoPull = Effect.fn("VcsStatusBroadcaster.maybeAutoPull")(function* (
380 cwd: string,
381 remote: VcsStatusRemoteResult | null,
382 policyCwds: ReadonlyArray<string>,
383 ) {
384 return yield* Effect.gen(function* () {
385 const autoPullEnabled = (yield* Effect.forEach(policyCwds, autoPullPolicy.isEnabled, {
386 concurrency: "unbounded",
387 })).some(Boolean);
388 if (
389 remote === null ||
390 !remote.hasUpstream ||
391 remote.aheadCount > 0 ||
392 remote.behindCount <= 0 ||
393 !autoPullEnabled
394 ) {
395 return null;
396 }
397
398 yield* workflow.invalidateLocalStatus(cwd);
399 const local = yield* workflow.localStatus({ cwd });
400 if (!local.isRepo || !local.isDefaultRef || local.hasWorkingTreeChanges) return null;
401
402 yield* workflow.pullCurrentBranch(cwd);
403 yield* workflow.invalidateStatus(cwd);
404 const [refreshedLocal, refreshedRemote] = yield* Effect.all(
405 [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd }, { refreshUpstream: false })],
406 { concurrency: "unbounded" },
407 );
408 yield* updateCachedStatus(cwd, refreshedLocal, refreshedRemote, { publish: true });
409 return { local: refreshedLocal, remote: refreshedRemote };
410 }).pipe(
411 Effect.catch(() =>
412 Effect.logWarning("Automatic project pull failed", { cwd }).pipe(Effect.as(null)),
413 ),
414 );
415 });
416
357417 const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* (
358418 cwd: string,
359 options?: { readonly refreshUpstream?: boolean },
419 options?: {
420 readonly refreshUpstream?: boolean;
421 readonly policyCwds?: ReadonlyArray<string>;
422 },
360423 ) {
361424 if (options?.refreshUpstream !== false) {
362425 yield* workflow.invalidateRemoteStatus(cwd);
363426 }
364427 const remote = yield* workflow.remoteStatus({ cwd }, options);
428 const pulled = yield* maybeAutoPull(cwd, remote, options?.policyCwds ?? [cwd]);
429 if (pulled !== null) return pulled.remote;
365430 return yield* updateCachedRemoteStatus(cwd, remote, { publish: true });
366431 });
367432
......@@ -376,6 +441,8 @@ export const make = Effect.gen(function* () {
376441 [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })],
377442 { concurrency: "unbounded" },
378443 );
444 const pulled = yield* maybeAutoPull(cwd, remote, [rawCwd]);
445 if (pulled !== null) return mergeGitStatusParts(pulled.local, pulled.remote);
379446 return yield* updateCachedStatus(cwd, local, remote, { publish: true });
380447 });
381448
......@@ -416,6 +483,7 @@ export const make = Effect.gen(function* () {
416483
417484 const exit = yield* refreshRemoteStatus(cwd, {
418485 refreshUpstream: !Duration.isZero(configuredInterval),
486 policyCwds: [...demandCwds.keys()],
419487 }).pipe(Effect.exit);
420488 if (Exit.isSuccess(exit)) {
421489 yield* Ref.set(needsInitialRefreshRef, false);
apps/web/src/components/settings/ProjectSettingsPanel.tsx+20
......@@ -96,6 +96,7 @@ import {
9696} from "../ui/menu";
9797import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
9898import { SidebarInset } from "../ui/sidebar";
99import { Switch } from "../ui/switch";
99100import { stackedThreadToast, toastManager } from "../ui/toast";
100101import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
101102import {
......@@ -371,6 +372,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
371372 title: string;
372373 defaultModelSelection: ModelSelection | null;
373374 defaultThreadEnvMode: ThreadEnvMode | null;
375 autoPull: boolean;
374376 faviconPath: string | null;
375377 }>,
376378 failureTitle: string,
......@@ -464,6 +466,13 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
464466 [updateAllMembers],
465467 );
466468
469 const autoPull = representative.autoPull ?? false;
470 const setAutoPull = useCallback(
471 (enabled: boolean) =>
472 void updateAllMembers({ autoPull: enabled }, "Failed to update automatic pull setting"),
473 [updateAllMembers],
474 );
475
467476 // ----- favicon -----
468477 const [faviconPickerOpen, setFaviconPickerOpen] = useState(false);
469478 const [isSavingFavicon, setIsSavingFavicon] = useState(false);
......@@ -940,6 +949,17 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
940949 </Select>
941950 }
942951 />
952 <SettingsRow
953 title="Automatically pull"
954 description="Keeps the default branch current in the background when the checkout has no local changes or commits."
955 control={
956 <Switch
957 checked={autoPull}
958 aria-label="Automatically pull the default branch"
959 onCheckedChange={setAutoPull}
960 />
961 }
962 />
943963 </SettingsSection>
944964
945965 <SettingsSection
docs/user/project-settings.md+10
......@@ -14,3 +14,13 @@ T3 Code supports SVG, PNG, ICO, JPEG, GIF, AVIF, and WebP files. The selected pa
1414each checkout in the project group and appears on your connected clients.
1515
1616To use automatic detection again, select **Automatic**.
17
18## Keep the default branch current
19
20Turn on **Automatically pull** in a project's settings to keep its default-branch checkout current.
21T3 Code checks in the background and when the server starts. It uses the branch's configured
22upstream and only performs a fast-forward pull when the checkout has no working-tree changes,
23untracked files, or local commits.
24
25The pull is skipped if the checkout is on another branch, has no upstream, or contains local work.
26Pull failures do not prevent the server from starting.
packages/contracts/src/orchestration.ts+6
......@@ -291,6 +291,9 @@ export const OrchestrationProject = Schema.Struct({
291291 // Per-project override for where new threads start. Null/absent means
292292 // "no override": clients fall back to t3.json, then the global setting.
293293 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
294 // Opt-in because background sync performs network I/O and may move the checkout.
295 // Optional on the wire so cached snapshots from older servers still decode.
296 autoPull: Schema.optional(Schema.Boolean),
294297 // Optional on the wire so cached snapshots from older servers still decode.
295298 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
296299 scripts: Schema.Array(ProjectScript),
......@@ -502,6 +505,7 @@ export const OrchestrationProjectShell = Schema.Struct({
502505 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
503506 defaultModelSelection: Schema.NullOr(ModelSelection),
504507 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
508 autoPull: Schema.optional(Schema.Boolean),
505509 // Optional on the wire so cached snapshots from older servers still decode.
506510 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
507511 scripts: Schema.Array(ProjectScript),
......@@ -723,6 +727,7 @@ const ProjectMetaUpdateCommand = Schema.Struct({
723727 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
724728 // Absent = leave unchanged; null = clear the override.
725729 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
730 autoPull: Schema.optional(Schema.Boolean),
726731 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
727732 scripts: Schema.optional(Schema.Array(ProjectScript)),
728733});
......@@ -1191,6 +1196,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({
11911196 repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)),
11921197 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
11931198 defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)),
1199 autoPull: Schema.optional(Schema.Boolean),
11941200 faviconPath: Schema.optional(Schema.NullOr(ProjectFaviconPath)),
11951201 scripts: Schema.optional(Schema.Array(ProjectScript)),
11961202 updatedAt: IsoDateTime,