Skip to content

Introduce pluggable VCS driver foundation#2435

Merged
juliusmarminge merged 46 commits into
mainfrom
t3code/pluggable-git-integration
May 2, 2026
Merged

Introduce pluggable VCS driver foundation#2435
juliusmarminge merged 46 commits into
mainfrom
t3code/pluggable-git-integration

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented May 1, 2026

this is a core rearchitecture we can add JJ, GitLab, BitBucket on top of later

Summary

  • Added a new vcs contract layer and typed VCS errors to replace Git-specific plumbing with provider-neutral abstractions.
  • Implemented the new GitVcsDriver and VcsProcess services, then rewired server code that previously depended on GitCore.
  • Migrated checkpointing, workspace, orchestration, and server integration paths to use the new VCS services.
  • Removed the old GitCore implementation and its tests in favor of the new driver-backed execution path.
  • Added version-control phase plans to document the follow-on provider abstraction work.

Testing

  • Not run (changes were not validated in this turn).
  • Expected validation: bun fmt
  • Expected validation: bun lint
  • Expected validation: bun typecheck

Note

High Risk
Large refactor of core Git/VCS and source-control orchestration paths (status/PR/worktrees/checkpointing) to new VcsProcess/driver registries, which can break repo detection, caching, and PR workflows if behavior differs. Touches subprocess execution and typed error plumbing, increasing regression risk across many integration tests.

Overview
Introduces a new VCS foundation: VcsProcess (Effect-native subprocess execution with bounded output/timeouts and typed errors) plus VcsDriverRegistry that detects/caches repositories and routes to GitVcsDriver.

Refactors server workflows to use these abstractions: checkpointing now executes Git commands via the registry and returns VcsError types; orchestration/integration harness wiring swaps GitCore/GitStatusBroadcaster for VcsDriverRegistry/VcsStatusBroadcaster and adds GitWorkflowService routing.

Reworks GitManager to depend on GitVcsDriver for local operations and SourceControlProviderRegistry for change-request operations (provider-neutral terminology, updated status/PR field names like refName/hasPrimaryRemote), with updated tests and a new provider-fallback test. Adds Phase 1/2 version-control plan docs under .plans/.

Reviewed by Cursor Bugbot for commit 9e3db6e. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Introduce pluggable VCS driver foundation replacing Git-specific services with provider-neutral abstractions

  • Adds a VcsDriver interface and GitVcsDriver implementation behind a VcsDriverRegistry that resolves drivers by kind, with detection caching (2s TTL) and project-config discovery via .t3code/vcs.json
  • Introduces VcsProcess as a standardized subprocess abstraction for all VCS command execution, replacing direct GitCore usage throughout checkpointing, workspace indexing, and orchestration
  • Renames all WebSocket RPC methods from git.* to vcs.* (e.g. vcsListRefs, vcsSwitchRef, vcsCreateRef) and replaces subscribeGitStatus with subscribeVcsStatus; field names change from branch/hasOriginRemote/isDefaultBranch to refName/hasPrimaryRemote/isDefaultRef throughout
  • Adds a SourceControlProviderRegistry with a GitHubSourceControlProvider adapter and a SourceControlDiscovery service that probes locally available VCS tools and providers (GitHub, GitLab, Azure DevOps, Bitbucket); a new /settings/source-control settings panel surfaces discovery results
  • Introduces GitWorkflowService as a facade over GitVcsDriver + GitManager that returns empty non-repo results when no repository is detected, replacing direct GitCore/GitStatusBroadcaster dependencies in orchestration
  • Risk: all WebSocket RPC method names, subscription names, and status payload field names are breaking changes for any client not updated in this PR

Macroscope summarized 9e3db6e.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 1, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 9b72afe9-19aa-41ae-a95e-de88371d3c73

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/pluggable-git-integration

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels May 1, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Execute method silently drops env field breaking checkpoints
    • Added env to gitCommand's options type and forwarded input.env from the execute method to gitCommand, so CheckpointStore's GIT_INDEX_FILE and author/committer overrides are now properly passed through to the spawned git process.

Create PR

Or push these changes by commenting:

@cursor push 6506f41f19
Preview (6506f41f19)
diff --git a/apps/server/src/vcs/Layers/GitVcsDriver.ts b/apps/server/src/vcs/Layers/GitVcsDriver.ts
--- a/apps/server/src/vcs/Layers/GitVcsDriver.ts
+++ b/apps/server/src/vcs/Layers/GitVcsDriver.ts
@@ -69,6 +69,7 @@
   args: ReadonlyArray<string>,
   options?: {
     readonly stdin?: string;
+    readonly env?: NodeJS.ProcessEnv;
     readonly allowNonZeroExit?: boolean;
     readonly timeoutMs?: number;
     readonly maxOutputBytes?: number;
@@ -81,6 +82,7 @@
     args,
     cwd,
     ...(options?.stdin !== undefined ? { stdin: options.stdin } : {}),
+    ...(options?.env !== undefined ? { env: options.env } : {}),
     ...(options?.allowNonZeroExit !== undefined
       ? { allowNonZeroExit: options.allowNonZeroExit }
       : {}),
@@ -118,6 +120,7 @@
   const execute: VcsDriverShape["execute"] = (input) =>
     gitCommand(process, input.operation, input.cwd, input.args, {
       ...(input.stdin !== undefined ? { stdin: input.stdin } : {}),
+      ...(input.env !== undefined ? { env: input.env } : {}),
       ...(input.allowNonZeroExit !== undefined ? { allowNonZeroExit: input.allowNonZeroExit } : {}),
       ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
       ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}),

You can send follow-ups to the cloud agent here.

Comment thread apps/server/src/vcs/GitVcsDriver.ts
Comment thread apps/server/src/vcs/GitVcsDriver.ts
@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp Bot commented May 1, 2026

Approvability

Verdict: Needs human review

1 blocking correctness issue found. Diff is too large for automated approval analysis. A human reviewer should evaluate this PR.

You can customize Macroscope's approvability policy. Learn more.

Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: VcsProcessExitError misused for non-exit validation failures
    • Replaced all three misuses of VcsProcessExitError (with fabricated exitCode values) with CheckpointInvariantError, which correctly represents application-level invariant violations rather than process exit failures.

Create PR

Or push these changes by commenting:

@cursor push 9cbdcf4648
Preview (9cbdcf4648)
diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.ts
--- a/apps/server/src/checkpointing/Layers/CheckpointStore.ts
+++ b/apps/server/src/checkpointing/Layers/CheckpointStore.ts
@@ -14,7 +14,6 @@
 import { Effect, Layer, FileSystem, Path } from "effect";
 
 import { CheckpointInvariantError } from "../Errors.ts";
-import { VcsProcessExitError } from "@t3tools/contracts";
 import { VcsDriver } from "../../vcs/VcsDriver.ts";
 import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts";
 import { CheckpointRef } from "@t3tools/contracts";
@@ -128,11 +127,8 @@
         });
         const treeOid = writeTreeResult.stdout.trim();
         if (treeOid.length === 0) {
-          return yield* new VcsProcessExitError({
+          return yield* new CheckpointInvariantError({
             operation,
-            command: "git write-tree",
-            cwd: input.cwd,
-            exitCode: 0,
             detail: "git write-tree returned an empty tree oid.",
           });
         }
@@ -146,11 +142,8 @@
         });
         const commitOid = commitTreeResult.stdout.trim();
         if (commitOid.length === 0) {
-          return yield* new VcsProcessExitError({
+          return yield* new CheckpointInvariantError({
             operation,
-            command: "git commit-tree",
-            cwd: input.cwd,
-            exitCode: 0,
             detail: "git commit-tree returned an empty commit oid.",
           });
         }
@@ -234,11 +227,8 @@
       }
 
       if (!fromCommitOid || !toCommitOid) {
-        return yield* new VcsProcessExitError({
+        return yield* new CheckpointInvariantError({
           operation,
-          command: "git diff",
-          cwd: input.cwd,
-          exitCode: 1,
           detail: "Checkpoint ref is unavailable for diff operation.",
         });
       }

You can send follow-ups to the cloud agent here.

Comment thread apps/server/src/checkpointing/Layers/CheckpointStore.ts
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Uncached repository detection on every checkpoint git command
    • Added an in-memory Map cache in VcsDriverRegistry.resolve() keyed on cwd+requestedKind so that detectRepository() (which spawns 3 git processes) is only called once per unique path, eliminating ~18 unnecessary process spawns per checkpoint capture.

Create PR

Or push these changes by commenting:

@cursor push 0f019fda3d
Preview (0f019fda3d)
diff --git a/apps/server/src/vcs/VcsDriverRegistry.ts b/apps/server/src/vcs/VcsDriverRegistry.ts
--- a/apps/server/src/vcs/VcsDriverRegistry.ts
+++ b/apps/server/src/vcs/VcsDriverRegistry.ts
@@ -40,6 +40,8 @@
     git,
   };
 
+  const resolveCache = new Map<string, VcsDriverHandle>();
+
   const detectWithDriver = Effect.fn("VcsDriverRegistry.detectWithDriver")(function* (
     kind: VcsDriverKind,
     driver: VcsDriverShape,
@@ -78,12 +80,19 @@
 
   const resolve: VcsDriverRegistryShape["resolve"] = Effect.fn("VcsDriverRegistry.resolve")(
     function* (input) {
+      const requestedKind = input.requestedKind ?? "auto";
+      const cacheKey = `${input.cwd}\0${requestedKind}`;
+      const cached = resolveCache.get(cacheKey);
+      if (cached) {
+        return cached;
+      }
+
       const detected = yield* detect(input);
       if (detected) {
+        resolveCache.set(cacheKey, detected);
         return detected;
       }
 
-      const requestedKind = input.requestedKind ?? "auto";
       return yield* unsupported(
         "VcsDriverRegistry.resolve",
         requestedKind === "auto" ? "unknown" : requestedKind,

You can send follow-ups to the cloud agent here.

Comment thread apps/server/src/checkpointing/Layers/CheckpointStore.ts
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: VcsStatusBroadcaster drops CWD symlink normalization from old code
    • Restored the normalizeCwd helper (using realpathSync.native) and added calls in getStatus, refreshLocalStatus, refreshStatus, and streamStatus to resolve symlinks before cache lookups and PubSub filtering, matching the old GitStatusBroadcasterLive behavior.

Create PR

Or push these changes by commenting:

@cursor push 1c1db2f34e
Preview (1c1db2f34e)
diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts
--- a/apps/server/src/vcs/VcsStatusBroadcaster.ts
+++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts
@@ -1,3 +1,5 @@
+import { realpathSync } from "node:fs";
+
 import {
   Duration,
   Effect,
@@ -63,6 +65,14 @@
   VcsStatusBroadcasterShape
 >()("t3/vcs/VcsStatusBroadcaster") {}
 
+function normalizeCwd(cwd: string): string {
+  try {
+    return realpathSync.native(cwd);
+  } catch {
+    return cwd;
+  }
+}
+
 function fingerprintStatusPart(status: unknown): string {
   return JSON.stringify(status);
 }
@@ -188,16 +198,18 @@
     const getStatus: VcsStatusBroadcasterShape["getStatus"] = Effect.fn(
       "VcsStatusBroadcaster.getStatus",
     )(function* (input) {
+      const cwd = normalizeCwd(input.cwd);
       const [local, remote] = yield* Effect.all([
-        getOrLoadLocalStatus(input.cwd),
-        getOrLoadRemoteStatus(input.cwd),
+        getOrLoadLocalStatus(cwd),
+        getOrLoadRemoteStatus(cwd),
       ]);
       return mergeGitStatusParts(local, remote);
     });
 
     const refreshLocalStatus: VcsStatusBroadcasterShape["refreshLocalStatus"] = Effect.fn(
       "VcsStatusBroadcaster.refreshLocalStatus",
-    )(function* (cwd) {
+    )(function* (rawCwd) {
+      const cwd = normalizeCwd(rawCwd);
       yield* workflow.invalidateLocalStatus(cwd);
       const local = yield* workflow.localStatus({ cwd });
       return yield* updateCachedLocalStatus(cwd, local, { publish: true });
@@ -213,7 +225,8 @@
 
     const refreshStatus: VcsStatusBroadcasterShape["refreshStatus"] = Effect.fn(
       "VcsStatusBroadcaster.refreshStatus",
-    )(function* (cwd) {
+    )(function* (rawCwd) {
+      const cwd = normalizeCwd(rawCwd);
       const [local, remote] = yield* Effect.all([
         refreshLocalStatus(cwd),
         refreshRemoteStatus(cwd),
@@ -299,12 +312,13 @@
     const streamStatus: VcsStatusBroadcasterShape["streamStatus"] = (input) =>
       Stream.unwrap(
         Effect.gen(function* () {
+          const cwd = normalizeCwd(input.cwd);
           const subscription = yield* PubSub.subscribe(changesPubSub);
-          const initialLocal = yield* getOrLoadLocalStatus(input.cwd);
-          const initialRemote = (yield* getCachedStatus(input.cwd))?.remote?.value ?? null;
-          yield* retainRemotePoller(input.cwd);
+          const initialLocal = yield* getOrLoadLocalStatus(cwd);
+          const initialRemote = (yield* getCachedStatus(cwd))?.remote?.value ?? null;
+          yield* retainRemotePoller(cwd);
 
-          const release = releaseRemotePoller(input.cwd).pipe(Effect.ignore, Effect.asVoid);
+          const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid);
 
           return Stream.concat(
             Stream.make({
@@ -313,7 +327,7 @@
               remote: initialRemote,
             }),
             Stream.fromSubscription(subscription).pipe(
-              Stream.filter((event) => event.cwd === input.cwd),
+              Stream.filter((event) => event.cwd === cwd),
               Stream.map((event) => event.event),
             ),
           ).pipe(Stream.ensuring(release));

You can send follow-ups to the cloud agent here.

Comment thread apps/server/src/vcs/VcsStatusBroadcaster.ts
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Custom env silently dropped in VCS driver execute
    • Added env to the gitCommand options signature and forwarded input.env from the execute method through gitCommand to process.run, so GIT_INDEX_FILE and other environment variables are now properly passed to the spawned git processes.

Create PR

Or push these changes by commenting:

@cursor push e4920a3ee7
Preview (e4920a3ee7)
diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts
--- a/apps/server/src/vcs/GitVcsDriver.ts
+++ b/apps/server/src/vcs/GitVcsDriver.ts
@@ -257,6 +257,7 @@
   args: ReadonlyArray<string>,
   options?: {
     readonly stdin?: string;
+    readonly env?: NodeJS.ProcessEnv;
     readonly allowNonZeroExit?: boolean;
     readonly timeoutMs?: number;
     readonly maxOutputBytes?: number;
@@ -269,6 +270,7 @@
     args,
     cwd,
     ...(options?.stdin !== undefined ? { stdin: options.stdin } : {}),
+    ...(options?.env !== undefined ? { env: options.env } : {}),
     ...(options?.allowNonZeroExit !== undefined
       ? { allowNonZeroExit: options.allowNonZeroExit }
       : {}),
@@ -305,6 +307,7 @@
   const execute: VcsDriverShape["execute"] = (input) =>
     gitCommand(process, input.operation, input.cwd, input.args, {
       ...(input.stdin !== undefined ? { stdin: input.stdin } : {}),
+      ...(input.env !== undefined ? { env: input.env } : {}),
       ...(input.allowNonZeroExit !== undefined ? { allowNonZeroExit: input.allowNonZeroExit } : {}),
       ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
       ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}),

You can send follow-ups to the cloud agent here.

Comment thread apps/server/src/checkpointing/Layers/CheckpointStore.ts
Comment thread apps/web/src/components/BranchToolbar.logic.ts Outdated
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Dual exit code field names create fragile inconsistency
    • Renamed ExecuteGitResult.code to ExecuteGitResult.exitCode to match VcsProcessOutput.exitCode, unifying the exit code field name across both execute signatures and updating all 30+ references in GitVcsDriverCore.ts, SourceControlProviderRegistry.ts, and test files.

Create PR

Or push these changes by commenting:

@cursor push 5f17833408
Preview (5f17833408)
diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts
--- a/apps/server/src/git/GitManager.test.ts
+++ b/apps/server/src/git/GitManager.test.ts
@@ -224,7 +224,7 @@
       allowNonZeroExit,
     });
     return {
-      code: result.code,
+      code: result.exitCode,
       stdout: result.stdout,
       stderr: result.stderr,
     };

diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts
--- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts
+++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts
@@ -8,7 +8,7 @@
 const processResult = (stdout: string) => ({
   stdout,
   stderr: "",
-  code: 0,
+  exitCode: 0,
   signal: null,
   timedOut: false,
   stdoutTruncated: false,

diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts
--- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts
+++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts
@@ -89,7 +89,7 @@
           })
           .pipe(
             Effect.map((result) =>
-              result.code === 0 ? firstRemoteUrlFromVerboseOutput(result.stdout) : null,
+              result.exitCode === 0 ? firstRemoteUrlFromVerboseOutput(result.stdout) : null,
             ),
             Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error)),
           ));

diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts
--- a/apps/server/src/vcs/GitVcsDriver.ts
+++ b/apps/server/src/vcs/GitVcsDriver.ts
@@ -35,7 +35,7 @@
 }
 
 export interface ExecuteGitResult {
-  readonly code: number;
+  readonly exitCode: number;
   readonly stdout: string;
   readonly stderr: string;
   readonly stdoutTruncated: boolean;

diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts
--- a/apps/server/src/vcs/GitVcsDriverCore.ts
+++ b/apps/server/src/vcs/GitVcsDriverCore.ts
@@ -693,7 +693,7 @@
         }
 
         return {
-          code: exitCode,
+          exitCode,
           stdout: stdout.text,
           stderr: stderr.text,
           stdoutTruncated: stdout.truncated,
@@ -761,7 +761,7 @@
       ...(options.progress ? { progress: options.progress } : {}),
     }).pipe(
       Effect.flatMap((result) => {
-        if (options.allowNonZeroExit || result.code === 0) {
+        if (options.allowNonZeroExit || result.exitCode === 0) {
           return Effect.succeed(result);
         }
         const stderr = result.stderr.trim();
@@ -778,7 +778,7 @@
             operation,
             cwd,
             args,
-            `${commandLabel(args)} failed: code=${result.code ?? "null"}`,
+            `${commandLabel(args)} failed: code=${result.exitCode ?? "null"}`,
           ),
         );
       }),
@@ -823,7 +823,7 @@
         allowNonZeroExit: true,
         timeoutMs: 5_000,
       },
-    ).pipe(Effect.map((result) => result.code === 0));
+    ).pipe(Effect.map((result) => result.exitCode === 0));
 
   const resolveAvailableBranchName = Effect.fn("resolveAvailableBranchName")(function* (
     cwd: string,
@@ -939,7 +939,7 @@
       { allowNonZeroExit: true },
     ).pipe(
       Effect.map((result) => {
-        if (result.code !== 0) {
+        if (result.exitCode !== 0) {
           return null;
         }
         return parseDefaultBranchFromRemoteHeadRef(result.stdout, remoteName);
@@ -958,12 +958,12 @@
       {
         allowNonZeroExit: true,
       },
-    ).pipe(Effect.map((result) => result.code === 0));
+    ).pipe(Effect.map((result) => result.exitCode === 0));
 
   const originRemoteExists = (cwd: string): Effect.Effect<boolean, GitCommandError> =>
     executeGit("GitVcsDriver.originRemoteExists", cwd, ["remote", "get-url", "origin"], {
       allowNonZeroExit: true,
-    }).pipe(Effect.map((result) => result.code === 0));
+    }).pipe(Effect.map((result) => result.exitCode === 0));
 
   const listRemoteNames = (cwd: string): Effect.Effect<ReadonlyArray<string>, GitCommandError> =>
     runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe(
@@ -1115,7 +1115,7 @@
       ["rev-list", "--count", `${baseRef}..HEAD`],
       { allowNonZeroExit: true },
     );
-    if (result.code !== 0) {
+    if (result.exitCode !== 0) {
       return 0;
     }
 
@@ -1140,7 +1140,7 @@
     );
 
     const branchLastCommit = new Map<string, number>();
-    if (branchRecency.code !== 0) {
+    if (branchRecency.exitCode !== 0) {
       return branchLastCommit;
     }
 
@@ -1173,7 +1173,7 @@
       return NON_REPOSITORY_STATUS_DETAILS;
     }
 
-    if (statusResult.code !== 0) {
+    if (statusResult.exitCode !== 0) {
       const stderr = statusResult.stderr.trim();
       return yield* createGitCommandError(
         "GitVcsDriver.statusDetails.status",
@@ -1206,7 +1206,7 @@
       );
     const statusStdout = statusResult.stdout;
     const defaultBranch =
-      defaultRefResult.code === 0
+      defaultRefResult.exitCode === 0
         ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
         : null;
 
@@ -1614,7 +1614,7 @@
     ).pipe(
       Effect.catchIf(isMissingGitCwdError, () =>
         Effect.succeed({
-          code: 128,
+          exitCode: 128,
           stdout: "",
           stderr: "fatal: not a git repository",
           stdoutTruncated: false,
@@ -1623,7 +1623,7 @@
       ),
     );
 
-    if (localBranchResult.code !== 0) {
+    if (localBranchResult.exitCode !== 0) {
       const stderr = localBranchResult.stderr.trim();
       if (stderr.toLowerCase().includes("not a git repository")) {
         return {
@@ -1654,7 +1654,7 @@
       Effect.catch((error) =>
         Effect.logWarning(
           `GitVcsDriver.listRefs: remote refName lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote refName list.`,
-        ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })),
+        ).pipe(Effect.as({ exitCode: 1, stdout: "", stderr: "" })),
       ),
     );
 
@@ -1670,7 +1670,7 @@
       Effect.catch((error) =>
         Effect.logWarning(
           `GitVcsDriver.listRefs: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`,
-        ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })),
+        ).pipe(Effect.as({ exitCode: 1, stdout: "", stderr: "" })),
       ),
     );
 
@@ -1703,25 +1703,25 @@
       );
 
     const remoteNames =
-      remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : [];
-    if (remoteBranchResult.code !== 0 && remoteBranchResult.stderr.trim().length > 0) {
+      remoteNamesResult.exitCode === 0 ? parseRemoteNames(remoteNamesResult.stdout) : [];
+    if (remoteBranchResult.exitCode !== 0 && remoteBranchResult.stderr.trim().length > 0) {
       yield* Effect.logWarning(
-        `GitVcsDriver.listRefs: remote refName lookup returned code ${remoteBranchResult.code} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote refName list.`,
+        `GitVcsDriver.listRefs: remote refName lookup returned code ${remoteBranchResult.exitCode} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote refName list.`,
       );
     }
-    if (remoteNamesResult.code !== 0 && remoteNamesResult.stderr.trim().length > 0) {
+    if (remoteNamesResult.exitCode !== 0 && remoteNamesResult.stderr.trim().length > 0) {
       yield* Effect.logWarning(
-        `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.code} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`,
+        `GitVcsDriver.listRefs: remote name lookup returned code ${remoteNamesResult.exitCode} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`,
       );
     }
 
     const defaultBranch =
-      defaultRef.code === 0
+      defaultRef.exitCode === 0
         ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
         : null;
 
     const worktreeMap = new Map<string, string>();
-    if (worktreeList.code === 0) {
+    if (worktreeList.exitCode === 0) {
       let currentPath: string | null = null;
       for (const line of worktreeList.stdout.split("\n")) {
         if (line.startsWith("worktree ")) {
@@ -1762,7 +1762,7 @@
       });
 
     const remoteBranches =
-      remoteBranchResult.code === 0
+      remoteBranchResult.exitCode === 0
         ? remoteBranchResult.stdout
             .split("\n")
             .map(parseBranchLine)
@@ -1943,7 +1943,7 @@
             timeoutMs: 5_000,
             allowNonZeroExit: true,
           },
-        ).pipe(Effect.map((result) => result.code === 0)),
+        ).pipe(Effect.map((result) => result.exitCode === 0)),
         executeGit(
           "GitVcsDriver.switchRef.remoteExists",
           input.cwd,
@@ -1952,7 +1952,7 @@
             timeoutMs: 5_000,
             allowNonZeroExit: true,
           },
-        ).pipe(Effect.map((result) => result.code === 0)),
+        ).pipe(Effect.map((result) => result.exitCode === 0)),
       ],
       { concurrency: "unbounded" },
     );
@@ -1968,7 +1968,7 @@
           },
         ).pipe(
           Effect.map((result) =>
-            result.code === 0
+            result.exitCode === 0
               ? parseTrackingBranchByUpstreamRef(result.stdout, input.refName)
               : null,
           ),
@@ -1986,7 +1986,7 @@
               timeoutMs: 5_000,
               allowNonZeroExit: true,
             },
-          ).pipe(Effect.map((result) => result.code === 0))
+          ).pipe(Effect.map((result) => result.exitCode === 0))
         : false;
 
     const checkoutArgs = localInputExists

You can send follow-ups to the cloud agent here.

Comment thread apps/server/src/vcs/GitVcsDriver.ts
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Missing empty-stdout guard breaks PR lookup loop
    • Added an empty-string guard before JSON decoding in the non-open state branch of listChangeRequests, returning an empty array when stdout is empty so the findLatestPr loop can continue trying remaining head selectors.

Create PR

Or push these changes by commenting:

@cursor push d5fa9c0593
Preview (d5fa9c0593)
diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts
--- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts
+++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts
@@ -76,8 +76,12 @@
         ],
       })
       .pipe(
-        Effect.flatMap((result) =>
-          Effect.sync(() => decodeGitHubPullRequestListJson(result.stdout.trim())).pipe(
+        Effect.flatMap((result) => {
+          const raw = result.stdout.trim();
+          if (raw.length === 0) {
+            return Effect.succeed([]);
+          }
+          return Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe(
             Effect.flatMap((decoded) =>
               Result.isSuccess(decoded)
                 ? Effect.succeed(
@@ -95,8 +99,8 @@
                     }),
                   ),
             ),
-          ),
-        ),
+          );
+        }),
         Effect.mapError((error) =>
           Schema.is(SourceControlProviderError)(error)
             ? error

You can send follow-ups to the cloud agent here.

Comment thread apps/server/src/sourceControl/GitHubSourceControlProvider.ts
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Provider resolution failure bypasses default branch fallback
    • Wrapped both sourceControlProvider(cwd) resolution and .getDefaultBranch() in a single Effect pipeline using Effect.flatMap, so Effect.catch now handles failures from either step and the "main" fallback is always reachable.

Create PR

Or push these changes by commenting:

@cursor push b73e16cbcc
Preview (b73e16cbcc)
diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts
--- a/apps/server/src/git/GitManager.ts
+++ b/apps/server/src/git/GitManager.ts
@@ -1045,9 +1045,10 @@
       }
     }
 
-    const defaultFromProvider = yield* (yield* sourceControlProvider(cwd))
-      .getDefaultBranch({ cwd })
-      .pipe(Effect.catch(() => Effect.succeed(null)));
+    const defaultFromProvider = yield* sourceControlProvider(cwd).pipe(
+      Effect.flatMap((provider) => provider.getDefaultBranch({ cwd })),
+      Effect.catch(() => Effect.succeed(null)),
+    );
     if (defaultFromProvider) {
       return defaultFromProvider;
     }

You can send follow-ups to the cloud agent here.

Comment thread apps/server/src/git/GitManager.ts Outdated
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 3 total unresolved issues (including 2 from previous reviews).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Local variable shadows outer sourceControlProvider function
    • Renamed the local variable from sourceControlProvider to hostingProvider inside readLocalStatus to eliminate the shadowing of the outer sourceControlProvider function, while preserving the sourceControlProvider key in the returned object.

Create PR

Or push these changes by commenting:

@cursor push fde1ecb449
Preview (fde1ecb449)
diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts
--- a/apps/server/src/git/GitManager.ts
+++ b/apps/server/src/git/GitManager.ts
@@ -699,13 +699,13 @@
       .pipe(
         Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)),
       );
-    const sourceControlProvider = details.isRepo
+    const hostingProvider = details.isRepo
       ? yield* resolveHostingProvider(cwd, details.branch)
       : null;
 
     return {
       isRepo: details.isRepo,
-      ...(sourceControlProvider ? { sourceControlProvider } : {}),
+      ...(hostingProvider ? { sourceControlProvider: hostingProvider } : {}),
       hasPrimaryRemote: details.hasOriginRemote,
       isDefaultRef: details.isDefaultBranch,
       refName: details.branch,

You can send follow-ups to the cloud agent here.

Comment thread apps/server/src/git/GitManager.ts Outdated
@juliusmarminge
Copy link
Copy Markdown
Member Author

@cursor push fde1ecb

Comment thread apps/server/src/git/GitManager.ts
@juliusmarminge juliusmarminge force-pushed the t3code/pluggable-git-integration branch from 84826c9 to a73408c Compare May 2, 2026 07:04
Comment thread apps/server/src/git/GitManager.ts
Comment thread packages/client-runtime/src/sourceControlDiscoveryState.ts
@juliusmarminge juliusmarminge force-pushed the t3code/pluggable-git-integration branch from 476e591 to 47942a9 Compare May 2, 2026 19:56
Comment thread packages/client-runtime/src/sourceControlDiscoveryState.ts
@@ -496,9 +448,12 @@ const createTrace2Monitor = Effect.fn("createTrace2Monitor")(function* (

if (event === "child_exit") {
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.

🟡 Medium vcs/GitVcsDriverCore.ts:449

Line 451 references traceRecord.success.exitCode, but git trace2 child_exit events use the field name code, not exitCode. Since exitCode does not exist on the trace record, code is always undefined, so the typeof code === "number" check on line 452 always fails and exitCode is always null for hook finished events. The diff shows this was incorrectly changed from .code to .exitCode.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/vcs/GitVcsDriverCore.ts around line 449:

Line 451 references `traceRecord.success.exitCode`, but git trace2 `child_exit` events use the field name `code`, not `exitCode`. Since `exitCode` does not exist on the trace record, `code` is always `undefined`, so the `typeof code === "number"` check on line 452 always fails and `exitCode` is always `null` for hook finished events. The diff shows this was incorrectly changed from `.code` to `.exitCode`.

Evidence trail:
1. apps/server/src/vcs/GitVcsDriverCore.ts line 451: `const code = traceRecord.success.exitCode;` (at REVIEWED_COMMIT)
2. apps/server/src/vcs/GitVcsDriverCore.ts line 452: `const exitCode = typeof code === "number" && Number.isInteger(code) ? code : null;`
3. Trace2Record schema at line 387: `const Trace2Record = Schema.Record(Schema.String, Schema.Unknown);` — produces Record<string, unknown>
4. Git trace2 documentation: https://git-scm.com/docs/api-trace2 — child_exit event format shows field name is `code`, not `exitCode`:
   [code fence]json
   {"event":"child_exit", "child_id":2, "pid":14708, "code":0, "t_rel":0.110605}
   [code fence]
5. PR diff shows file is entirely new (`--- /dev/null`, `new file mode 100644`) — sub-claim about 'diff showing change from .code to .exitCode' is incorrect but ancillary

Julius Marminge added 8 commits May 2, 2026 14:22
- Add phase 1 VCS driver plan
- Add phase 2 source control provider plan
- Index both plans in `.plans/README.md`
- Replace direct GitCore dependencies with VcsDriver and VcsProcess
- Move checkpointing, workspace, and orchestration flows onto the new VCS abstraction
- Drop workspace-specific git helpers from GitCore
- Move VcsDriver and VcsProcess out of nested service/layer paths
- Update server, workspace, orchestration, and tests to use new exports
- Rename layer exports to `layer` for consistency
- Switch GitVcsDriver and VcsProcess usages to namespace imports
- Update server and integration wiring to use `.layer` consistently
- Keep test type annotations aligned with the exported service types
- Remove the aliased service import
- Reference `VcsProcess.VcsProcess` explicitly in checkpoint and workspace tests
- Import `VcsProcess` as a namespace in the test
- Update layer injection to target `VcsProcess.VcsProcess`
- Switch freshness and scan timestamps to `DateTime.now` from Effect
- Keep timestamps consistent within each async operation
- Introduce a dedicated `GitVcsDriver` service and `vcsLayer`
- Update server, orchestration, and workspace wiring to use the new layer
- Adjust tests and mocks for the new git-specific dependency
Julius Marminge and others added 14 commits May 2, 2026 14:22
- Move GitManager and GitHubCli contracts/layers to their new modules
- Update server wiring and tests for the relocated pluggable git setup
- Resolve symlinked working directories before caching and workflow calls
- Pass execute env through the Git VCS driver
- Rename branch fixtures to refs and primary remote flags
- Align Git actions and keybinding tests with new ref labels
- Switch VCS process and git driver results to `ChildProcessSpawner.ExitCode`
- Update status checks and fallback results to use the new typed exit code
- Refresh tests to match the new result shape
- Add a reusable `spawn` API for VCS processes
- Factor stream collection into `collectText`
- Preserve existing `run` behavior on top of the new handle
- Replace raw spawn access with `withProcess` for scoped use
- Ensure child processes are finalized even on early exits
- Keep `run` built on the scoped helper for safer cleanup
- Treat empty `gh pr list` output as no results
- Fall back to `main` when provider default-branch detection fails
- Add coverage for PR creation fallback behavior
Co-authored-by: codex <codex@users.noreply.github.com>
@juliusmarminge juliusmarminge force-pushed the t3code/pluggable-git-integration branch from 7aa00ff to f4180a4 Compare May 2, 2026 21:27
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Unhandled provider error aborts action before commit/push
    • Wrapped the sourceControlProvider() call in runStackedAction with Effect.catch fallback to getChangeRequestTerminologyForKind("unknown"), matching the existing pattern in buildCompletionToast.

Create PR

Or push these changes by commenting:

@cursor push 6dd13497dc
Preview (6dd13497dc)
diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts
--- a/apps/server/src/git/GitManager.ts
+++ b/apps/server/src/git/GitManager.ts
@@ -1663,7 +1663,10 @@
         const currentBranch = branchStep.name ?? initialStatus.branch;
         const commitAction = isCommitAction(input.action) ? input.action : null;
         const changeRequestTerms = wantsPr
-          ? getChangeRequestTerminologyForKind((yield* sourceControlProvider(input.cwd)).kind)
+          ? yield* sourceControlProvider(input.cwd).pipe(
+              Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)),
+              Effect.catch(() => Effect.succeed(getChangeRequestTerminologyForKind("unknown"))),
+            )
           : null;
 
         const commit = commitAction

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit 76dc275. Configure here.

Comment thread apps/server/src/git/GitManager.ts
Julius Marminge and others added 5 commits May 2, 2026 15:15
Wrap sourceControlProvider() call at line 1665 with Effect.catch fallback,
matching the existing pattern in buildCompletionToast. The provider resolution
is only used for cosmetic progress labels and should never abort the commit/push
action.
@juliusmarminge
Copy link
Copy Markdown
Member Author

@cursor push 6dd1349

Wrap sourceControlProvider() call at line 1665 with Effect.catch fallback,
matching the existing pattern in buildCompletionToast. The provider resolution
is only used for cosmetic progress labels and should never abort the commit/push
action.

Applied via @cursor push command
@juliusmarminge juliusmarminge merged commit 6d7fe2e into main May 2, 2026
12 checks passed
@juliusmarminge juliusmarminge deleted the t3code/pluggable-git-integration branch May 2, 2026 23:08
Morphexe pushed a commit to Morphexe/t3code that referenced this pull request May 4, 2026
Co-authored-by: Julius Marminge <julius@macmini.local>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: cursor[bot] <206951365+cursor[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants