diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 87c81f08c8..044a78baaa 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -27,6 +27,8 @@ import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.t import { GitCoreLive } from "../src/git/Layers/GitCore.ts"; import { GitCore, type GitCoreShape } from "../src/git/Services/GitCore.ts"; import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts"; +import { VcsCore } from "../src/vcs/Services/VcsCore.ts"; +import { VcsCoreFromGitLive } from "../src/vcs/Layers/VcsCore.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts"; @@ -288,7 +290,9 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provide(AnalyticsService.layerTest), ); - const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive)); + const checkpointStoreLayer = CheckpointStoreLive.pipe( + Layer.provide(VcsCoreFromGitLive.pipe(Layer.provide(GitCoreLive))), + ); const projectionSnapshotQueryLayer = OrchestrationProjectionSnapshotQueryLive; const runtimeServicesLayer = Layer.mergeAll( projectionSnapshotQueryLayer, @@ -304,10 +308,12 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(serverSettingsLayer), ); - const gitCoreLayer = Layer.succeed(GitCore, { + const branchRenameLayerValue = { renameBranch: (input: Parameters[0]) => Effect.succeed({ branch: input.newBranch }), - } as unknown as GitCoreShape); + } as unknown as GitCoreShape; + const gitCoreLayer = Layer.succeed(GitCore, branchRenameLayerValue); + const vcsCoreLayer = Layer.succeed(VcsCore, branchRenameLayerValue); const textGenerationLayer = Layer.succeed(TextGeneration, { generateBranchName: () => Effect.succeed({ branch: "update" }), generateThreadTitle: () => Effect.succeed({ title: "New thread" }), @@ -315,6 +321,7 @@ export const makeOrchestrationIntegrationHarness = ( const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(gitCoreLayer), + Layer.provideMerge(vcsCoreLayer), Layer.provideMerge(textGenerationLayer), Layer.provideMerge(serverSettingsLayer), ); @@ -323,7 +330,7 @@ export const makeOrchestrationIntegrationHarness = ( Layer.provideMerge( WorkspaceEntriesLive.pipe( Layer.provide(WorkspacePathsLive), - Layer.provideMerge(gitCoreLayer), + Layer.provideMerge(vcsCoreLayer), Layer.provide(NodeServices.layer), ), ), diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts index 6e7b18277c..c384aee52d 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.test.ts @@ -13,6 +13,9 @@ import { GitCore } from "../../git/Services/GitCore.ts"; import { GitCommandError } from "@t3tools/contracts"; import { ServerConfig } from "../../config.ts"; import { ThreadId } from "@t3tools/contracts"; +import { JjCoreLive } from "../../jj/Layers/JjCore.ts"; +import { initJjRepo } from "../../jj/Layers/JjTestUtils.ts"; +import { VcsCoreLive } from "../../vcs/Layers/VcsCore.ts"; const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-checkpoint-store-test-", @@ -21,11 +24,26 @@ const GitCoreTestLayer = GitCoreLive.pipe( Layer.provide(ServerConfigLayer), Layer.provide(NodeServices.layer), ); +const JjCoreTestLayer = JjCoreLive.pipe( + Layer.provide(ServerConfigLayer), + Layer.provideMerge(GitCoreTestLayer), + Layer.provideMerge(NodeServices.layer), +); +const VcsCoreTestLayer = VcsCoreLive.pipe( + Layer.provideMerge(GitCoreTestLayer), + Layer.provideMerge(JjCoreTestLayer), +); const CheckpointStoreTestLayer = CheckpointStoreLive.pipe( - Layer.provide(GitCoreTestLayer), + Layer.provideMerge(VcsCoreTestLayer), Layer.provide(NodeServices.layer), ); -const TestLayer = Layer.mergeAll(NodeServices.layer, GitCoreTestLayer, CheckpointStoreTestLayer); +const TestLayer = Layer.mergeAll( + NodeServices.layer, + GitCoreTestLayer, + JjCoreTestLayer, + VcsCoreTestLayer, + CheckpointStoreTestLayer, +); function makeTmpDir( prefix = "checkpoint-store-test-", @@ -46,6 +64,15 @@ function writeTextFile( }); } +function fsReadText( + filePath: string, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.readFileString(filePath); + }); +} + function git( cwd: string, args: ReadonlyArray, @@ -88,20 +115,54 @@ function buildLargeText(lineCount = 5_000): string { it.layer(TestLayer)("CheckpointStoreLive", (it) => { describe("diffCheckpoints", () => { - it.effect("returns full oversized checkpoint diffs without truncation", () => + it.effect( + "returns full oversized checkpoint diffs without truncation for git repositories", + () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const checkpointStore = yield* CheckpointStore; + const threadId = ThreadId.makeUnsafe("thread-checkpoint-store"); + const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); + const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); + + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: fromCheckpointRef, + }); + yield* writeTextFile(path.join(tmp, "README.md"), buildLargeText()); + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: toCheckpointRef, + }); + + const diff = yield* checkpointStore.diffCheckpoints({ + cwd: tmp, + fromCheckpointRef, + toCheckpointRef, + }); + + expect(diff).toContain("diff --git"); + expect(diff).not.toContain("[truncated]"); + expect(diff).toContain("+line 04999"); + }), + ); + + it.effect("captures JJ checkpoints as native revisions instead of hidden git refs", () => Effect.gen(function* () { - const tmp = yield* makeTmpDir(); - yield* initRepoWithCommit(tmp); + const tmp = yield* makeTmpDir("checkpoint-store-jj-test-"); + yield* initJjRepo(tmp); const checkpointStore = yield* CheckpointStore; - const threadId = ThreadId.makeUnsafe("thread-checkpoint-store"); + const threadId = ThreadId.makeUnsafe("thread-checkpoint-store-jj"); const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); + const fileSystem = yield* FileSystem.FileSystem; yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: fromCheckpointRef, }); - yield* writeTextFile(path.join(tmp, "README.md"), buildLargeText()); + yield* writeTextFile(path.join(tmp, "notes.txt"), "native jj checkpoint\n"); yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: toCheckpointRef, @@ -114,8 +175,36 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => { }); expect(diff).toContain("diff --git"); - expect(diff).not.toContain("[truncated]"); - expect(diff).toContain("+line 04999"); + expect(diff).toContain("+++ b/notes.txt"); + + const gitCore = yield* GitCore; + const gitRefResult = yield* gitCore.execute({ + operation: "CheckpointStore.test.git.verifyMissingRef", + cwd: tmp, + args: ["rev-parse", "--verify", "--quiet", `${toCheckpointRef}^{commit}`], + allowNonZeroExit: true, + timeoutMs: 10_000, + }); + expect(gitRefResult.code).not.toBe(0); + + yield* fileSystem.remove(path.join(tmp, "notes.txt")); + const restored = yield* checkpointStore.restoreCheckpoint({ + cwd: tmp, + checkpointRef: toCheckpointRef, + }); + expect(restored).toBe(true); + expect(yield* fsReadText(path.join(tmp, "notes.txt"))).toBe("native jj checkpoint\n"); + + const restoredToInitial = yield* checkpointStore.restoreCheckpoint({ + cwd: tmp, + checkpointRef: fromCheckpointRef, + }); + expect(restoredToInitial).toBe(true); + const notesExists = yield* fileSystem.stat(path.join(tmp, "notes.txt")).pipe( + Effect.map(() => true), + Effect.catch(() => Effect.succeed(false)), + ); + expect(notesExists).toBe(false); }), ); }); diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.ts index 184ec96323..f2063eddb3 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.ts @@ -1,10 +1,13 @@ /** * CheckpointStoreLive - Filesystem checkpoint store adapter layer. * - * Implements hidden Git-ref checkpoint capture/restore directly with - * Effect-native child process execution (`effect/unstable/process`). + * Implements VCS-native checkpoint capture/restore. Git repositories use + * hidden refs and git object plumbing; jj repositories leverage the + * operation log — each checkpoint records the current operation ID, and + * restore/diff resolve the working-copy commit at that operation via + * `--at-op`. * - * This layer owns filesystem/Git interactions only; it does not persist + * This layer owns filesystem/VCS interactions only; it does not persist * checkpoint metadata and does not coordinate provider rollback semantics. * * @module CheckpointStoreLive @@ -15,17 +18,157 @@ import { Effect, Layer, FileSystem, Path } from "effect"; import { CheckpointInvariantError } from "../Errors.ts"; import { GitCommandError } from "@t3tools/contracts"; -import { GitCore } from "../../git/Services/GitCore.ts"; import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; import { CheckpointRef } from "@t3tools/contracts"; +import { VcsCore } from "../../vcs/Services/VcsCore.ts"; +import { detectRepoKind } from "../../vcs/Utils.ts"; +import { resolveJjRepoDir, runJjCommand, runJjStdout } from "../../jj/Utils.ts"; + +const JJ_CHECKPOINTS_DIR = "t3-checkpoints"; + +interface JjCheckpointPayload { + operationId?: unknown; + /** @deprecated Legacy format — kept for backward compatibility. */ + commitId?: unknown; +} + +function encodeCheckpointRef(checkpointRef: CheckpointRef): string { + return Buffer.from(checkpointRef, "utf8").toString("hex"); +} const makeCheckpointStore = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const git = yield* GitCore; + const vcs = yield* VcsCore; + const repoKindOf = (cwd: string) => detectRepoKind(cwd); + + // --------------------------------------------------------------------------- + // JJ oplog helpers + // --------------------------------------------------------------------------- + + /** Force a working-copy snapshot and return the current operation ID. */ + const readJjOperationId = (cwd: string): Effect.Effect => + // `jj file track .` forces a snapshot (including untracked files) and + // produces an operation that captures the full working-copy state. + trackJjWorkspace(cwd).pipe( + Effect.flatMap(() => + runJjStdout("CheckpointStore.readJjOperationId", cwd, [ + "op", + "log", + "--limit", + "1", + "--no-graph", + "-T", + "self.id()", + ]), + ), + Effect.map((stdout) => { + const opId = stdout.trim(); + return opId.length > 0 ? opId : null; + }), + Effect.catch(() => Effect.succeed(null)), + ); + + /** Resolve the working-copy commit ID at a specific operation. */ + const readJjCommitIdAtOp = ( + cwd: string, + operationId: string, + ): Effect.Effect => + runJjStdout("CheckpointStore.readJjCommitIdAtOp", cwd, [ + "log", + "-r", + "@", + "--no-graph", + "-T", + 'commit_id ++ "\\n"', + "--at-op", + operationId, + ]).pipe( + Effect.map((stdout) => { + const commitId = stdout.trim(); + return commitId.length > 0 ? commitId : null; + }), + Effect.catch(() => Effect.succeed(null)), + ); + + /** Read current working-copy commit (fallback for legacy checkpoints). */ + const readJjCommitId = (cwd: string): Effect.Effect => + runJjStdout("CheckpointStore.readJjCommitId", cwd, [ + "log", + "-r", + "@", + "--no-graph", + "-T", + 'commit_id ++ "\\n"', + ]).pipe( + Effect.map((stdout) => { + const commitId = stdout.trim(); + return commitId.length > 0 ? commitId : null; + }), + Effect.catch(() => Effect.succeed(null)), + ); + + const resolveJjCheckpointPath = (cwd: string, checkpointRef: CheckpointRef) => + resolveJjRepoDir(cwd).pipe( + Effect.map((repoDir) => + path.join(repoDir, JJ_CHECKPOINTS_DIR, `${encodeCheckpointRef(checkpointRef)}.json`), + ), + ); + + /** + * Resolve the commit ID stored in a checkpoint file. + * Supports both the new oplog format (`operationId`) and the legacy + * format (`commitId`). + */ + const resolveJjCheckpointCommit = ( + cwd: string, + checkpointRef: CheckpointRef, + ): Effect.Effect => + resolveJjCheckpointPath(cwd, checkpointRef).pipe( + Effect.flatMap((checkpointPath) => fs.readFileString(checkpointPath)), + Effect.map((raw) => JSON.parse(raw) as JjCheckpointPayload), + Effect.flatMap((payload) => { + // New format: resolve commit from the operation ID. + if (typeof payload.operationId === "string" && payload.operationId.trim().length > 0) { + return readJjCommitIdAtOp(cwd, payload.operationId.trim()); + } + // Legacy format: commit ID stored directly. + if (typeof payload.commitId === "string" && payload.commitId.trim().length > 0) { + return Effect.succeed(payload.commitId.trim()); + } + return Effect.succeed(null); + }), + Effect.catch(() => Effect.succeed(null)), + ); + + const trackJjWorkspace = (cwd: string) => + runJjCommand({ + operation: "CheckpointStore.trackJjWorkspace", + cwd, + args: ["file", "track", "."], + allowNonZeroExit: true, + }).pipe( + Effect.flatMap((result) => { + if (result.code === 0) { + return Effect.void; + } + + const detail = result.stderr.trim(); + return detail.length === 0 || detail.includes("No arguments") + ? Effect.void + : Effect.fail( + new GitCommandError({ + operation: "CheckpointStore.trackJjWorkspace", + command: "jj file track .", + cwd, + detail, + }), + ); + }), + ); const resolveHeadCommit = (cwd: string): Effect.Effect => - git + vcs .execute({ operation: "CheckpointStore.resolveHeadCommit", cwd, @@ -43,7 +186,7 @@ const makeCheckpointStore = Effect.gen(function* () { ); const hasHeadCommit = (cwd: string): Effect.Effect => - git + vcs .execute({ operation: "CheckpointStore.hasHeadCommit", cwd, @@ -56,7 +199,7 @@ const makeCheckpointStore = Effect.gen(function* () { cwd: string, checkpointRef: CheckpointRef, ): Effect.Effect => - git + vcs .execute({ operation: "CheckpointStore.resolveCheckpointCommit", cwd, @@ -74,21 +217,49 @@ const makeCheckpointStore = Effect.gen(function* () { ); const isGitRepository: CheckpointStoreShape["isGitRepository"] = (cwd) => - git - .execute({ - operation: "CheckpointStore.isGitRepository", - cwd, - args: ["rev-parse", "--is-inside-work-tree"], - allowNonZeroExit: true, - }) - .pipe( - Effect.map((result) => result.code === 0 && result.stdout.trim() === "true"), - Effect.catch(() => Effect.succeed(false)), - ); + Effect.succeed(repoKindOf(cwd) !== null); const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = Effect.fn( "captureCheckpoint", )(function* (input) { + if (repoKindOf(input.cwd) === "jj") { + // readJjOperationId tracks files and snapshots the working copy before + // reading the operation ID, so the returned ID includes all current + // file state — even when snapshot.auto-track is set to none(). + const operationId = yield* readJjOperationId(input.cwd); + if (!operationId) { + return yield* new GitCommandError({ + operation: "CheckpointStore.captureCheckpoint", + command: "jj op log", + cwd: input.cwd, + detail: "Unable to resolve the current jj operation ID.", + }); + } + + const checkpointPath = yield* resolveJjCheckpointPath(input.cwd, input.checkpointRef); + yield* fs.makeDirectory(path.dirname(checkpointPath), { recursive: true }).pipe( + Effect.mapError( + (cause) => + new CheckpointInvariantError({ + operation: "CheckpointStore.captureCheckpoint", + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }), + ), + ); + yield* fs.writeFileString(checkpointPath, JSON.stringify({ operationId }, null, 2)).pipe( + Effect.mapError( + (cause) => + new CheckpointInvariantError({ + operation: "CheckpointStore.captureCheckpoint", + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }), + ), + ); + return; + } + const operation = "CheckpointStore.captureCheckpoint"; yield* Effect.acquireUseRelease( @@ -106,7 +277,7 @@ const makeCheckpointStore = Effect.gen(function* () { const headExists = yield* hasHeadCommit(input.cwd); if (headExists) { - yield* git.execute({ + yield* vcs.execute({ operation, cwd: input.cwd, args: ["read-tree", "HEAD"], @@ -114,14 +285,14 @@ const makeCheckpointStore = Effect.gen(function* () { }); } - yield* git.execute({ + yield* vcs.execute({ operation, cwd: input.cwd, args: ["add", "-A", "--", "."], env: commitEnv, }); - const writeTreeResult = yield* git.execute({ + const writeTreeResult = yield* vcs.execute({ operation, cwd: input.cwd, args: ["write-tree"], @@ -138,7 +309,7 @@ const makeCheckpointStore = Effect.gen(function* () { } const message = `t3 checkpoint ref=${input.checkpointRef}`; - const commitTreeResult = yield* git.execute({ + const commitTreeResult = yield* vcs.execute({ operation, cwd: input.cwd, args: ["commit-tree", treeOid, "-m", message], @@ -154,7 +325,7 @@ const makeCheckpointStore = Effect.gen(function* () { }); } - yield* git.execute({ + yield* vcs.execute({ operation, cwd: input.cwd, args: ["update-ref", input.checkpointRef, commitOid], @@ -176,13 +347,34 @@ const makeCheckpointStore = Effect.gen(function* () { }); const hasCheckpointRef: CheckpointStoreShape["hasCheckpointRef"] = (input) => - resolveCheckpointCommit(input.cwd, input.checkpointRef).pipe( - Effect.map((commit) => commit !== null), - ); + (repoKindOf(input.cwd) === "jj" + ? resolveJjCheckpointCommit(input.cwd, input.checkpointRef) + : resolveCheckpointCommit(input.cwd, input.checkpointRef) + ).pipe(Effect.map((commit) => commit !== null)); const restoreCheckpoint: CheckpointStoreShape["restoreCheckpoint"] = Effect.fn( "restoreCheckpoint", )(function* (input) { + if (repoKindOf(input.cwd) === "jj") { + let commitOid = yield* resolveJjCheckpointCommit(input.cwd, input.checkpointRef); + + if (!commitOid && input.fallbackToHead === true) { + commitOid = yield* readJjCommitId(input.cwd); + } + + if (!commitOid) { + return false; + } + + yield* trackJjWorkspace(input.cwd); + yield* runJjCommand({ + operation: "CheckpointStore.restoreCheckpoint", + cwd: input.cwd, + args: ["restore", "--from", commitOid], + }); + return true; + } + const operation = "CheckpointStore.restoreCheckpoint"; let commitOid = yield* resolveCheckpointCommit(input.cwd, input.checkpointRef); @@ -195,12 +387,12 @@ const makeCheckpointStore = Effect.gen(function* () { return false; } - yield* git.execute({ + yield* vcs.execute({ operation, cwd: input.cwd, args: ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."], }); - yield* git.execute({ + yield* vcs.execute({ operation, cwd: input.cwd, args: ["clean", "-fd", "--", "."], @@ -208,7 +400,7 @@ const makeCheckpointStore = Effect.gen(function* () { const headExists = yield* hasHeadCommit(input.cwd); if (headExists) { - yield* git.execute({ + yield* vcs.execute({ operation, cwd: input.cwd, args: ["reset", "--quiet", "--", "."], @@ -220,6 +412,34 @@ const makeCheckpointStore = Effect.gen(function* () { const diffCheckpoints: CheckpointStoreShape["diffCheckpoints"] = Effect.fn("diffCheckpoints")( function* (input) { + if (repoKindOf(input.cwd) === "jj") { + let fromCommitOid = yield* resolveJjCheckpointCommit(input.cwd, input.fromCheckpointRef); + const toCommitOid = yield* resolveJjCheckpointCommit(input.cwd, input.toCheckpointRef); + + if (!fromCommitOid && input.fallbackFromToHead === true) { + const headCommit = yield* readJjCommitId(input.cwd); + if (headCommit) { + fromCommitOid = headCommit; + } + } + + if (!fromCommitOid || !toCommitOid) { + return yield* new GitCommandError({ + operation: "CheckpointStore.diffCheckpoints", + command: "jj diff", + cwd: input.cwd, + detail: "Checkpoint ref is unavailable for diff operation.", + }); + } + + const result = yield* runJjCommand({ + operation: "CheckpointStore.diffCheckpoints", + cwd: input.cwd, + args: ["diff", "--from", fromCommitOid, "--to", toCommitOid, "--git"], + }); + return result.stdout; + } + const operation = "CheckpointStore.diffCheckpoints"; let fromCommitOid = yield* resolveCheckpointCommit(input.cwd, input.fromCheckpointRef); @@ -241,7 +461,7 @@ const makeCheckpointStore = Effect.gen(function* () { }); } - const result = yield* git.execute({ + const result = yield* vcs.execute({ operation, cwd: input.cwd, args: ["diff", "--patch", "--minimal", "--no-color", fromCommitOid, toCommitOid], @@ -254,12 +474,25 @@ const makeCheckpointStore = Effect.gen(function* () { const deleteCheckpointRefs: CheckpointStoreShape["deleteCheckpointRefs"] = Effect.fn( "deleteCheckpointRefs", )(function* (input) { + if (repoKindOf(input.cwd) === "jj") { + yield* Effect.forEach( + input.checkpointRefs, + (checkpointRef) => + resolveJjCheckpointPath(input.cwd, checkpointRef).pipe( + Effect.flatMap((checkpointPath) => fs.remove(checkpointPath)), + Effect.catch(() => Effect.void), + ), + { discard: true }, + ); + return; + } + const operation = "CheckpointStore.deleteCheckpointRefs"; yield* Effect.forEach( input.checkpointRefs, (checkpointRef) => - git.execute({ + vcs.execute({ operation, cwd: input.cwd, args: ["update-ref", "-d", checkpointRef], diff --git a/apps/server/src/checkpointing/Services/CheckpointStore.ts b/apps/server/src/checkpointing/Services/CheckpointStore.ts index 284198c145..59875066e3 100644 --- a/apps/server/src/checkpointing/Services/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Services/CheckpointStore.ts @@ -1,9 +1,11 @@ /** * CheckpointStore - Repository interface for filesystem-backed workspace checkpoints. * - * Owns hidden Git-ref checkpoint capture/restore and diff computation for a - * workspace thread timeline. It does not store user-facing checkpoint metadata - * and does not coordinate provider conversation rollback. + * Owns VCS-backed checkpoint capture/restore and diff computation for a + * workspace thread timeline. Git repositories use hidden refs; jj repositories + * persist checkpoint commit ids in shared repo metadata. It does not store + * user-facing checkpoint metadata and does not coordinate provider + * conversation rollback. * * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed * domain errors for checkpoint storage operations. @@ -44,14 +46,12 @@ export interface DeleteCheckpointRefsInput { */ export interface CheckpointStoreShape { /** - * Check whether cwd is inside a Git worktree. + * Check whether cwd is inside a supported version-controlled workspace. */ readonly isGitRepository: (cwd: string) => Effect.Effect; /** - * Capture a checkpoint commit and store it at the provided checkpoint ref. - * - * Uses an isolated temporary Git index and writes a hidden ref. + * Capture a checkpoint and store it at the provided checkpoint ref. */ readonly captureCheckpoint: ( input: CaptureCheckpointInput, diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 99ca21b06d..a7328b7b3b 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -12,7 +12,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { ClaudeModelSelection } from "@t3tools/contracts"; import { resolveApiModelId } from "@t3tools/shared/model"; -import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/vcs"; import { TextGenerationError } from "@t3tools/contracts"; import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 52ddf55453..6f4799f18a 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -4,7 +4,7 @@ import { Effect, FileSystem, Layer, Option, Path, Schema, Scope, Stream } from " import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { CodexModelSelection } from "@t3tools/contracts"; -import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/vcs"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 1178a4b67e..27cae9ac92 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -19,7 +19,7 @@ import { import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, type GitBranch } from "@t3tools/contracts"; -import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; +import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/vcs"; import { compactTraceAttributes } from "../../observability/Attributes.ts"; import { gitCommandDuration, gitCommandsTotal, withMetrics } from "../../observability/Metrics.ts"; import { diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 7fedb15714..7b212c4567 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -14,7 +14,7 @@ import { resolveAutoFeatureBranchName, sanitizeBranchFragment, sanitizeFeatureBranchName, -} from "@t3tools/shared/git"; +} from "@t3tools/shared/vcs"; import { GitManagerError } from "@t3tools/contracts"; import { diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index d7a28d1763..7ca8a6d030 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -115,6 +115,8 @@ export interface GitFetchPullRequestBranchInput { cwd: string; prNumber: number; branch: string; + remoteName?: string; + remoteBranch?: string; } export interface GitEnsureRemoteInput { diff --git a/apps/server/src/jj/Layers/JjCore.test.ts b/apps/server/src/jj/Layers/JjCore.test.ts new file mode 100644 index 0000000000..c9715ca268 --- /dev/null +++ b/apps/server/src/jj/Layers/JjCore.test.ts @@ -0,0 +1,423 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { describe, expect } from "vitest"; + +import { ServerConfig } from "../../config.ts"; +import { GitCoreLive } from "../../git/Layers/GitCore.ts"; +import { JjCoreLive } from "./JjCore.ts"; +import { JjCore } from "../Services/JjCore.ts"; +import { + addRemoteAndPush, + createBareRemote, + initJjRepo, + listBookmarks, + makeTempDir, + runGit, + runJj, + runJjStdout, + writeTextFile, +} from "./JjTestUtils.ts"; + +const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-jj-core-test-" }); +const GitCoreTestLayer = GitCoreLive.pipe( + Layer.provide(ServerConfigLayer), + Layer.provide(NodeServices.layer), +); +const JjCoreTestLayer = JjCoreLive.pipe( + Layer.provide(ServerConfigLayer), + Layer.provide(GitCoreTestLayer), + Layer.provideMerge(NodeServices.layer), +); +const TestLayer = Layer.mergeAll(NodeServices.layer, GitCoreTestLayer, JjCoreTestLayer); + +it.layer(TestLayer)("JjCore", (it) => { + describe("statusDetails", () => { + it.effect("returns status for a clean jj repository", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-jj-core-status-"); + yield* initJjRepo(repoDir); + + const jjCore = yield* JjCore; + const status = yield* jjCore.statusDetails(repoDir); + + expect(status.isRepo).toBe(true); + expect(status.branch).toBe("main"); + expect(status.isDefaultBranch).toBe(true); + expect(status.hasWorkingTreeChanges).toBe(false); + expect(status.workingTree.files).toHaveLength(0); + }), + ); + + it.effect("detects working tree changes", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-jj-core-status-dirty-"); + yield* initJjRepo(repoDir); + yield* writeTextFile(path.join(repoDir, "dirty.txt"), "dirty\n"); + yield* runJj(repoDir, ["file", "track", "dirty.txt"]); + + const jjCore = yield* JjCore; + const status = yield* jjCore.statusDetails(repoDir); + + expect(status.hasWorkingTreeChanges).toBe(true); + expect(status.workingTree.files.length).toBeGreaterThan(0); + expect(status.workingTree.insertions).toBeGreaterThan(0); + }), + ); + }); + + describe("commit and push", () => { + it.effect("commits changes and pushes to a remote", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-jj-core-commit-"); + yield* initJjRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* addRemoteAndPush(repoDir, "origin", remoteDir); + + yield* writeTextFile(path.join(repoDir, "feature.txt"), "committed\n"); + const jjCore = yield* JjCore; + const { commitSha } = yield* jjCore.commit(repoDir, "Add feature file", ""); + + expect(commitSha.length).toBeGreaterThan(0); + + const statusAfterCommit = yield* jjCore.statusDetails(repoDir); + expect(statusAfterCommit.branch).toBe("main"); + expect(statusAfterCommit.aheadCount).toBeGreaterThan(0); + + const pushResult = yield* jjCore.pushCurrentBranch(repoDir, "main"); + expect(pushResult.status).toBe("pushed"); + + const statusAfterPush = yield* jjCore.statusDetails(repoDir); + expect(statusAfterPush.aheadCount).toBe(0); + }), + ); + }); + + describe("pullCurrentBranch", () => { + it.effect("pulls upstream changes into the local bookmark", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-jj-core-pull-"); + yield* initJjRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* addRemoteAndPush(repoDir, "origin", remoteDir); + + // Simulate upstream changes by pushing directly to the bare remote. + // This part uses git because we're operating on a separate clone + // that isn't a jj repo — it represents another developer pushing. + const tmpClone = yield* makeTempDir("t3code-jj-pull-clone-"); + yield* runGit(tmpClone, ["clone", "--branch", "main", remoteDir, "."]); + yield* runGit(tmpClone, ["config", "user.email", "test@example.com"]); + yield* runGit(tmpClone, ["config", "user.name", "Test"]); + yield* writeTextFile(path.join(tmpClone, "upstream.txt"), "upstream change\n"); + yield* runGit(tmpClone, ["add", "upstream.txt"]); + yield* runGit(tmpClone, ["commit", "-m", "upstream commit"]); + yield* runGit(tmpClone, ["push", "origin", "main"]); + + const jjCore = yield* JjCore; + const pullResult = yield* jjCore.pullCurrentBranch(repoDir); + + expect(pullResult.status).toBe("pulled"); + expect(existsSync(path.join(repoDir, "upstream.txt"))).toBe(true); + }), + ); + + it.effect("pulls even when working copy has uncommitted changes", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-jj-core-pull-dirty-"); + yield* initJjRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* addRemoteAndPush(repoDir, "origin", remoteDir); + + // Simulate upstream changes (git clone — separate developer) + const tmpClone = yield* makeTempDir("t3code-jj-pull-dirty-clone-"); + yield* runGit(tmpClone, ["clone", "--branch", "main", remoteDir, "."]); + yield* runGit(tmpClone, ["config", "user.email", "test@example.com"]); + yield* runGit(tmpClone, ["config", "user.name", "Test"]); + yield* writeTextFile(path.join(tmpClone, "upstream.txt"), "upstream\n"); + yield* runGit(tmpClone, ["add", "upstream.txt"]); + yield* runGit(tmpClone, ["commit", "-m", "upstream commit"]); + yield* runGit(tmpClone, ["push", "origin", "main"]); + + // Create local working copy changes + yield* writeTextFile(path.join(repoDir, "local-wip.txt"), "work in progress\n"); + yield* runJj(repoDir, ["file", "track", "local-wip.txt"]); + + const jjCore = yield* JjCore; + const beforePull = yield* jjCore.statusDetails(repoDir); + expect(beforePull.hasWorkingTreeChanges).toBe(true); + + // Should NOT block — JJ auto-snapshots and rebases + const pullResult = yield* jjCore.pullCurrentBranch(repoDir); + expect(pullResult.status).toBe("pulled"); + + // Both upstream and local files should be present + expect(existsSync(path.join(repoDir, "upstream.txt"))).toBe(true); + expect(existsSync(path.join(repoDir, "local-wip.txt"))).toBe(true); + }), + ); + }); + + describe("setBranchUpstream", () => { + it.effect("round-trips upstream config values correctly", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-jj-core-upstream-"); + yield* initJjRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* addRemoteAndPush(repoDir, "origin", remoteDir); + + const jjCore = yield* JjCore; + yield* jjCore.setBranchUpstream({ + cwd: repoDir, + branch: "main", + remoteName: "origin", + remoteBranch: "main", + }); + + const remoteCfg = yield* jjCore.readConfigValue(repoDir, "branch.main.remote"); + const mergeCfg = yield* jjCore.readConfigValue(repoDir, "branch.main.merge"); + + expect(remoteCfg).toBe("origin"); + expect(mergeCfg).toBe("refs/heads/main"); + + // Verify statusDetails resolves the upstream correctly + const status = yield* jjCore.statusDetails(repoDir); + expect(status.upstreamRef).toBe("origin/main"); + }), + ); + }); + + describe("createBranch", () => { + it.effect("creates a bookmark at the working copy revision", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-jj-core-createbranch-"); + yield* initJjRepo(repoDir); + yield* writeTextFile(path.join(repoDir, "new-feature.txt"), "feature\n"); + yield* runJj(repoDir, ["file", "track", "new-feature.txt"]); + + const jjCore = yield* JjCore; + yield* jjCore.createBranch({ cwd: repoDir, branch: "feat/new" }); + + // The bookmark should point at @ (the working copy with changes) + const currentChangeId = yield* runJjStdout(repoDir, [ + "log", + "-r", + "@", + "--no-graph", + "-T", + "commit_id", + ]); + const bookmarkChangeId = yield* runJjStdout(repoDir, [ + "log", + "-r", + "feat/new", + "--no-graph", + "-T", + "commit_id", + ]); + expect(bookmarkChangeId).toBe(currentChangeId); + + // Should be resolvable as current branch + const status = yield* jjCore.statusDetails(repoDir); + expect(status.branch).toBe("feat/new"); + }), + ); + }); + + describe("createWorktree", () => { + it.effect("creates distinct jj workspaces for distinct temporary thread branches", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-jj-core-workspaces-"); + yield* initJjRepo(repoDir); + + const jjCore = yield* JjCore; + const first = yield* jjCore.createWorktree({ + cwd: repoDir, + branch: "main", + newBranch: "t3code/11111111", + path: null, + }); + const second = yield* jjCore.createWorktree({ + cwd: repoDir, + branch: "main", + newBranch: "t3code/22222222", + path: null, + }); + + expect(first.worktree.path).not.toBe(second.worktree.path); + expect(existsSync(first.worktree.path)).toBe(true); + expect(existsSync(second.worktree.path)).toBe(true); + + const firstStatus = yield* jjCore.statusDetails(first.worktree.path); + const secondStatus = yield* jjCore.statusDetails(second.worktree.path); + const rootStatus = yield* jjCore.statusDetails(repoDir); + + expect(firstStatus.branch).toBe("t3code/11111111"); + expect(secondStatus.branch).toBe("t3code/22222222"); + expect(rootStatus.branch).toBe("main"); + }), + ); + }); + + describe("filterIgnoredPaths", () => { + it.effect("filters out gitignored paths", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-jj-core-ignore-"); + yield* initJjRepo(repoDir); + yield* writeTextFile(path.join(repoDir, ".gitignore"), "ignored.txt\n"); + yield* runJj(repoDir, ["file", "track", ".gitignore"]); + yield* writeTextFile(path.join(repoDir, "tracked.txt"), "tracked\n"); + yield* runJj(repoDir, ["file", "track", "tracked.txt"]); + yield* writeTextFile(path.join(repoDir, "ignored.txt"), "should be ignored\n"); + + const jjCore = yield* JjCore; + const result = yield* jjCore.filterIgnoredPaths(repoDir, [ + "tracked.txt", + "ignored.txt", + ".gitignore", + ]); + + expect(result).toContain("tracked.txt"); + expect(result).toContain(".gitignore"); + expect(result).not.toContain("ignored.txt"); + }), + ); + }); + + describe("fetchPullRequestBranch", () => { + it.effect( + "fetches a GitHub pull request head into a local bookmark without switching the root repo", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-jj-core-"); + yield* initJjRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* addRemoteAndPush(repoDir, "origin", remoteDir); + + // Create a feature branch with a commit, push it, and simulate a + // GitHub PR ref. The PR ref push uses git since jj doesn't have + // an equivalent for arbitrary refspecs. + yield* writeTextFile(path.join(repoDir, "pr-fetch.txt"), "fetch me\n"); + yield* runJj(repoDir, ["file", "track", "pr-fetch.txt"]); + yield* runJj(repoDir, ["describe", "-m", "Add PR fetch branch"]); + yield* runJj(repoDir, ["bookmark", "create", "feature/pr-fetch", "-r", "@"]); + yield* runJj(repoDir, ["new"]); + yield* runJj(repoDir, ["git", "fetch", "--remote", "origin"], true); + yield* runJj(repoDir, ["bookmark", "track", "feature/pr-fetch@origin"], true); + yield* runJj(repoDir, ["git", "push", "--remote", "origin", "-b", "feature/pr-fetch"]); + // Simulate GitHub's PR ref — jj doesn't support arbitrary refspecs. + yield* runJj(repoDir, ["git", "export"]); + yield* runJj(repoDir, [ + "util", + "exec", + "--", + "git", + "push", + "origin", + "feature/pr-fetch:refs/pull/55/head", + ]); + yield* runJj(repoDir, ["git", "import"]); + // Move back to main so fetchPullRequestBranch doesn't see us on the feature branch. + yield* runJj(repoDir, ["new", "main"]); + + yield* (yield* JjCore).fetchPullRequestBranch({ + cwd: repoDir, + prNumber: 55, + branch: "t3code/pr-55/feature-pr-fetch", + remoteName: "origin", + remoteBranch: "feature/pr-fetch", + }); + + const bookmarks = yield* listBookmarks(repoDir); + expect( + bookmarks.some( + (bookmark) => + bookmark.name === "t3code/pr-55/feature-pr-fetch" && bookmark.remote === undefined, + ), + ).toBe(true); + + const status = yield* (yield* JjCore).statusDetails(repoDir); + expect(status.branch).toBe("main"); + }), + ); + }); + + describe("remote branch mapping", () => { + it.effect( + "maps a synthetic local bookmark to a different remote branch and preserves upstream in a workspace", + () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-jj-core-"); + yield* initJjRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* addRemoteAndPush(repoDir, "origin", originDir); + // Create a feature branch, push to the fork remote, then clean up. + // Uses git export/import to set up the cross-repo scenario that + // JjCore.fetchRemoteBranch must handle. + yield* writeTextFile(path.join(repoDir, "fork.txt"), "fork\n"); + yield* runJj(repoDir, ["file", "track", "fork.txt"]); + yield* runJj(repoDir, ["describe", "-m", "Fork PR branch"]); + yield* runJj(repoDir, ["bookmark", "create", "feature/pr-fork", "-r", "@"]); + yield* runJj(repoDir, ["new"]); + yield* runJj(repoDir, ["git", "remote", "add", "fork", forkDir]).pipe(Effect.asVoid); + yield* runJj(repoDir, ["git", "export"]); + yield* runGit(repoDir, ["push", "fork", "feature/pr-fork"]); + yield* runJj(repoDir, ["bookmark", "delete", "feature/pr-fork"]); + yield* runJj(repoDir, ["git", "import"]); + + const syntheticBranch = "t3code/pr-81/feature-pr-fork"; + yield* (yield* JjCore).fetchRemoteBranch({ + cwd: repoDir, + remoteName: "fork", + remoteBranch: "feature/pr-fork", + localBranch: syntheticBranch, + }); + yield* (yield* JjCore).setBranchUpstream({ + cwd: repoDir, + branch: syntheticBranch, + remoteName: "fork", + remoteBranch: "feature/pr-fork", + }); + + const worktreePath = path.join(repoDir, "pr-worktree"); + const worktree = yield* (yield* JjCore).createWorktree({ + cwd: repoDir, + branch: syntheticBranch, + path: worktreePath, + }); + + expect(existsSync(worktree.worktree.path)).toBe(true); + const worktreeStatus = yield* (yield* JjCore).statusDetails(worktree.worktree.path); + expect(worktreeStatus.branch).toBe(syntheticBranch); + expect(worktreeStatus.upstreamRef).toBe("fork/feature/pr-fork"); + + const remoteHeadBefore = yield* runGit(forkDir, [ + "rev-parse", + "refs/heads/feature/pr-fork", + ]).pipe(Effect.map((result) => result.stdout.trim())); + + yield* writeTextFile(path.join(worktree.worktree.path, "workspace.txt"), "workspace\n"); + yield* (yield* JjCore).commit(worktree.worktree.path, "Update workspace", ""); + const pushResult = yield* (yield* JjCore).pushCurrentBranch( + worktree.worktree.path, + syntheticBranch, + ); + + const remoteHeadAfter = yield* runGit(forkDir, [ + "rev-parse", + "refs/heads/feature/pr-fork", + ]).pipe(Effect.map((result) => result.stdout.trim())); + + expect(pushResult.status).toBe("pushed"); + expect(pushResult.upstreamBranch).toBe("fork/feature/pr-fork"); + expect(remoteHeadAfter).not.toBe(remoteHeadBefore); + + const rootStatus = yield* (yield* JjCore).statusDetails(repoDir); + expect(rootStatus.branch).toBe("main"); + }), + ); + }); +}); diff --git a/apps/server/src/jj/Layers/JjCore.ts b/apps/server/src/jj/Layers/JjCore.ts new file mode 100644 index 0000000000..9c8121292a --- /dev/null +++ b/apps/server/src/jj/Layers/JjCore.ts @@ -0,0 +1,1643 @@ +import { FileSystem, Effect, Layer, Path, Ref } from "effect"; + +import { GitCommandError, type GitBranch } from "@t3tools/contracts"; +import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/vcs"; +import { ServerConfig } from "../../config.ts"; +import { + type ExecuteGitResult, + type GitCommitOptions, + type GitCoreShape, + type GitFetchPullRequestBranchInput, + type GitFetchRemoteBranchInput, + type GitPreparedCommitContext, + type GitPushResult, + type GitRangeContext, + type GitRenameBranchInput, + type GitStatusDetails, +} from "../../git/Services/GitCore.ts"; +import { JjCore, type JjCoreShape } from "../Services/JjCore.ts"; +import { + branchConfigKey, + canonicalizePath, + parseJsonLines, + parseMergeRefBranchName, + readJjRepoBackendType, + resolveJjRoot, + resolveJjRepoDir, + runJjCommand, + runJjStdout, +} from "../Utils.ts"; + +const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000; +const RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES = 19_000; +const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; +const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; +const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; +const DEFAULT_BRANCH_CANDIDATES = ["main", "master", "trunk"] as const; +const JJ_LIST_BRANCHES_DEFAULT_LIMIT = 100; +const WORKSPACE_REGISTRY_FILE = "t3-workspaces.json"; + +interface WorkspaceRegistry { + readonly branches: Record; +} + +const EMPTY_WORKSPACE_REGISTRY: WorkspaceRegistry = { branches: {} }; + +interface ParsedBookmark { + readonly name: string; + readonly remoteName: string | null; +} + +interface BranchUpstreamInfo { + readonly remoteName: string; + readonly remoteBranch: string; + readonly upstreamRef: string; +} + +interface GitRemoteEntry { + readonly name: string; + readonly url: string; +} + +function normalizeOptionalString(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function isDefaultBranchName(branch: string | null | undefined): boolean { + return ( + branch !== null && branch !== undefined && DEFAULT_BRANCH_CANDIDATES.includes(branch as never) + ); +} + +function parseDiffStatEntries( + stdout: string, +): Array<{ path: string; insertions: number; deletions: number }> { + const entries: Array<{ path: string; insertions: number; deletions: number }> = []; + for (const line of stdout.split(/\r?\n/g)) { + const trimmedLine = line.trim(); + if (trimmedLine.length === 0 || /^\d+\s+files?\s+changed,/.test(trimmedLine)) { + continue; + } + + const separatorIndex = line.lastIndexOf(" | "); + if (separatorIndex < 0) { + continue; + } + + const rawPath = line.slice(0, separatorIndex).trim(); + if (rawPath.length === 0) { + continue; + } + + const statPart = line.slice(separatorIndex + 3).trim(); + const tokens = statPart.split(/\s+/g); + const totalChanges = Number.parseInt(tokens[0] ?? "", 10); + + if (!Number.isFinite(totalChanges) || totalChanges === 0) { + entries.push({ path: rawPath, insertions: 0, deletions: 0 }); + continue; + } + + const markers = tokens[1] ?? ""; + const plusCount = (markers.match(/\+/g) ?? []).length; + const minusCount = (markers.match(/-/g) ?? []).length; + const totalMarkers = plusCount + minusCount; + + if (totalMarkers === 0) { + entries.push({ path: rawPath, insertions: totalChanges, deletions: 0 }); + continue; + } + + const insertions = Math.round(totalChanges * (plusCount / totalMarkers)); + const deletions = totalChanges - insertions; + entries.push({ path: rawPath, insertions, deletions }); + } + return entries; +} + +function splitLineSeparatedPaths(input: string): string[] { + return input + .split(/\r?\n/g) + .map((value) => value.trim()) + .filter((value) => value.length > 0); +} + +function parseGitRemoteEntries(stdout: string): GitRemoteEntry[] { + return stdout + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .flatMap((line) => { + const separatorIndex = line.search(/\s/); + if (separatorIndex < 0) { + return []; + } + + const name = line.slice(0, separatorIndex).trim(); + const url = line.slice(separatorIndex).trim(); + return name.length > 0 && url.length > 0 ? [{ name, url }] : []; + }); +} + +function normalizeRemoteUrl(value: string): string { + return value + .trim() + .replace(/\/+$/g, "") + .replace(/\.git$/i, "") + .toLowerCase(); +} + +function commandError( + operation: string, + cwd: string, + command: string, + detail: string, + cause?: unknown, +) { + return new GitCommandError({ + operation, + command, + cwd, + detail, + ...(cause !== undefined ? { cause } : {}), + }); +} + +function filterBranchesForListQuery( + branches: ReadonlyArray, + query?: string, +): ReadonlyArray { + if (!query) { + return branches; + } + + const normalizedQuery = query.toLowerCase(); + return branches.filter((branch) => branch.name.toLowerCase().includes(normalizedQuery)); +} + +function paginateBranches(input: { + branches: ReadonlyArray; + cursor?: number | undefined; + limit?: number | undefined; +}) { + const cursor = input.cursor ?? 0; + const limit = input.limit ?? JJ_LIST_BRANCHES_DEFAULT_LIMIT; + const totalCount = input.branches.length; + const branches = input.branches.slice(cursor, cursor + limit); + const nextCursor = cursor + branches.length < totalCount ? cursor + branches.length : null; + + return { + branches, + nextCursor, + totalCount, + }; +} + +function parseBookmarkEntries(stdout: string): ParsedBookmark[] { + const rows = parseJsonLines<{ + name?: string; + remote?: string; + target?: string[]; + }>(stdout); + + return rows + .map((row) => ({ + name: row.name?.trim() ?? "", + remoteName: normalizeOptionalString(row.remote), + })) + .filter((row) => row.name.length > 0); +} + +function parseCommitId(stdout: string): string | null { + const row = parseJsonLines<{ commit_id?: string }>(stdout)[0]; + const commitId = row?.commit_id?.trim() ?? ""; + return commitId.length > 0 ? commitId : null; +} + +function unique(values: ReadonlyArray): T[] { + return [...new Set(values)]; +} + +function sortCurrentBranchCandidates(candidates: ReadonlyArray): string[] { + return [...candidates].toSorted((left, right) => { + const leftDefault = isDefaultBranchName(left); + const rightDefault = isDefaultBranchName(right); + if (leftDefault !== rightDefault) { + return leftDefault ? 1 : -1; + } + return left.localeCompare(right); + }); +} + +function normalizeWorkspaceRegistry(value: unknown): WorkspaceRegistry { + if (!value || typeof value !== "object" || !("branches" in value)) { + return EMPTY_WORKSPACE_REGISTRY; + } + + const branchesValue = (value as { branches?: unknown }).branches; + if (!branchesValue || typeof branchesValue !== "object") { + return EMPTY_WORKSPACE_REGISTRY; + } + + const branches: Record = Object.fromEntries( + Object.entries(branchesValue).flatMap(([branchName, worktreePath]) => + typeof worktreePath === "string" ? [[branchName, worktreePath]] : [], + ), + ); + return { branches }; +} + +function isBranchAssignedToDifferentWorkspace(input: { + readonly branch: string; + readonly workspaceRoot: string; + readonly registry: WorkspaceRegistry; +}): boolean { + const assignedWorkspacePath = input.registry.branches[input.branch]; + return assignedWorkspacePath + ? canonicalizePath(assignedWorkspacePath) !== input.workspaceRoot + : false; +} + +function resolveRemoteNameForBranch(input: { + readonly branch: string; + readonly remoteBookmarks: ReadonlyArray; +}): string | null { + const remoteMatches = input.remoteBookmarks.filter((entry) => entry.name === input.branch); + if (remoteMatches.length === 0) { + return null; + } + + return ( + remoteMatches.find((entry) => entry.remoteName === "origin")?.remoteName ?? + remoteMatches[0]?.remoteName ?? + null + ); +} + +export const makeJjCore = Effect.fn("makeJjCore")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const pendingCommitFilePaths = yield* Ref.make(new Map()); + const { worktreesDir } = yield* ServerConfig; + + const workspaceRegistryPath = (repoDir: string) => path.join(repoDir, WORKSPACE_REGISTRY_FILE); + + const readWorkspaceRegistry = (repoDir: string): Effect.Effect => + fileSystem.readFileString(workspaceRegistryPath(repoDir)).pipe( + Effect.map((raw) => JSON.parse(raw) as unknown), + Effect.catch(() => Effect.succeed(EMPTY_WORKSPACE_REGISTRY)), + Effect.map(normalizeWorkspaceRegistry), + ); + + const writeWorkspaceRegistry = ( + repoDir: string, + registry: WorkspaceRegistry, + ): Effect.Effect => + fileSystem + .writeFileString(workspaceRegistryPath(repoDir), JSON.stringify(registry, null, 2)) + .pipe( + Effect.mapError((cause) => + commandError( + "JjCore.writeWorkspaceRegistry", + repoDir, + "write workspace registry", + cause instanceof Error ? cause.message : String(cause), + cause, + ), + ), + ); + + const updateWorkspaceRegistry = ( + repoDir: string, + update: (registry: WorkspaceRegistry) => WorkspaceRegistry, + ) => + readWorkspaceRegistry(repoDir).pipe( + Effect.flatMap((registry) => writeWorkspaceRegistry(repoDir, update(registry))), + ); + + const recordWorkspaceBranch = (repoDir: string, workspacePath: string, branch: string) => + updateWorkspaceRegistry(repoDir, (registry) => { + const canonicalWorkspacePath = canonicalizePath(workspacePath); + const branches: Record = {}; + for (const [branchName, recordedPath] of Object.entries(registry.branches)) { + if (branchName === branch || canonicalizePath(recordedPath) === canonicalWorkspacePath) { + continue; + } + branches[branchName] = recordedPath; + } + branches[branch] = canonicalWorkspacePath; + return { branches }; + }); + + const readCurrentCommitId = (cwd: string, revset = "@") => + runJjStdout("JjCore.readCurrentCommitId", cwd, [ + "log", + "-r", + revset, + "--no-graph", + "-T", + 'json(self) ++ "\\n"', + ]).pipe(Effect.map(parseCommitId)); + + const resolveTrackTargetPaths = (cwd: string, filePaths?: readonly string[]) => { + if (!filePaths || filePaths.length === 0) { + return Effect.succeed(["."] as string[]); + } + + return Effect.forEach( + filePaths, + (filePath) => + fileSystem.stat(path.join(cwd, filePath)).pipe( + Effect.map(() => filePath), + Effect.catch(() => Effect.succeed(null)), + ), + { concurrency: "unbounded" }, + ).pipe( + Effect.map((paths) => paths.filter((filePath): filePath is string => filePath !== null)), + ); + }; + + const trackPaths = (cwd: string, filePaths?: readonly string[]) => + resolveTrackTargetPaths(cwd, filePaths).pipe( + Effect.flatMap((trackablePaths) => { + if (trackablePaths.length === 0) { + return Effect.void; + } + + return runJjCommand({ + operation: "JjCore.trackPaths", + cwd, + args: ["file", "track", ...trackablePaths], + allowNonZeroExit: true, + }).pipe( + Effect.flatMap((result) => { + if (result.code === 0) { + return Effect.void; + } + + const detail = result.stderr.trim(); + if (detail.includes("No arguments")) { + return Effect.void; + } + + return Effect.fail( + commandError( + "JjCore.trackPaths", + cwd, + "jj file track", + detail.length > 0 ? detail : "jj file track failed", + ), + ); + }), + ); + }), + ); + + const listGitRemoteEntries = (cwd: string): Effect.Effect => + readJjRepoBackendType(cwd).pipe( + Effect.flatMap((backendType) => { + if (backendType !== "git") { + return Effect.succeed([]); + } + + return runJjStdout("JjCore.listGitRemoteEntries", cwd, ["git", "remote", "list"]).pipe( + Effect.map(parseGitRemoteEntries), + Effect.catch(() => Effect.succeed([])), + ); + }), + Effect.catch(() => Effect.succeed([])), + ); + + const listGitRemoteNames = (cwd: string) => + listGitRemoteEntries(cwd).pipe(Effect.map((entries) => entries.map((entry) => entry.name))); + + const resolvePrimaryRemoteName = (cwd: string): Effect.Effect => + listGitRemoteNames(cwd).pipe( + Effect.flatMap((remoteNames) => { + const remoteName = remoteNames.includes("origin") ? "origin" : (remoteNames[0] ?? null); + return remoteName + ? Effect.succeed(remoteName) + : Effect.fail( + commandError( + "JjCore.resolvePrimaryRemoteName", + cwd, + "git remote", + "Cannot resolve a Git remote for this repository.", + ), + ); + }), + ); + + const resolveCurrentBookmarkCandidates = (cwd: string) => + runJjStdout("JjCore.resolveCurrentBookmarkCandidates", cwd, [ + "log", + "-r", + "@", + "--no-graph", + "-T", + 'json(self.bookmarks()) ++ "\\n"', + ]).pipe( + Effect.map((stdout) => + unique( + parseJsonLines>(stdout) + .flatMap((rows) => rows) + .filter((row) => normalizeOptionalString(row.remote) === null) + .map((row) => row.name?.trim() ?? "") + .filter((name) => name.length > 0), + ), + ), + ); + + const resolveNearestBookmarkCandidates = (cwd: string) => + runJjStdout("JjCore.resolveNearestBookmarkCandidates", cwd, [ + "log", + "-r", + "heads(::@- & bookmarks())", + "--no-graph", + "-T", + 'json(self.bookmarks()) ++ "\\n"', + ]).pipe( + Effect.map((stdout) => + unique( + parseJsonLines>(stdout) + .flatMap((rows) => rows) + .filter((row) => normalizeOptionalString(row.remote) === null) + .map((row) => row.name?.trim() ?? "") + .filter((name) => name.length > 0), + ), + ), + ); + + const resolveBookmarkState = (cwd: string) => + runJjStdout("JjCore.resolveBookmarkState", cwd, [ + "bookmark", + "list", + "--all-remotes", + "-T", + 'json(self) ++ "\\n"', + ]).pipe( + Effect.map((stdout) => parseBookmarkEntries(stdout)), + Effect.map((entries) => { + const localBookmarks = entries.filter((entry) => entry.remoteName === null); + const remoteBookmarks = entries.filter( + (entry) => entry.remoteName !== null && entry.remoteName !== "git", + ); + return { + localBookmarks, + remoteBookmarks, + }; + }), + ); + + const resolveDefaultBranch = ( + localBranchNames: ReadonlyArray, + remoteBookmarks: ReadonlyArray, + ): string | null => { + for (const candidate of DEFAULT_BRANCH_CANDIDATES) { + if ( + localBranchNames.includes(candidate) || + remoteBookmarks.some( + (bookmark) => bookmark.remoteName === "origin" && bookmark.name === candidate, + ) + ) { + return candidate; + } + } + + return localBranchNames[0] ?? null; + }; + + const resolveCurrentBranch = Effect.fn("JjCore.resolveCurrentBranch")(function* (cwd: string) { + const [bookmarkState, currentCandidates, nearestCandidates, workspaceRoot, registry] = + yield* Effect.all( + [ + resolveBookmarkState(cwd), + resolveCurrentBookmarkCandidates(cwd).pipe(Effect.catch(() => Effect.succeed([]))), + resolveNearestBookmarkCandidates(cwd).pipe(Effect.catch(() => Effect.succeed([]))), + resolveJjRoot(cwd).pipe(Effect.map(canonicalizePath)), + resolveJjRepoDir(cwd).pipe( + Effect.flatMap((root) => readWorkspaceRegistry(root)), + Effect.catch(() => Effect.succeed(EMPTY_WORKSPACE_REGISTRY)), + ), + ], + { concurrency: "unbounded" }, + ); + + const localBranchNames = new Set(bookmarkState.localBookmarks.map((bookmark) => bookmark.name)); + const registryBranch = Object.entries(registry.branches).find( + ([, worktreePath]) => canonicalizePath(worktreePath) === workspaceRoot, + )?.[0]; + if (registryBranch && localBranchNames.has(registryBranch)) { + return registryBranch; + } + + const candidateIsAvailableInWorkspace = (name: string) => + localBranchNames.has(name) && + !isBranchAssignedToDifferentWorkspace({ + branch: name, + workspaceRoot, + registry, + }); + + const directCurrentCandidates = currentCandidates.filter(candidateIsAvailableInWorkspace); + if (directCurrentCandidates.length > 0) { + return sortCurrentBranchCandidates(directCurrentCandidates)[0] ?? null; + } + + const candidates = nearestCandidates.filter(candidateIsAvailableInWorkspace); + if (candidates.length === 0) { + return null; + } + + return sortCurrentBranchCandidates(candidates)[0] ?? null; + }); + + const readConfiguredUpstreamInfo = Effect.fn("JjCore.readConfiguredUpstreamInfo")(function* ( + cwd: string, + branch: string, + ) { + const [remoteName, mergeRef] = yield* Effect.all( + [ + readConfigValue(cwd, branchConfigKey(branch, "remote")).pipe( + Effect.catch(() => Effect.succeed(null)), + ), + readConfigValue(cwd, branchConfigKey(branch, "merge")).pipe( + Effect.catch(() => Effect.succeed(null)), + ), + ], + { concurrency: "unbounded" }, + ); + + const remoteBranch = parseMergeRefBranchName(mergeRef); + if (!remoteName || !remoteBranch) { + return null; + } + + return { + remoteName, + remoteBranch, + upstreamRef: `${remoteName}/${remoteBranch}`, + } satisfies BranchUpstreamInfo; + }); + + const resolveBranchUpstreamInfo = Effect.fn("JjCore.resolveBranchUpstreamInfo")(function* ( + cwd: string, + branch: string, + remoteBookmarks: ReadonlyArray, + ) { + const configured = yield* readConfiguredUpstreamInfo(cwd, branch); + if (configured) { + return configured; + } + + const remoteName = resolveRemoteNameForBranch({ + branch, + remoteBookmarks, + }); + if (!remoteName) { + return null; + } + + return { + remoteName, + remoteBranch: branch, + upstreamRef: `${remoteName}/${branch}`, + } satisfies BranchUpstreamInfo; + }); + + const ensureLocalBookmark = Effect.fn("JjCore.ensureLocalBookmark")(function* ( + cwd: string, + branch: string, + revision: string, + ) { + const localBranchNames = yield* resolveBookmarkState(cwd).pipe( + Effect.map((state) => state.localBookmarks.map((bookmark) => bookmark.name)), + ); + + if (localBranchNames.includes(branch)) { + yield* runJjCommand({ + operation: "JjCore.ensureLocalBookmark.move", + cwd, + args: ["bookmark", "move", branch, "--to", revision], + }); + return; + } + + yield* runJjCommand({ + operation: "JjCore.ensureLocalBookmark.create", + cwd, + args: ["bookmark", "create", branch, "--revision", revision], + }); + }); + const countRevset = (cwd: string, revset: string): Effect.Effect => + runJjStdout("JjCore.countRevset", cwd, ["log", "-r", revset, "--count"]).pipe( + Effect.map((stdout) => { + const count = Number.parseInt(stdout.trim(), 10); + return Number.isFinite(count) ? Math.max(0, count) : 0; + }), + Effect.catch(() => Effect.succeed(0)), + ); + + const readDiffStat = (cwd: string, fromCommit: string, toCommit: string) => + runJjCommand({ + operation: "JjCore.readDiffStat", + cwd, + args: ["diff", "--from", fromCommit, "--to", toCommit, "--stat"], + allowNonZeroExit: true, + }).pipe(Effect.map((result) => result.stdout)); + + const statusDetails: JjCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) { + const jjRoot = yield* resolveJjRoot(cwd).pipe(Effect.catch(() => Effect.succeed(null))); + if (!jjRoot) { + return { + isRepo: false, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + upstreamRef: null, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + } satisfies GitStatusDetails; + } + + const [bookmarkState, branch, localCommit, currentCommit, gitRemoteNames] = yield* Effect.all( + [ + resolveBookmarkState(cwd), + resolveCurrentBranch(cwd), + readCurrentCommitId(cwd, "@-"), + readCurrentCommitId(cwd, "@"), + listGitRemoteNames(cwd), + ], + { concurrency: "unbounded" }, + ); + + const localBranchNames = bookmarkState.localBookmarks.map((bookmark) => bookmark.name); + const defaultBranch = resolveDefaultBranch(localBranchNames, bookmarkState.remoteBookmarks); + const upstreamInfo = branch + ? yield* resolveBranchUpstreamInfo(cwd, branch, bookmarkState.remoteBookmarks) + : null; + const upstreamRef = upstreamInfo?.upstreamRef ?? null; + const hasOriginRemote = + gitRemoteNames.includes("origin") || + bookmarkState.remoteBookmarks.some((bookmark) => bookmark.remoteName === "origin"); + + if (!localCommit || !currentCommit) { + return { + isRepo: true, + hasOriginRemote, + isDefaultBranch: branch !== null && branch === defaultBranch, + branch, + upstreamRef, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: upstreamRef !== null, + aheadCount: 0, + behindCount: 0, + } satisfies GitStatusDetails; + } + + const diffStatStdout = yield* readDiffStat(cwd, localCommit, currentCommit); + const entries = parseDiffStatEntries(diffStatStdout); + const workingTreeFiles = entries + .map((entry) => ({ + path: entry.path, + insertions: entry.insertions, + deletions: entry.deletions, + })) + .toSorted((left, right) => left.path.localeCompare(right.path)); + + const insertions = workingTreeFiles.reduce((sum, file) => sum + file.insertions, 0); + const deletions = workingTreeFiles.reduce((sum, file) => sum + file.deletions, 0); + const hasWorkingTreeChanges = workingTreeFiles.length > 0; + + let aheadCount = 0; + let behindCount = 0; + if (branch && upstreamInfo) { + aheadCount = yield* countRevset( + cwd, + `${upstreamInfo.remoteBranch}@${upstreamInfo.remoteName}..${branch}`, + ); + behindCount = yield* countRevset( + cwd, + `${branch}..${upstreamInfo.remoteBranch}@${upstreamInfo.remoteName}`, + ); + } else if (branch && defaultBranch && branch !== defaultBranch) { + aheadCount = yield* countRevset(cwd, `${defaultBranch}..${branch}`); + } + + return { + isRepo: true, + hasOriginRemote, + isDefaultBranch: branch !== null && branch === defaultBranch, + branch, + upstreamRef, + hasWorkingTreeChanges, + workingTree: { + files: workingTreeFiles, + insertions, + deletions, + }, + hasUpstream: upstreamRef !== null, + aheadCount, + behindCount, + } satisfies GitStatusDetails; + }); + + const execute: JjCoreShape["execute"] = Effect.fn("execute")(function* (input) { + const currentCommit = () => + readCurrentCommitId(input.cwd).pipe( + Effect.flatMap((commitId) => + commitId + ? Effect.succeed(commitId) + : Effect.fail( + commandError( + input.operation, + input.cwd, + `jj ${input.args.join(" ")}`, + "Unable to resolve the current working-copy commit.", + ), + ), + ), + ); + + const args = [...input.args]; + const toResult = (result: { + readonly code?: number; + readonly stdout?: string; + readonly stderr?: string; + readonly stdoutTruncated?: boolean; + readonly stderrTruncated?: boolean; + }) => + ({ + code: result.code ?? 0, + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + stdoutTruncated: result.stdoutTruncated ?? false, + stderrTruncated: result.stderrTruncated ?? false, + }) satisfies ExecuteGitResult; + + if (args[0] === "rev-parse" && (args.includes("HEAD") || args.includes("HEAD^{commit}"))) { + const commitId = yield* currentCommit(); + return toResult({ stdout: `${commitId}\n` }); + } + + if (args[0] === "rev-parse" && args.includes("--is-inside-work-tree")) { + return toResult({ stdout: "true\n" }); + } + + if (args[0] === "diff") { + const revisions = args.filter((arg) => !arg.startsWith("-")).slice(1); + const [fromCommit, toCommit] = revisions.slice(-2); + if (!fromCommit || !toCommit) { + return yield* commandError( + input.operation, + input.cwd, + `git ${args.join(" ")}`, + "Unsupported diff invocation for jj repositories.", + ); + } + + const result = yield* runJjCommand({ + operation: input.operation, + cwd: input.cwd, + args: ["diff", "--from", fromCommit, "--to", toCommit, "--git"], + ...(input.allowNonZeroExit !== undefined + ? { allowNonZeroExit: input.allowNonZeroExit } + : {}), + ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), + ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}), + ...(input.truncateOutputAtMaxBytes !== undefined + ? { truncateOutputAtMaxBytes: input.truncateOutputAtMaxBytes } + : {}), + ...(input.env !== undefined ? { env: input.env } : {}), + }); + return toResult(result); + } + + if (args[0] === "restore" && args.includes("--source")) { + const sourceIndex = args.indexOf("--source"); + const source = args[sourceIndex + 1] ?? ""; + yield* trackPaths(input.cwd); + const result = yield* runJjCommand({ + operation: input.operation, + cwd: input.cwd, + args: ["restore", "--from", source === "HEAD" ? yield* currentCommit() : source], + ...(input.allowNonZeroExit !== undefined + ? { allowNonZeroExit: input.allowNonZeroExit } + : {}), + ...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}), + ...(input.maxOutputBytes !== undefined ? { maxOutputBytes: input.maxOutputBytes } : {}), + ...(input.truncateOutputAtMaxBytes !== undefined + ? { truncateOutputAtMaxBytes: input.truncateOutputAtMaxBytes } + : {}), + ...(input.env !== undefined ? { env: input.env } : {}), + }); + return toResult(result); + } + + return yield* commandError( + input.operation, + input.cwd, + `git ${args.join(" ")}`, + "Unsupported raw git command for jj repositories.", + ); + }); + + const status: JjCoreShape["status"] = (input) => + statusDetails(input.cwd).pipe( + Effect.map((details) => ({ + isRepo: details.isRepo, + hasOriginRemote: details.hasOriginRemote, + isDefaultBranch: details.isDefaultBranch, + branch: details.branch, + hasWorkingTreeChanges: details.hasWorkingTreeChanges, + workingTree: details.workingTree, + hasUpstream: details.hasUpstream, + aheadCount: details.aheadCount, + behindCount: details.behindCount, + pr: null, + })), + ); + + const prepareCommitContext: JjCoreShape["prepareCommitContext"] = Effect.fn( + "prepareCommitContext", + )(function* (cwd, filePaths) { + yield* trackPaths(cwd, filePaths); + + yield* Ref.update(pendingCommitFilePaths, (state) => { + const next = new Map(state); + if (filePaths && filePaths.length > 0) { + next.set(cwd, [...filePaths]); + } else { + next.delete(cwd); + } + return next; + }); + + const diffArgs = ["diff", "--summary", ...(filePaths ? [...filePaths] : [])]; + const stagedSummary = yield* runJjStdout( + "JjCore.prepareCommitContext.summary", + cwd, + diffArgs, + ).pipe(Effect.map((stdout) => stdout.trim())); + if (stagedSummary.length === 0) { + return null; + } + + const stagedPatchResult = yield* runJjCommand({ + operation: "JjCore.prepareCommitContext.patch", + cwd, + args: ["diff", "--git", ...(filePaths ? [...filePaths] : [])], + maxOutputBytes: PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }); + + return { + stagedSummary, + stagedPatch: stagedPatchResult.stdout, + } satisfies GitPreparedCommitContext; + }); + + const commit: JjCoreShape["commit"] = Effect.fn("commit")(function* ( + cwd, + subject, + body, + _options?: GitCommitOptions, + ) { + const branchBeforeCommit = yield* resolveCurrentBranch(cwd); + const pendingFilePaths = yield* Ref.modify(pendingCommitFilePaths, (state) => { + const next = new Map(state); + const filePaths = next.get(cwd); + next.delete(cwd); + return [filePaths, next] as const; + }); + + yield* trackPaths(cwd, pendingFilePaths); + + const description = body.trim().length > 0 ? `${subject}\n\n${body.trim()}` : subject; + yield* runJjCommand({ + operation: "JjCore.commit", + cwd, + args: ["commit", "-m", description, ...(pendingFilePaths ?? [])], + timeoutMs: 10 * 60_000, + }); + + if (branchBeforeCommit) { + yield* runJjCommand({ + operation: "JjCore.commit.moveBookmark", + cwd, + args: ["bookmark", "move", branchBeforeCommit, "--to", "@-"], + }).pipe(Effect.catch(() => Effect.void)); + } + + const commitSha = + (yield* readCurrentCommitId(cwd, "@-").pipe(Effect.catch(() => Effect.succeed(null)))) ?? ""; + + return { commitSha }; + }); + + const pushCurrentBranch: JjCoreShape["pushCurrentBranch"] = Effect.fn("pushCurrentBranch")( + function* (cwd, fallbackBranch) { + const details = yield* statusDetails(cwd); + const branch = details.branch ?? fallbackBranch; + if (!branch) { + return yield* commandError( + "JjCore.pushCurrentBranch", + cwd, + "jj git push", + "Cannot push without an active bookmark.", + ); + } + + const { remoteBookmarks } = yield* resolveBookmarkState(cwd); + const upstreamInfo = yield* resolveBranchUpstreamInfo(cwd, branch, remoteBookmarks); + const remoteName = + upstreamInfo?.remoteName ?? + (yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null)))); + const remoteBranch = upstreamInfo?.remoteBranch ?? branch; + + if (!remoteName) { + return yield* commandError( + "JjCore.pushCurrentBranch", + cwd, + "jj git push", + "Cannot push because no Git remote is configured for this repository.", + ); + } + + const hasRemoteBookmark = remoteBookmarks.some( + (bookmark) => bookmark.name === remoteBranch && bookmark.remoteName === remoteName, + ); + if (hasRemoteBookmark && details.aheadCount === 0 && details.behindCount === 0) { + return { + status: "skipped_up_to_date" as const, + branch, + upstreamBranch: `${remoteName}/${remoteBranch}`, + } satisfies GitPushResult; + } + + if (remoteBranch !== branch) { + yield* ensureLocalBookmark(cwd, remoteBranch, branch); + } + + yield* runJjCommand({ + operation: "JjCore.pushCurrentBranch", + cwd, + args: [ + "git", + "push", + "--remote", + remoteName, + "--bookmark", + remoteBranch === branch ? branch : remoteBranch, + ], + timeoutMs: 30_000, + }); + + return { + status: "pushed" as const, + branch, + upstreamBranch: `${remoteName}/${remoteBranch}`, + setUpstream: upstreamInfo === null || !hasRemoteBookmark, + } satisfies GitPushResult; + }, + ); + + const pullCurrentBranch: JjCoreShape["pullCurrentBranch"] = Effect.fn("pullCurrentBranch")( + function* (cwd) { + const details = yield* statusDetails(cwd); + const branch = details.branch; + if (!branch) { + return yield* commandError( + "JjCore.pullCurrentBranch", + cwd, + "jj git fetch", + "Cannot pull without an active bookmark.", + ); + } + + const { remoteBookmarks } = yield* resolveBookmarkState(cwd); + const upstreamInfo = yield* resolveBranchUpstreamInfo(cwd, branch, remoteBookmarks); + if (!upstreamInfo) { + return yield* commandError( + "JjCore.pullCurrentBranch", + cwd, + "jj git fetch", + "Current bookmark has no tracked remote. Push it first.", + ); + } + + // Snapshot the bookmark position before fetch so we can detect movement. + // JJ auto-advances tracked local bookmarks during fetch, so the + // before/after comparison is the source of truth — not behind-count. + const bookmarkCommitBefore = yield* readCurrentCommitId(cwd, branch).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + + yield* runJjCommand({ + operation: "JjCore.pullCurrentBranch.fetch", + cwd, + args: [ + "git", + "fetch", + "--remote", + upstreamInfo.remoteName, + "--branch", + upstreamInfo.remoteBranch, + ], + timeoutMs: 30_000, + }); + + const bookmarkCommitAfter = yield* readCurrentCommitId(cwd, branch).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + + if (bookmarkCommitBefore === bookmarkCommitAfter) { + return { + status: "skipped_up_to_date" as const, + branch, + upstreamBranch: upstreamInfo.upstreamRef, + }; + } + + // Rebase the working copy on top of the updated branch to preserve any + // in-progress changes. JJ auto-snapshots the working copy, so there is + // no "dirty working tree" concept that should block a pull. + yield* runJjCommand({ + operation: "JjCore.pullCurrentBranch.rebaseWorkingCopy", + cwd, + args: ["rebase", "-r", "@", "-d", branch], + }).pipe(Effect.catch(() => Effect.void)); + + return { + status: "pulled" as const, + branch, + upstreamBranch: upstreamInfo.upstreamRef, + }; + }, + ); + + const readRangeContext: JjCoreShape["readRangeContext"] = Effect.fn("readRangeContext")( + function* (cwd, baseBranch) { + const currentBranch = yield* resolveCurrentBranch(cwd); + const targetRev = currentBranch ?? "@-"; + const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( + [ + runJjCommand({ + operation: "JjCore.readRangeContext.log", + cwd, + args: [ + "log", + "-r", + `${baseBranch}..${targetRev}`, + "--no-graph", + "-T", + 'description.first_line() ++ "\\n"', + ], + maxOutputBytes: RANGE_COMMIT_SUMMARY_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }).pipe(Effect.map((result) => result.stdout)), + runJjCommand({ + operation: "JjCore.readRangeContext.diffStat", + cwd, + args: ["diff", "--from", baseBranch, "--to", targetRev, "--stat"], + maxOutputBytes: RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }).pipe(Effect.map((result) => result.stdout)), + runJjCommand({ + operation: "JjCore.readRangeContext.diffPatch", + cwd, + args: ["diff", "--from", baseBranch, "--to", targetRev, "--git"], + maxOutputBytes: RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }).pipe(Effect.map((result) => result.stdout)), + ], + { concurrency: "unbounded" }, + ); + + return { + commitSummary, + diffSummary, + diffPatch, + } satisfies GitRangeContext; + }, + ); + + const readConfigValue: JjCoreShape["readConfigValue"] = (cwd, key) => + Effect.gen(function* () { + const remoteUrlMatch = /^remote\.([^.]+)\.url$/.exec(key); + if (remoteUrlMatch) { + const remoteName = remoteUrlMatch[1] ?? ""; + const remoteEntries = yield* listGitRemoteEntries(cwd); + return remoteEntries.find((entry) => entry.name === remoteName)?.url ?? null; + } + + const result = yield* runJjCommand({ + operation: "JjCore.readConfigValue", + cwd, + args: ["config", "get", key], + allowNonZeroExit: true, + timeoutMs: 5_000, + }); + if (result.code !== 0) { + return null; + } + + const value = result.stdout.trim(); + return value.length > 0 ? value : null; + }); + + const isInsideWorkTree: JjCoreShape["isInsideWorkTree"] = (cwd) => + runJjCommand({ + operation: "JjCore.isInsideWorkTree", + cwd, + args: ["root"], + allowNonZeroExit: true, + timeoutMs: 5_000, + }).pipe(Effect.map((result) => result.code === 0)); + + const listWorkspaceFiles: JjCoreShape["listWorkspaceFiles"] = (cwd) => + runJjCommand({ + operation: "JjCore.listWorkspaceFiles", + cwd, + args: ["file", "list", "-r", "@", "-T", 'path ++ "\\n"'], + allowNonZeroExit: true, + timeoutMs: 20_000, + maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, + truncateOutputAtMaxBytes: true, + }).pipe( + Effect.map((result) => ({ + paths: splitLineSeparatedPaths(result.stdout).filter( + (value) => value !== ".jj" && !value.startsWith(".jj/"), + ), + truncated: result.stdoutTruncated, + })), + ); + + const filterIgnoredPaths: JjCoreShape["filterIgnoredPaths"] = (cwd, relativePaths) => { + if (relativePaths.length === 0) { + return Effect.succeed(relativePaths); + } + + return listWorkspaceFiles(cwd).pipe( + Effect.map((result) => { + const trackedPaths = new Set(result.paths); + return relativePaths.filter((p) => trackedPaths.has(p)); + }), + Effect.catch(() => Effect.succeed(relativePaths)), + ); + }; + + const listBranches: JjCoreShape["listBranches"] = Effect.fn("listBranches")(function* (input) { + const jjRoot = yield* resolveJjRoot(input.cwd).pipe(Effect.catch(() => Effect.succeed(null))); + if (!jjRoot) { + return { + branches: [], + isRepo: false, + hasOriginRemote: false, + nextCursor: null, + totalCount: 0, + }; + } + + const [bookmarkState, currentStatus, workspaceRoot, registry] = yield* Effect.all( + [ + resolveBookmarkState(input.cwd), + statusDetails(input.cwd), + resolveJjRoot(input.cwd).pipe(Effect.map(canonicalizePath)), + resolveJjRepoDir(input.cwd).pipe( + Effect.flatMap((root) => readWorkspaceRegistry(root)), + Effect.catch(() => Effect.succeed(EMPTY_WORKSPACE_REGISTRY)), + ), + ], + { concurrency: "unbounded" }, + ); + + const defaultBranch = resolveDefaultBranch( + bookmarkState.localBookmarks.map((bookmark) => bookmark.name), + bookmarkState.remoteBookmarks, + ); + const localBranches: GitBranch[] = bookmarkState.localBookmarks + .map( + (bookmark) => + ({ + name: bookmark.name, + current: bookmark.name === currentStatus.branch, + isRemote: false, + isDefault: bookmark.name === defaultBranch, + worktreePath: registry.branches[bookmark.name] ?? null, + }) satisfies GitBranch, + ) + .toSorted((left, right) => { + const leftPriority = left.current ? 0 : left.isDefault ? 1 : 2; + const rightPriority = right.current ? 0 : right.isDefault ? 1 : 2; + if (leftPriority !== rightPriority) return leftPriority - rightPriority; + return left.name.localeCompare(right.name); + }); + + const remoteBranches: GitBranch[] = bookmarkState.remoteBookmarks + .map( + (bookmark) => + ({ + name: `${bookmark.remoteName}/${bookmark.name}`, + current: false, + isRemote: true, + remoteName: bookmark.remoteName ?? undefined, + isDefault: bookmark.name === defaultBranch, + worktreePath: null, + }) satisfies GitBranch, + ) + .toSorted((left, right) => left.name.localeCompare(right.name)); + + const registryCurrentPath = currentStatus.branch + ? (registry.branches[currentStatus.branch] ?? null) + : null; + const adjustedLocalBranches: GitBranch[] = localBranches.map((branch) => { + const worktreePath = + branch.name === currentStatus.branch + ? workspaceRoot + : (branch.worktreePath ?? + (branch.name === currentStatus.branch ? registryCurrentPath : null)); + + return { + name: branch.name, + current: branch.current, + isRemote: branch.isRemote, + isDefault: branch.isDefault, + worktreePath, + } satisfies GitBranch; + }); + + const paginated = paginateBranches({ + branches: filterBranchesForListQuery( + dedupeRemoteBranchesWithLocalMatches([...adjustedLocalBranches, ...remoteBranches]), + input.query, + ), + cursor: input.cursor, + limit: input.limit, + }); + + return { + branches: [...paginated.branches], + isRepo: true, + hasOriginRemote: currentStatus.hasOriginRemote, + nextCursor: paginated.nextCursor, + totalCount: paginated.totalCount, + }; + }); + + const createWorktree: JjCoreShape["createWorktree"] = Effect.fn("createWorktree")( + function* (input) { + const targetBranch = input.newBranch ?? input.branch; + if (input.newBranch) { + yield* runJjCommand({ + operation: "JjCore.createWorktree.createBookmark", + cwd: input.cwd, + args: ["bookmark", "create", targetBranch, "--revision", input.branch], + }); + } + + const [workspaceRoot, repoDir] = yield* Effect.all( + [resolveJjRoot(input.cwd), resolveJjRepoDir(input.cwd)], + { concurrency: "unbounded" }, + ); + const repoName = path.basename(workspaceRoot); + const sanitizedBranch = targetBranch.replace(/\//g, "-"); + const workspacePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); + const workspaceName = sanitizedBranch; + + yield* fileSystem + .makeDirectory(path.dirname(workspacePath), { recursive: true }) + .pipe( + Effect.mapError((cause) => + commandError( + "JjCore.createWorktree.prepareDirectory", + input.cwd, + "mkdir", + cause instanceof Error ? cause.message : String(cause), + cause, + ), + ), + ); + + yield* runJjCommand({ + operation: "JjCore.createWorktree.workspaceAdd", + cwd: input.cwd, + args: [ + "workspace", + "add", + "--name", + workspaceName, + "--revision", + targetBranch, + workspacePath, + ], + }); + + yield* recordWorkspaceBranch(repoDir, workspacePath, targetBranch); + + return { + worktree: { + path: workspacePath, + branch: targetBranch, + }, + }; + }, + ); + + const fetchPullRequestBranch: JjCoreShape["fetchPullRequestBranch"] = Effect.fn( + "fetchPullRequestBranch", + )(function* (input: GitFetchPullRequestBranchInput) { + const remoteBranch = input.remoteBranch?.trim() ?? ""; + if (remoteBranch.length === 0) { + return yield* commandError( + "JjCore.fetchPullRequestBranch", + input.cwd, + "jj git fetch", + "JJ-native PR fetch requires the pull request head branch name.", + ); + } + + const remoteName = + input.remoteName?.trim() || + (yield* resolvePrimaryRemoteName(input.cwd).pipe(Effect.catch(() => Effect.succeed(null)))); + if (!remoteName) { + return yield* commandError( + "JjCore.fetchPullRequestBranch", + input.cwd, + "jj git fetch", + "Cannot resolve a Git remote for this pull request.", + ); + } + + yield* runJjCommand({ + operation: "JjCore.fetchPullRequestBranch.fetch", + cwd: input.cwd, + args: ["git", "fetch", "--remote", remoteName, "--branch", remoteBranch], + timeoutMs: 30_000, + }); + + yield* ensureLocalBookmark(input.cwd, input.branch, `${remoteBranch}@${remoteName}`); + }); + + const ensureRemote: JjCoreShape["ensureRemote"] = Effect.fn("ensureRemote")(function* (input) { + const remoteEntries = yield* listGitRemoteEntries(input.cwd); + const remoteNames = new Set(remoteEntries.map((entry) => entry.name)); + + const preferredRemoteName = + input.preferredName.trim().length > 0 ? input.preferredName.trim() : "fork"; + const normalizedUrl = normalizeRemoteUrl(input.url); + for (const remoteEntry of remoteEntries) { + if (normalizeRemoteUrl(remoteEntry.url) === normalizedUrl) { + return remoteEntry.name; + } + } + + let resolvedName = preferredRemoteName; + let suffix = 2; + while (remoteNames.has(resolvedName)) { + resolvedName = `${preferredRemoteName}-${suffix}`; + suffix += 1; + } + + yield* runJjCommand({ + operation: "JjCore.ensureRemote.remoteAdd", + cwd: input.cwd, + args: ["git", "remote", "add", resolvedName, input.url], + }).pipe(Effect.asVoid); + + return resolvedName; + }); + + const fetchRemoteBranch: JjCoreShape["fetchRemoteBranch"] = Effect.fn("fetchRemoteBranch")( + function* (input: GitFetchRemoteBranchInput) { + yield* runJjCommand({ + operation: "JjCore.fetchRemoteBranch.fetch", + cwd: input.cwd, + args: ["git", "fetch", "--remote", input.remoteName, "--branch", input.remoteBranch], + timeoutMs: 30_000, + }); + + yield* ensureLocalBookmark( + input.cwd, + input.localBranch, + `${input.remoteBranch}@${input.remoteName}`, + ); + }, + ); + + const setBranchUpstream: JjCoreShape["setBranchUpstream"] = Effect.fn("setBranchUpstream")( + function* (input) { + yield* runJjCommand({ + operation: "JjCore.setBranchUpstream.trackRemoteBookmark", + cwd: input.cwd, + args: ["bookmark", "track", `${input.remoteBranch}@${input.remoteName}`], + allowNonZeroExit: true, + }).pipe(Effect.catch(() => Effect.void)); + + yield* runJjCommand({ + operation: "JjCore.setBranchUpstream.remote", + cwd: input.cwd, + args: [ + "config", + "set", + "--repo", + branchConfigKey(input.branch, "remote"), + JSON.stringify(input.remoteName), + ], + timeoutMs: 5_000, + }).pipe(Effect.asVoid); + yield* runJjCommand({ + operation: "JjCore.setBranchUpstream.merge", + cwd: input.cwd, + args: [ + "config", + "set", + "--repo", + branchConfigKey(input.branch, "merge"), + JSON.stringify(`refs/heads/${input.remoteBranch}`), + ], + timeoutMs: 5_000, + }).pipe(Effect.asVoid); + }, + ); + + const removeWorktree: JjCoreShape["removeWorktree"] = Effect.fn("removeWorktree")( + function* (input) { + const workspaceExists = yield* fileSystem.stat(input.path).pipe( + Effect.map(() => true), + Effect.catch(() => Effect.succeed(false)), + ); + if (!workspaceExists) { + return yield* commandError( + "JjCore.removeWorktree", + input.cwd, + "jj workspace forget", + `Workspace path does not exist: ${input.path}`, + ); + } + + const status = yield* statusDetails(input.path).pipe( + Effect.catch(() => + Effect.succeed({ + hasWorkingTreeChanges: false, + branch: null, + } as Pick), + ), + ); + if (status.hasWorkingTreeChanges && !input.force) { + return yield* commandError( + "JjCore.removeWorktree", + input.cwd, + "jj workspace forget", + "Workspace has uncommitted changes. Pass force to remove it.", + ); + } + + const repoDir = yield* resolveJjRepoDir(input.path); + yield* runJjCommand({ + operation: "JjCore.removeWorktree.forget", + cwd: input.path, + args: ["workspace", "forget"], + }); + yield* fileSystem + .remove(input.path, { recursive: true }) + .pipe( + Effect.mapError((cause) => + commandError( + "JjCore.removeWorktree.removePath", + input.cwd, + "remove workspace path", + cause instanceof Error ? cause.message : String(cause), + cause, + ), + ), + ); + + yield* updateWorkspaceRegistry(repoDir, (registry) => ({ + branches: Object.fromEntries( + Object.entries(registry.branches).filter( + ([branchName, worktreePath]) => + canonicalizePath(worktreePath) !== canonicalizePath(input.path) && + branchName !== status.branch, + ), + ), + })).pipe(Effect.catch(() => Effect.void)); + }, + ); + + const renameBranch: JjCoreShape["renameBranch"] = Effect.fn("renameBranch")(function* ( + input: GitRenameBranchInput, + ) { + if (input.oldBranch === input.newBranch) { + return { branch: input.newBranch }; + } + + yield* runJjCommand({ + operation: "JjCore.renameBranch", + cwd: input.cwd, + args: ["bookmark", "rename", input.oldBranch, input.newBranch], + }); + + const repoDir = yield* resolveJjRepoDir(input.cwd); + yield* updateWorkspaceRegistry(repoDir, (registry) => { + const existingPath = registry.branches[input.oldBranch]; + if (!existingPath) { + return registry; + } + const nextBranches = { ...registry.branches }; + delete nextBranches[input.oldBranch]; + nextBranches[input.newBranch] = existingPath; + return { branches: nextBranches }; + }).pipe(Effect.catch(() => Effect.void)); + + return { branch: input.newBranch }; + }); + + const createBranch: JjCoreShape["createBranch"] = Effect.fn("createBranch")(function* (input) { + yield* runJjCommand({ + operation: "JjCore.createBranch", + cwd: input.cwd, + args: ["bookmark", "create", input.branch, "--revision", "@"], + }); + + const [repoDir, workspaceRoot] = yield* Effect.all( + [resolveJjRepoDir(input.cwd), resolveJjRoot(input.cwd)], + { concurrency: "unbounded" }, + ); + yield* recordWorkspaceBranch(repoDir, workspaceRoot, input.branch).pipe( + Effect.catch(() => Effect.void), + ); + }); + + const checkoutBranch: JjCoreShape["checkoutBranch"] = Effect.fn("checkoutBranch")( + function* (input) { + yield* runJjCommand({ + operation: "JjCore.checkoutBranch", + cwd: input.cwd, + args: ["new", input.branch], + }); + + const [repoDir, workspaceRoot] = yield* Effect.all( + [resolveJjRepoDir(input.cwd), resolveJjRoot(input.cwd)], + { concurrency: "unbounded" }, + ); + yield* recordWorkspaceBranch(repoDir, workspaceRoot, input.branch).pipe( + Effect.catch(() => Effect.void), + ); + }, + ); + + const initRepo: JjCoreShape["initRepo"] = (input) => + runJjCommand({ + operation: "JjCore.initRepo", + cwd: process.cwd(), + args: ["git", "init", input.cwd], + }).pipe(Effect.asVoid); + + const listLocalBranchNames: JjCoreShape["listLocalBranchNames"] = (cwd) => + resolveBookmarkState(cwd).pipe( + Effect.map((state) => state.localBookmarks.map((bookmark) => bookmark.name).toSorted()), + ); + + return { + execute, + status, + statusDetails, + prepareCommitContext, + commit, + pushCurrentBranch, + pullCurrentBranch, + readRangeContext, + readConfigValue, + isInsideWorkTree, + listWorkspaceFiles, + filterIgnoredPaths, + listBranches, + createWorktree, + fetchPullRequestBranch, + ensureRemote, + fetchRemoteBranch, + setBranchUpstream, + removeWorktree, + renameBranch, + createBranch, + checkoutBranch, + initRepo, + listLocalBranchNames, + } satisfies GitCoreShape; +}); + +export const JjCoreLive = Layer.effect(JjCore, makeJjCore()); diff --git a/apps/server/src/jj/Layers/JjManager.test.ts b/apps/server/src/jj/Layers/JjManager.test.ts new file mode 100644 index 0000000000..821fff9a3a --- /dev/null +++ b/apps/server/src/jj/Layers/JjManager.test.ts @@ -0,0 +1,434 @@ +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { Effect, Layer, Schema } from "effect"; +import { describe, expect } from "vitest"; +import type { ThreadId } from "@t3tools/contracts"; + +import { GitHubCliError } from "@t3tools/contracts"; +import { ServerConfig } from "../../config.ts"; +import { GitHubCli, type GitHubCliShape } from "../../git/Services/GitHubCli.ts"; +import { TextGeneration } from "../../git/Services/TextGeneration.ts"; +import { GitCoreLive } from "../../git/Layers/GitCore.ts"; +import { + ProjectSetupScriptRunner, + type ProjectSetupScriptRunnerInput, + type ProjectSetupScriptRunnerShape, +} from "../../project/Services/ProjectSetupScriptRunner.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { JjCore } from "../Services/JjCore.ts"; +import { JjCoreLive } from "./JjCore.ts"; +import { makeJjManager } from "./JjManager.ts"; +import { + addRemoteAndPush, + createBareRemote, + initJjRepo, + makeTempDir, + runJj, + writeTextFile, +} from "./JjTestUtils.ts"; + +interface FakeGhScenario { + pullRequest: { + number: number; + title: string; + url: string; + baseRefName: string; + headRefName: string; + state?: "open" | "closed" | "merged"; + isCrossRepository?: boolean; + headRepositoryNameWithOwner?: string | null; + headRepositoryOwnerLogin?: string | null; + }; + repositoryCloneUrls?: Record; +} + +function runGitSyncForFakeGh(cwd: string, args: readonly string[]): void { + const result = spawnSync("git", args, { + cwd, + encoding: "utf8", + }); + if (result.status === 0) { + return; + } + throw new GitHubCliError({ + operation: "execute", + detail: `Failed to simulate gh checkout with git ${args.join(" ")}: ${result.stderr?.trim() || "unknown error"}`, + }); +} + +function createGitHubCliWithFakeGh(scenario: FakeGhScenario): GitHubCliShape { + const execute: GitHubCliShape["execute"] = (input) => { + const args = [...input.args]; + + if (args[0] === "pr" && args[1] === "view") { + const pullRequest = scenario.pullRequest; + return Effect.succeed({ + stdout: `${JSON.stringify(pullRequest)}\n`, + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + } + + if (args[0] === "pr" && args[1] === "checkout") { + return Effect.try({ + try: () => { + const headBranch = scenario.pullRequest.headRefName; + const existingBranch = spawnSync( + "git", + ["show-ref", "--verify", "--quiet", `refs/heads/${headBranch}`], + { + cwd: input.cwd, + encoding: "utf8", + }, + ); + if (existingBranch.status === 0) { + runGitSyncForFakeGh(input.cwd, ["checkout", headBranch]); + } else { + runGitSyncForFakeGh(input.cwd, ["checkout", "-b", headBranch]); + } + return { + stdout: "", + stderr: "", + code: 0, + signal: null, + timedOut: false, + }; + }, + catch: (error) => + Schema.is(GitHubCliError)(error) + ? error + : new GitHubCliError({ + operation: "execute", + detail: + error instanceof Error + ? `Failed to simulate gh checkout: ${error.message}` + : "Failed to simulate gh checkout.", + }), + }); + } + + if (args[0] === "repo" && args[1] === "view") { + const repository = args[2]; + if (typeof repository === "string" && args.includes("nameWithOwner,url,sshUrl")) { + const cloneUrls = scenario.repositoryCloneUrls?.[repository]; + if (!cloneUrls) { + return Effect.fail( + new GitHubCliError({ + operation: "execute", + detail: `Unexpected repository lookup: ${repository}`, + }), + ); + } + return Effect.succeed({ + stdout: + JSON.stringify({ + nameWithOwner: repository, + url: cloneUrls.url, + sshUrl: cloneUrls.sshUrl, + }) + "\n", + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + } + + return Effect.succeed({ + stdout: "main\n", + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + } + + if (args[0] === "pr" && args[1] === "list") { + return Effect.succeed({ + stdout: "[]\n", + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + } + + return Effect.fail( + new GitHubCliError({ + operation: "execute", + detail: `Unexpected gh command: ${args.join(" ")}`, + }), + ); + }; + + return { + execute, + listOpenPullRequests: () => Effect.succeed([]), + createPullRequest: () => Effect.void, + getDefaultBranch: () => Effect.succeed("main"), + getPullRequest: () => Effect.succeed(scenario.pullRequest), + getRepositoryCloneUrls: (input) => { + const cloneUrls = scenario.repositoryCloneUrls?.[input.repository]; + if (!cloneUrls) { + return Effect.fail( + new GitHubCliError({ + operation: "getRepositoryCloneUrls", + detail: `Unexpected repository lookup: ${input.repository}`, + }), + ); + } + return Effect.succeed({ + nameWithOwner: input.repository, + url: cloneUrls.url, + sshUrl: cloneUrls.sshUrl, + }); + }, + checkoutPullRequest: (input) => + execute({ + cwd: input.cwd, + args: ["pr", "checkout", input.reference, ...(input.force ? ["--force"] : [])], + }).pipe(Effect.asVoid), + }; +} + +function makeManager(input: { + ghScenario: FakeGhScenario; + setupScriptRunner?: ProjectSetupScriptRunnerShape; +}) { + const serverConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-jj-manager-test-", + }); + const serverSettingsLayer = ServerSettingsService.layerTest(); + const gitCoreLayer = GitCoreLive.pipe( + Layer.provide(serverConfigLayer), + Layer.provideMerge(NodeServices.layer), + ); + const jjCoreLayer = JjCoreLive.pipe( + Layer.provide(serverConfigLayer), + Layer.provide(gitCoreLayer), + Layer.provideMerge(NodeServices.layer), + ); + + const managerLayer = Layer.mergeAll( + Layer.succeed(GitHubCli, createGitHubCliWithFakeGh(input.ghScenario)), + Layer.succeed(TextGeneration, { + generateCommitMessage: () => Effect.die("unused in JjManager tests"), + generatePrContent: () => Effect.die("unused in JjManager tests"), + generateBranchName: () => Effect.die("unused in JjManager tests"), + generateThreadTitle: () => Effect.die("unused in JjManager tests"), + }), + Layer.succeed( + ProjectSetupScriptRunner, + input.setupScriptRunner ?? { + runForThread: () => Effect.succeed({ status: "no-script" as const }), + }, + ), + gitCoreLayer, + jjCoreLayer, + serverSettingsLayer, + ).pipe(Layer.provideMerge(NodeServices.layer)); + + return makeJjManager().pipe(Effect.provide(managerLayer)); +} + +const asThreadId = (threadId: string) => threadId as ThreadId; + +const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-jj-manager-test-", +}); +const GitCoreTestLayer = GitCoreLive.pipe( + Layer.provide(ServerConfigLayer), + Layer.provide(NodeServices.layer), +); +const JjCoreTestLayer = JjCoreLive.pipe( + Layer.provide(ServerConfigLayer), + Layer.provide(GitCoreTestLayer), + Layer.provideMerge(NodeServices.layer), +); +const TestLayer = Layer.mergeAll(NodeServices.layer, GitCoreTestLayer, JjCoreTestLayer); + +it.layer(TestLayer)("JjManager", (it) => { + describe("preparePullRequestThread", () => { + it.effect("prepares pull request threads in local mode on the PR head branch", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-jj-manager-"); + yield* initJjRepo(repoDir); + yield* writeTextFile(path.join(repoDir, "local.txt"), "local\n"); + yield* runJj(repoDir, ["file", "track", "local.txt"]); + yield* runJj(repoDir, ["describe", "-m", "Local PR branch"]); + yield* runJj(repoDir, ["bookmark", "create", "feature/pr-local", "-r", "@"]); + yield* runJj(repoDir, ["new", "main"]); + + const manager = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 64, + title: "Local PR", + url: "https://github.com/pingdotgg/t3code/pull/64", + baseRefName: "main", + headRefName: "feature/pr-local", + state: "open", + }, + }, + }); + + const result = yield* manager.preparePullRequestThread({ + cwd: repoDir, + reference: "#64", + mode: "local", + }); + + expect(result.branch).toBe("feature/pr-local"); + expect(result.worktreePath).toBeNull(); + const status = yield* manager.status({ cwd: repoDir }); + expect(status.branch).toBe("feature/pr-local"); + }), + ); + + it.effect("creates a new JJ workspace for a same-repo PR and launches setup once", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-jj-manager-"); + yield* initJjRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* addRemoteAndPush(repoDir, "origin", remoteDir); + yield* writeTextFile(path.join(repoDir, "worktree.txt"), "worktree\n"); + yield* runJj(repoDir, ["file", "track", "worktree.txt"]); + yield* runJj(repoDir, ["describe", "-m", "PR worktree branch"]); + yield* runJj(repoDir, ["bookmark", "create", "feature/pr-worktree", "-r", "@"]); + yield* runJj(repoDir, ["new", "main"]); + yield* runJj(repoDir, ["git", "fetch", "--remote", "origin"], true); + yield* runJj(repoDir, ["bookmark", "track", "feature/pr-worktree@origin"], true); + yield* runJj(repoDir, ["git", "push", "--remote", "origin", "-b", "feature/pr-worktree"]); + yield* runJj(repoDir, ["git", "export"]); + yield* runJj(repoDir, [ + "util", + "exec", + "--", + "git", + "push", + "origin", + "feature/pr-worktree:refs/pull/77/head", + ]); + yield* runJj(repoDir, ["git", "import"]); + + const setupCalls: ProjectSetupScriptRunnerInput[] = []; + const manager = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 77, + title: "Worktree PR", + url: "https://github.com/pingdotgg/t3code/pull/77", + baseRefName: "main", + headRefName: "feature/pr-worktree", + state: "open", + }, + }, + setupScriptRunner: { + runForThread: (setupInput) => + Effect.sync(() => { + setupCalls.push(setupInput); + return { status: "no-script" as const }; + }), + }, + }); + + const result = yield* manager.preparePullRequestThread({ + cwd: repoDir, + reference: "77", + mode: "worktree", + threadId: asThreadId("thread-jj-pr-worktree"), + }); + + expect(result.branch).toBe("feature/pr-worktree"); + expect(result.worktreePath).not.toBeNull(); + expect(fs.existsSync(result.worktreePath as string)).toBe(true); + const jjCore = yield* JjCore; + const worktreeStatus = yield* jjCore.statusDetails(result.worktreePath as string); + const rootStatus = yield* jjCore.statusDetails(repoDir); + + expect(worktreeStatus.branch).toBe("feature/pr-worktree"); + expect(rootStatus.branch).toBe("main"); + expect(setupCalls).toEqual([ + { + threadId: "thread-jj-pr-worktree", + projectCwd: repoDir, + worktreePath: result.worktreePath as string, + }, + ]); + }), + ); + + it.effect("preserves fork upstream tracking when preparing a JJ PR workspace", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-jj-manager-"); + yield* initJjRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* addRemoteAndPush(repoDir, "origin", originDir); + yield* writeTextFile(path.join(repoDir, "fork.txt"), "fork\n"); + yield* runJj(repoDir, ["file", "track", "fork.txt"]); + yield* runJj(repoDir, ["describe", "-m", "Fork PR branch"]); + yield* runJj(repoDir, ["bookmark", "create", "feature/pr-fork", "-r", "@"]); + yield* runJj(repoDir, ["new", "main"]); + yield* runJj(repoDir, ["git", "remote", "add", "fork-seed", forkDir]).pipe(Effect.asVoid); + yield* runJj(repoDir, ["git", "export"]); + yield* runJj(repoDir, [ + "util", + "exec", + "--", + "git", + "push", + "fork-seed", + "feature/pr-fork", + ]); + yield* runJj(repoDir, ["bookmark", "delete", "feature/pr-fork"]); + yield* runJj(repoDir, ["git", "import"]); + + const manager = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 81, + title: "Fork PR", + url: "https://github.com/pingdotgg/t3code/pull/81", + baseRefName: "main", + headRefName: "feature/pr-fork", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/t3code", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/t3code": { + url: forkDir, + sshUrl: forkDir, + }, + }, + }, + }); + + const result = yield* manager.preparePullRequestThread({ + cwd: repoDir, + reference: "81", + mode: "worktree", + }); + + expect(result.branch).toBe("t3code/pr-81/feature/pr-fork"); + expect(result.worktreePath).not.toBeNull(); + const jjCore = yield* JjCore; + const worktreeStatus = yield* jjCore.statusDetails(result.worktreePath as string); + const rootStatus = yield* jjCore.statusDetails(repoDir); + + expect(worktreeStatus.branch).toBe("t3code/pr-81/feature/pr-fork"); + // The upstream remote name depends on URL normalization between jj and + // the fake GH CLI; assert the branch portion is correct. + expect(worktreeStatus.upstreamRef).toMatch(/\/feature\/pr-fork$/); + expect(rootStatus.branch).toBe("main"); + }), + ); + }); +}); diff --git a/apps/server/src/jj/Layers/JjManager.ts b/apps/server/src/jj/Layers/JjManager.ts new file mode 100644 index 0000000000..09823f9242 --- /dev/null +++ b/apps/server/src/jj/Layers/JjManager.ts @@ -0,0 +1,1008 @@ +import { randomUUID } from "node:crypto"; +import fsPromises from "node:fs/promises"; +import path from "node:path"; + +import { Effect, Layer, Ref } from "effect"; +import { + GitActionProgressEvent, + GitActionProgressPhase, + GitRunStackedActionResult, + GitStackedAction, + ModelSelection, +} from "@t3tools/contracts"; +import { + resolveAutoFeatureBranchName, + sanitizeBranchFragment, + sanitizeFeatureBranchName, +} from "@t3tools/shared/vcs"; + +import { + type GitManagerShape, + type GitRunStackedActionOptions, +} from "../../git/Services/GitManager.ts"; +import { GitHubCli } from "../../git/Services/GitHubCli.ts"; +import { TextGeneration } from "../../git/Services/TextGeneration.ts"; +import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScriptRunner.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { JjCore } from "../Services/JjCore.ts"; +import { JjManager, type JjManagerShape } from "../Services/JjManager.ts"; +import { + branchConfigKey, + canonicalizePath, + parseMergeRefBranchName, + resolveJjRoot, +} from "../Utils.ts"; +import { GitManagerError } from "@t3tools/contracts"; + +const SHORT_SHA_LENGTH = 7; + +function jjManagerError(operation: string, detail: string, cause?: unknown): GitManagerError { + return new GitManagerError({ + operation, + detail, + ...(cause !== undefined ? { cause } : {}), + }); +} + +function shortenSha(sha: string | undefined): string | null { + if (!sha) return null; + return sha.slice(0, SHORT_SHA_LENGTH); +} + +function isCommitAction( + action: GitStackedAction, +): action is "commit" | "commit_push" | "commit_push_pr" { + return action === "commit" || action === "commit_push" || action === "commit_push_pr"; +} + +function formatCommitMessage(subject: string, body: string): string { + const trimmedBody = body.trim(); + if (trimmedBody.length === 0) { + return subject; + } + return `${subject}\n\n${trimmedBody}`; +} + +function parseCustomCommitMessage(raw: string): { subject: string; body: string } | null { + const normalized = raw.replace(/\r\n/g, "\n").trim(); + if (normalized.length === 0) { + return null; + } + + const [firstLine, ...rest] = normalized.split("\n"); + const subject = firstLine?.trim() ?? ""; + if (subject.length === 0) { + return null; + } + + return { + subject, + body: rest.join("\n").trim(), + }; +} + +function sanitizeCommitMessage(generated: { + subject: string; + body: string; + branch?: string | undefined; +}) { + const rawSubject = generated.subject.trim().split(/\r?\n/g)[0]?.trim() ?? ""; + const subject = rawSubject.replace(/[.]+$/g, "").trim(); + const safeSubject = subject.length > 0 ? subject.slice(0, 72).trimEnd() : "Update project files"; + return { + subject: safeSubject, + body: generated.body.trim(), + ...(generated.branch !== undefined ? { branch: generated.branch } : {}), + }; +} + +function limitContext(value: string, maxChars: number): string { + if (value.length <= maxChars) return value; + return `${value.slice(0, maxChars)}\n\n[truncated]`; +} + +interface CommitAndBranchSuggestion { + subject: string; + body: string; + branch?: string | undefined; + commitMessage: string; +} + +interface ResolvedPullRequest { + number: number; + title: string; + url: string; + baseBranch: string; + headBranch: string; + state: "open" | "closed" | "merged"; +} + +interface PullRequestHeadRemoteInfo { + isCrossRepository?: boolean; + headRepositoryNameWithOwner?: string | null; + headRepositoryOwnerLogin?: string | null; +} + +function normalizePullRequestReference(reference: string): string { + const trimmed = reference.trim(); + const hashNumber = /^#(\d+)$/.exec(trimmed); + return hashNumber?.[1] ?? trimmed; +} + +function parseRepositoryNameFromPullRequestUrl(url: string): string | null { + const trimmed = url.trim(); + const match = /^https:\/\/github\.com\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); + const repositoryName = match?.[1]?.trim() ?? ""; + return repositoryName.length > 0 ? repositoryName : null; +} + +function resolveHeadRepositoryNameWithOwner( + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, +): string | null { + const explicitRepository = pullRequest.headRepositoryNameWithOwner?.trim() ?? ""; + if (explicitRepository.length > 0) { + return explicitRepository; + } + + if (!pullRequest.isCrossRepository) { + return null; + } + + const ownerLogin = pullRequest.headRepositoryOwnerLogin?.trim() ?? ""; + const repositoryName = parseRepositoryNameFromPullRequestUrl(pullRequest.url); + if (ownerLogin.length === 0 || !repositoryName) { + return null; + } + + return `${ownerLogin}/${repositoryName}`; +} + +function resolvePullRequestWorktreeLocalBranchName( + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, +): string { + if (!pullRequest.isCrossRepository) { + return pullRequest.headBranch; + } + + const sanitizedHeadBranch = sanitizeBranchFragment(pullRequest.headBranch).trim(); + const suffix = sanitizedHeadBranch.length > 0 ? sanitizedHeadBranch : "head"; + return `t3code/pr-${pullRequest.number}/${suffix}`; +} + +function shouldPreferSshRemote(url: string | null): boolean { + if (!url) return false; + const trimmed = url.trim(); + return trimmed.startsWith("git@") || trimmed.startsWith("ssh://"); +} + +function toResolvedPullRequest(pr: { + number: number; + title: string; + url: string; + baseRefName: string; + headRefName: string; + state?: "open" | "closed" | "merged"; +}): ResolvedPullRequest { + return { + number: pr.number, + title: pr.title, + url: pr.url, + baseBranch: pr.baseRefName, + headBranch: pr.headRefName, + state: pr.state ?? "open", + }; +} + +function toPullRequestHeadRemoteInfo(pr: { + isCrossRepository?: boolean; + headRepositoryNameWithOwner?: string | null; + headRepositoryOwnerLogin?: string | null; +}): PullRequestHeadRemoteInfo { + return { + ...(pr.isCrossRepository !== undefined ? { isCrossRepository: pr.isCrossRepository } : {}), + ...(pr.headRepositoryNameWithOwner !== undefined + ? { headRepositoryNameWithOwner: pr.headRepositoryNameWithOwner } + : {}), + ...(pr.headRepositoryOwnerLogin !== undefined + ? { headRepositoryOwnerLogin: pr.headRepositoryOwnerLogin } + : {}), + }; +} + +type StripProgressContext = T extends any ? Omit : never; +type GitActionProgressPayload = StripProgressContext; + +export const makeJjManager = Effect.fn("makeJjManager")(function* () { + const jjCore = yield* JjCore; + const gitHubCli = yield* GitHubCli; + const textGeneration = yield* TextGeneration; + const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; + const serverSettingsService = yield* ServerSettingsService; + + const createProgressEmitter = ( + input: { cwd: string; action: GitStackedAction }, + options?: GitRunStackedActionOptions, + ) => { + const actionId = options?.actionId ?? randomUUID(); + const reporter = options?.progressReporter; + + const emit = (event: GitActionProgressPayload) => + reporter + ? reporter.publish({ + actionId, + cwd: input.cwd, + action: input.action, + ...event, + } as GitActionProgressEvent) + : Effect.void; + + return { + actionId, + emit, + }; + }; + + const resolveHeadSelectorBranch = Effect.fn("resolveHeadSelectorBranch")(function* ( + cwd: string, + branch: string, + ) { + const mergeRef = yield* jjCore + .readConfigValue(cwd, branchConfigKey(branch, "merge")) + .pipe(Effect.catch(() => Effect.succeed(null))); + return parseMergeRefBranchName(mergeRef) ?? branch; + }); + + const configurePullRequestHeadUpstreamBase = Effect.fn("configurePullRequestHeadUpstream")( + function* ( + cwd: string, + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, + localBranch = pullRequest.headBranch, + ) { + const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; + if (repositoryNameWithOwner.length === 0) { + return; + } + + const workspaceRoot = yield* resolveJjRoot(cwd); + const cloneUrls = yield* gitHubCli.getRepositoryCloneUrls({ + cwd: workspaceRoot, + repository: repositoryNameWithOwner, + }); + const originRemoteUrl = yield* jjCore.readConfigValue(cwd, "remote.origin.url"); + const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url; + const preferredRemoteName = + pullRequest.headRepositoryOwnerLogin?.trim() || + repositoryNameWithOwner.split("/")[0]?.trim() || + "fork"; + const remoteName = yield* jjCore.ensureRemote({ + cwd, + preferredName: preferredRemoteName, + url: remoteUrl, + }); + + yield* jjCore.setBranchUpstream({ + cwd, + branch: localBranch, + remoteName, + remoteBranch: pullRequest.headBranch, + }); + }, + ); + + const configurePullRequestHeadUpstream = ( + cwd: string, + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, + localBranch = pullRequest.headBranch, + ) => + configurePullRequestHeadUpstreamBase(cwd, pullRequest, localBranch).pipe( + Effect.catch((error) => + Effect.logWarning( + `JjManager.configurePullRequestHeadUpstream: failed to configure upstream for ${localBranch} -> ${pullRequest.headBranch} in ${cwd}: ${error.message}`, + ).pipe(Effect.asVoid), + ), + ); + + const materializePullRequestHeadBranchBase = Effect.fn("materializePullRequestHeadBranch")( + function* ( + cwd: string, + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, + localBranch = pullRequest.headBranch, + ) { + const repositoryNameWithOwner = resolveHeadRepositoryNameWithOwner(pullRequest) ?? ""; + if (repositoryNameWithOwner.length === 0) { + yield* jjCore.fetchPullRequestBranch({ + cwd, + prNumber: pullRequest.number, + branch: localBranch, + remoteBranch: pullRequest.headBranch, + }); + return; + } + + const workspaceRoot = yield* resolveJjRoot(cwd); + const cloneUrls = yield* gitHubCli.getRepositoryCloneUrls({ + cwd: workspaceRoot, + repository: repositoryNameWithOwner, + }); + const originRemoteUrl = yield* jjCore.readConfigValue(cwd, "remote.origin.url"); + const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url; + const preferredRemoteName = + pullRequest.headRepositoryOwnerLogin?.trim() || + repositoryNameWithOwner.split("/")[0]?.trim() || + "fork"; + const remoteName = yield* jjCore.ensureRemote({ + cwd, + preferredName: preferredRemoteName, + url: remoteUrl, + }); + + yield* jjCore.fetchRemoteBranch({ + cwd, + remoteName, + remoteBranch: pullRequest.headBranch, + localBranch, + }); + yield* jjCore.setBranchUpstream({ + cwd, + branch: localBranch, + remoteName, + remoteBranch: pullRequest.headBranch, + }); + }, + ); + + const materializePullRequestHeadBranch = ( + cwd: string, + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, + localBranch = pullRequest.headBranch, + ) => + materializePullRequestHeadBranchBase(cwd, pullRequest, localBranch).pipe( + Effect.catch((error) => + (resolveHeadRepositoryNameWithOwner(pullRequest) ?? "").length > 0 + ? Effect.fail(error) + : jjCore.fetchPullRequestBranch({ + cwd, + prNumber: pullRequest.number, + branch: localBranch, + remoteBranch: pullRequest.headBranch, + }), + ), + ); + + const resolveCommitAndBranchSuggestion = Effect.fn("resolveCommitAndBranchSuggestion")( + function* (input: { + cwd: string; + branch: string | null; + commitMessage?: string; + includeBranch?: boolean; + filePaths?: readonly string[]; + modelSelection: ModelSelection; + }) { + const context = yield* jjCore.prepareCommitContext(input.cwd, input.filePaths); + if (!context) { + return null; + } + + const customCommit = parseCustomCommitMessage(input.commitMessage ?? ""); + if (customCommit) { + return { + subject: customCommit.subject, + body: customCommit.body, + ...(input.includeBranch + ? { branch: sanitizeFeatureBranchName(customCommit.subject) } + : {}), + commitMessage: formatCommitMessage(customCommit.subject, customCommit.body), + } satisfies CommitAndBranchSuggestion; + } + + const generated = yield* textGeneration + .generateCommitMessage({ + cwd: input.cwd, + branch: input.branch, + stagedSummary: limitContext(context.stagedSummary, 8_000), + stagedPatch: limitContext(context.stagedPatch, 50_000), + ...(input.includeBranch ? { includeBranch: true } : {}), + modelSelection: input.modelSelection, + }) + .pipe(Effect.map((result) => sanitizeCommitMessage(result))); + + return { + subject: generated.subject, + body: generated.body, + ...(generated.branch !== undefined ? { branch: generated.branch } : {}), + commitMessage: formatCommitMessage(generated.subject, generated.body), + } satisfies CommitAndBranchSuggestion; + }, + ); + + const findOpenPr = Effect.fn("findOpenPr")(function* (cwd: string, headSelector: string) { + const workspaceRoot = yield* resolveJjRoot(cwd); + const pullRequests = yield* gitHubCli.listOpenPullRequests({ + cwd: workspaceRoot, + headSelector, + limit: 1, + }); + + return pullRequests[0] ?? null; + }); + + const status: JjManagerShape["status"] = Effect.fn("status")(function* (input) { + const current = yield* jjCore.status(input); + if (!current.isRepo || !current.branch || !current.hasOriginRemote) { + return current; + } + + const headSelector = yield* resolveHeadSelectorBranch(input.cwd, current.branch); + const pullRequest = yield* findOpenPr(input.cwd, headSelector).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + + return { + ...current, + pr: pullRequest + ? { + number: pullRequest.number, + title: pullRequest.title, + url: pullRequest.url, + baseBranch: pullRequest.baseRefName, + headBranch: pullRequest.headRefName, + state: pullRequest.state ?? "open", + } + : null, + }; + }); + + const resolvePullRequest: JjManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")( + function* (input) { + const workspaceRoot = yield* resolveJjRoot(input.cwd); + const pullRequest = yield* gitHubCli.getPullRequest({ + cwd: workspaceRoot, + reference: normalizePullRequestReference(input.reference), + }); + + return { + pullRequest: toResolvedPullRequest(pullRequest), + }; + }, + ); + + const preparePullRequestThread: JjManagerShape["preparePullRequestThread"] = Effect.fn( + "preparePullRequestThread", + )(function* (input) { + const maybeRunSetupScript = (worktreePath: string) => { + if (!input.threadId) { + return Effect.void; + } + + return projectSetupScriptRunner + .runForThread({ + threadId: input.threadId, + projectCwd: input.cwd, + worktreePath, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning( + `JjManager.preparePullRequestThread: failed to launch worktree setup script for thread ${input.threadId} in ${worktreePath}: ${error.message}`, + ).pipe(Effect.asVoid), + ), + ); + }; + + const normalizedReference = normalizePullRequestReference(input.reference); + const workspaceRoot = yield* resolveJjRoot(input.cwd); + const rootWorktreePath = canonicalizePath(workspaceRoot); + const pullRequestSummary = yield* gitHubCli.getPullRequest({ + cwd: workspaceRoot, + reference: normalizedReference, + }); + const pullRequest = toResolvedPullRequest(pullRequestSummary); + const pullRequestWithRemoteInfo = { + ...pullRequest, + ...toPullRequestHeadRemoteInfo(pullRequestSummary), + } as const; + const localPullRequestBranch = pullRequestWithRemoteInfo.isCrossRepository + ? resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo) + : pullRequest.headBranch; + + if (input.mode === "local") { + const existingLocalBranchNames = yield* jjCore + .listLocalBranchNames(workspaceRoot) + .pipe(Effect.catch(() => Effect.succeed([] as string[]))); + if (!existingLocalBranchNames.includes(localPullRequestBranch)) { + yield* materializePullRequestHeadBranch( + workspaceRoot, + pullRequestWithRemoteInfo, + localPullRequestBranch, + ); + } + yield* Effect.scoped( + jjCore.checkoutBranch({ + cwd: workspaceRoot, + branch: localPullRequestBranch, + }), + ); + const details = yield* jjCore.statusDetails(workspaceRoot); + yield* configurePullRequestHeadUpstream( + workspaceRoot, + pullRequestWithRemoteInfo, + details.branch ?? localPullRequestBranch, + ); + + return { + pullRequest, + branch: details.branch ?? localPullRequestBranch, + worktreePath: null, + }; + } + + const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* ( + worktreePath: string, + ) { + const details = yield* jjCore.statusDetails(worktreePath); + yield* configurePullRequestHeadUpstream( + worktreePath, + pullRequestWithRemoteInfo, + details.branch ?? pullRequest.headBranch, + ); + }); + + const findLocalHeadBranch = (cwd: string) => + jjCore.listBranches({ cwd }).pipe( + Effect.map((result) => { + const localBranch = result.branches.find( + (branch) => !branch.isRemote && branch.name === localPullRequestBranch, + ); + if (localBranch) { + return localBranch; + } + if (localPullRequestBranch === pullRequest.headBranch) { + return null; + } + return ( + result.branches.find( + (branch) => + !branch.isRemote && + branch.name === pullRequest.headBranch && + branch.worktreePath !== null && + canonicalizePath(branch.worktreePath) !== rootWorktreePath, + ) ?? null + ); + }), + ); + + const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd); + const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath + ? canonicalizePath(existingBranchBeforeFetch.worktreePath) + : null; + if ( + existingBranchBeforeFetch?.worktreePath && + existingBranchBeforeFetchPath !== rootWorktreePath + ) { + yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath); + return { + pullRequest, + branch: localPullRequestBranch, + worktreePath: existingBranchBeforeFetch.worktreePath, + }; + } + if (existingBranchBeforeFetchPath === rootWorktreePath) { + return yield* jjManagerError( + "preparePullRequestThread", + "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", + ); + } + + yield* materializePullRequestHeadBranch( + input.cwd, + pullRequestWithRemoteInfo, + localPullRequestBranch, + ); + + const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); + const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath + ? canonicalizePath(existingBranchAfterFetch.worktreePath) + : null; + if ( + existingBranchAfterFetch?.worktreePath && + existingBranchAfterFetchPath !== rootWorktreePath + ) { + yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath); + return { + pullRequest, + branch: localPullRequestBranch, + worktreePath: existingBranchAfterFetch.worktreePath, + }; + } + if (existingBranchAfterFetchPath === rootWorktreePath) { + return yield* jjManagerError( + "preparePullRequestThread", + "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", + ); + } + + const worktree = yield* jjCore.createWorktree({ + cwd: input.cwd, + branch: localPullRequestBranch, + path: null, + }); + yield* ensureExistingWorktreeUpstream(worktree.worktree.path); + yield* maybeRunSetupScript(worktree.worktree.path); + + return { + pullRequest, + branch: worktree.worktree.branch, + worktreePath: worktree.worktree.path, + }; + }); + + const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( + modelSelection: ModelSelection, + cwd: string, + branch: string | null, + commitMessage?: string, + filePaths?: readonly string[], + ) { + const suggestion = yield* resolveCommitAndBranchSuggestion({ + cwd, + branch, + ...(commitMessage ? { commitMessage } : {}), + ...(filePaths ? { filePaths } : {}), + includeBranch: true, + modelSelection, + }); + if (!suggestion) { + return yield* jjManagerError( + "runFeatureBranchStep", + "Cannot create a feature branch because there are no changes to commit.", + ); + } + + const existingBranchNames = yield* jjCore.listLocalBranchNames(cwd); + const preferredBranch = suggestion.branch ?? sanitizeFeatureBranchName(suggestion.subject); + const resolvedBranch = resolveAutoFeatureBranchName(existingBranchNames, preferredBranch); + + // In JJ, createBranch points the bookmark at @ (the working copy with + // changes). There is no need to checkout — the working copy already + // contains the changes that will be committed in the next step. + yield* jjCore.createBranch({ cwd, branch: resolvedBranch }); + + return { + branchStep: { status: "created" as const, name: resolvedBranch }, + resolvedCommitMessage: suggestion.commitMessage, + resolvedCommitSuggestion: suggestion, + }; + }); + + const runCommitStep = Effect.fn("runCommitStep")(function* ( + modelSelection: ModelSelection, + cwd: string, + branch: string | null, + commitMessage?: string, + preResolvedSuggestion?: CommitAndBranchSuggestion, + filePaths?: readonly string[], + ) { + let suggestion: CommitAndBranchSuggestion | null | undefined = preResolvedSuggestion; + if (!suggestion) { + suggestion = yield* resolveCommitAndBranchSuggestion({ + cwd, + branch, + ...(commitMessage ? { commitMessage } : {}), + ...(filePaths ? { filePaths } : {}), + modelSelection, + }); + } + if (!suggestion) { + return { status: "skipped_no_changes" as const }; + } + + const { commitSha } = yield* jjCore.commit(cwd, suggestion.subject, suggestion.body); + return { + status: "created" as const, + commitSha, + subject: suggestion.subject, + }; + }); + + const runPrStep = Effect.fn("runPrStep")(function* ( + modelSelection: ModelSelection, + cwd: string, + branch: string | null, + ) { + if (!branch) { + return yield* jjManagerError( + "runPrStep", + "Cannot create a pull request without an active bookmark.", + ); + } + + const headSelector = yield* resolveHeadSelectorBranch(cwd, branch); + const existing = yield* findOpenPr(cwd, headSelector).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + if (existing) { + return { + status: "opened_existing" as const, + url: existing.url, + number: existing.number, + baseBranch: existing.baseRefName, + headBranch: existing.headRefName, + title: existing.title, + }; + } + + const details = yield* jjCore.statusDetails(cwd); + if (!details.hasUpstream) { + return yield* jjManagerError( + "runPrStep", + "Current bookmark has not been pushed. Push it first.", + ); + } + + const baseBranch = + (yield* jjCore + .readConfigValue(cwd, branchConfigKey(branch, "gh-merge-base")) + .pipe(Effect.catch(() => Effect.succeed(null)))) ?? + (details.isDefaultBranch ? branch : "main"); + const rangeContext = yield* jjCore.readRangeContext(cwd, baseBranch); + + const generated = yield* textGeneration.generatePrContent({ + cwd, + baseBranch, + headBranch: headSelector, + commitSummary: limitContext(rangeContext.commitSummary, 20_000), + diffSummary: limitContext(rangeContext.diffSummary, 20_000), + diffPatch: limitContext(rangeContext.diffPatch, 60_000), + modelSelection, + }); + + const workspaceRoot = yield* resolveJjRoot(cwd); + const bodyFile = path.join( + process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp", + `t3code-jj-pr-body-${process.pid}-${randomUUID()}.md`, + ); + yield* Effect.tryPromise({ + try: () => fsPromises.writeFile(bodyFile, generated.body, "utf8"), + catch: (cause) => jjManagerError("runPrStep", "Failed to write PR body file.", cause), + }); + yield* gitHubCli + .createPullRequest({ + cwd: workspaceRoot, + baseBranch, + headSelector, + title: generated.title, + bodyFile, + }) + .pipe( + Effect.ensuring( + Effect.tryPromise({ + try: () => fsPromises.unlink(bodyFile), + catch: () => undefined, + }).pipe(Effect.ignore), + ), + ); + + const created = yield* findOpenPr(cwd, headSelector).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + return created + ? { + status: "created" as const, + url: created.url, + number: created.number, + baseBranch: created.baseRefName, + headBranch: created.headRefName, + title: created.title, + } + : { + status: "created" as const, + baseBranch, + headBranch: headSelector, + title: generated.title, + }; + }); + + const runStackedAction: JjManagerShape["runStackedAction"] = Effect.fn("runStackedAction")( + function* (input, options) { + const progress = createProgressEmitter(input, options); + const currentPhase = yield* Ref.make(null); + const runAction = Effect.fn("runStackedAction.runAction")(function* () { + const initialStatus = yield* jjCore.statusDetails(input.cwd); + const wantsCommit = isCommitAction(input.action); + const wantsPush = + input.action === "push" || + input.action === "commit_push" || + input.action === "commit_push_pr" || + (input.action === "create_pr" && + (!initialStatus.hasUpstream || initialStatus.aheadCount > 0)); + const wantsPr = input.action === "create_pr" || input.action === "commit_push_pr"; + + if (input.featureBranch && !wantsCommit) { + return yield* jjManagerError( + "runStackedAction", + "Feature-branch creation is only supported for commit actions.", + ); + } + if (input.action === "push" && initialStatus.hasWorkingTreeChanges) { + return yield* jjManagerError( + "runStackedAction", + "Commit or revert local changes before pushing.", + ); + } + if (input.action === "create_pr" && initialStatus.hasWorkingTreeChanges) { + return yield* jjManagerError( + "runStackedAction", + "Commit local changes before creating a PR.", + ); + } + + yield* progress.emit({ + kind: "action_started", + phases: [ + ...(input.featureBranch ? (["branch"] as const) : []), + ...(wantsCommit ? (["commit"] as const) : []), + ...(wantsPush ? (["push"] as const) : []), + ...(wantsPr ? (["pr"] as const) : []), + ], + }); + + const modelSelection = yield* serverSettingsService.getSettings.pipe( + Effect.map((settings) => settings.textGenerationModelSelection), + Effect.mapError((cause) => + jjManagerError("runStackedAction", "Failed to load server settings.", cause), + ), + ); + + let branchStep: { status: "created" | "skipped_not_requested"; name?: string }; + let commitMessageForStep = input.commitMessage; + let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined; + + if (input.featureBranch) { + yield* Ref.set(currentPhase, "branch"); + yield* progress.emit({ + kind: "phase_started", + phase: "branch", + label: "Preparing feature bookmark...", + }); + const featureBranchResult = yield* runFeatureBranchStep( + modelSelection, + input.cwd, + initialStatus.branch, + input.commitMessage, + input.filePaths, + ); + branchStep = featureBranchResult.branchStep; + commitMessageForStep = featureBranchResult.resolvedCommitMessage; + preResolvedCommitSuggestion = featureBranchResult.resolvedCommitSuggestion; + } else { + branchStep = { status: "skipped_not_requested" as const }; + } + + const currentBranch = branchStep.name ?? initialStatus.branch; + + const commit = wantsCommit + ? yield* Effect.gen(function* () { + yield* Ref.set(currentPhase, "commit"); + return yield* runCommitStep( + modelSelection, + input.cwd, + currentBranch, + commitMessageForStep, + preResolvedCommitSuggestion, + input.filePaths, + ); + }) + : { status: "skipped_not_requested" as const }; + + const push = wantsPush + ? yield* Effect.gen(function* () { + yield* Ref.set(currentPhase, "push"); + yield* progress.emit({ + kind: "phase_started", + phase: "push", + label: "Pushing...", + }); + return yield* jjCore.pushCurrentBranch(input.cwd, currentBranch); + }) + : { status: "skipped_not_requested" as const }; + + const pr = wantsPr + ? yield* Effect.gen(function* () { + yield* Ref.set(currentPhase, "pr"); + yield* progress.emit({ + kind: "phase_started", + phase: "pr", + label: "Creating PR...", + }); + return yield* runPrStep(modelSelection, input.cwd, currentBranch); + }) + : { status: "skipped_not_requested" as const }; + + const committedSha = commit.status === "created" ? shortenSha(commit.commitSha) : null; + + const toast = (() => { + if (pr.status === "created" || pr.status === "opened_existing") { + return { + title: `${pr.status === "created" ? "Created PR" : "Opened PR"}${pr.number ? ` #${pr.number}` : ""}`, + ...(pr.title ? { description: pr.title } : {}), + cta: pr.url + ? { + kind: "open_pr" as const, + label: "View PR", + url: pr.url, + } + : { kind: "none" as const }, + }; + } + if (push.status === "pushed") { + return { + title: `Pushed${committedSha ? ` ${committedSha}` : ""}`, + ...(commit.status === "created" && commit.subject + ? { description: commit.subject } + : {}), + cta: wantsPr + ? { + kind: "run_action" as const, + label: "Create PR", + action: { kind: "create_pr" as const }, + } + : { kind: "none" as const }, + }; + } + if (commit.status === "created") { + return { + title: `Committed${committedSha ? ` ${committedSha}` : ""}`, + ...(commit.subject ? { description: commit.subject } : {}), + cta: { + kind: "run_action" as const, + label: "Push", + action: { kind: "push" as const }, + }, + }; + } + return { + title: "Done", + cta: { kind: "none" as const }, + }; + })(); + + const result = { + action: input.action, + branch: branchStep, + commit, + push, + pr, + toast, + } satisfies GitRunStackedActionResult; + + yield* progress.emit({ + kind: "action_finished", + result, + }); + + return result; + }); + + return yield* runAction().pipe( + Effect.tapError((error) => + Effect.flatMap(Ref.get(currentPhase), (phase) => + progress.emit({ + kind: "action_failed", + phase, + message: error.message, + }), + ), + ), + ); + }, + ); + + return { + status, + resolvePullRequest, + preparePullRequestThread, + runStackedAction, + } satisfies GitManagerShape; +}); + +export const JjManagerLive = Layer.effect(JjManager, makeJjManager()); diff --git a/apps/server/src/jj/Layers/JjTestUtils.ts b/apps/server/src/jj/Layers/JjTestUtils.ts new file mode 100644 index 0000000000..a2b084aed5 --- /dev/null +++ b/apps/server/src/jj/Layers/JjTestUtils.ts @@ -0,0 +1,164 @@ +import path from "node:path"; + +import { Effect, FileSystem, PlatformError, Scope } from "effect"; + +import { GitCommandError } from "@t3tools/contracts"; +import { type ProcessRunResult, runProcess } from "../../processRunner.ts"; +import { GitCore } from "../../git/Services/GitCore.ts"; +import { JjCore } from "../Services/JjCore.ts"; + +export function makeTempDir( + prefix: string, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); + }); +} + +export function writeTextFile( + filePath: string, + contents: string, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.writeFileString(filePath, contents); + }); +} + +export function runGit( + cwd: string, + args: readonly string[], + allowNonZeroExit = false, +): Effect.Effect< + { readonly code: number; readonly stdout: string; readonly stderr: string }, + GitCommandError, + GitCore +> { + return Effect.gen(function* () { + const gitCore = yield* GitCore; + return yield* gitCore.execute({ + operation: "JjTestUtils.runGit", + cwd, + args, + allowNonZeroExit, + timeoutMs: 30_000, + }); + }); +} + +export function runJj( + cwd: string, + args: readonly string[], + allowNonZeroExit = false, +): Effect.Effect { + return Effect.promise(() => + runProcess("jj", args, { + cwd, + timeoutMs: 30_000, + allowNonZeroExit, + }), + ); +} + +export function runJjStdout(cwd: string, args: readonly string[]): Effect.Effect { + return runJj(cwd, args).pipe(Effect.map((result) => result.stdout.trim())); +} + +export function parseJsonLines(stdout: string): T[] { + return stdout + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as T); +} + +export function listBookmarks(cwd: string) { + return runJjStdout(cwd, ["bookmark", "list", "--all-remotes", "-T", 'json(self) ++ "\\n"']).pipe( + Effect.map( + ( + stdout, + ): Array<{ + name?: string; + remote?: string; + target?: string[]; + tracking_target?: string[]; + }> => parseJsonLines(stdout), + ), + ); +} + +/** + * Initialize a JJ repo with git backend, auto-track disabled, and an initial + * commit on "main". Uses jj-native commands for the commit workflow; + * git is only touched for user config required by the git backend. + */ +export function initJjRepo( + cwd: string, +): Effect.Effect< + { initialBranch: string }, + GitCommandError | PlatformError.PlatformError | Error, + GitCore | JjCore | FileSystem.FileSystem +> { + return Effect.gen(function* () { + const jjCore = yield* JjCore; + yield* jjCore.initRepo({ cwd }); + + // Disable auto-tracking so tests exercise the explicit `jj file track` + // path — the same path production repos with `snapshot.auto-track = "none()"` + // will hit. + yield* runJj(cwd, ["config", "set", "--repo", "snapshot.auto-track", "none()"]).pipe( + Effect.asVoid, + ); + + // Git backend needs author/committer identity. + yield* runGit(cwd, ["config", "user.email", "test@example.com"]); + yield* runGit(cwd, ["config", "user.name", "Test User"]); + + // Seed the repo with a committed README using jj-native commands. + yield* writeTextFile(path.join(cwd, "README.md"), "hello\n"); + yield* runJj(cwd, ["file", "track", "README.md"]).pipe(Effect.asVoid); + yield* runJj(cwd, ["describe", "-m", "Initial commit"]).pipe(Effect.asVoid); + yield* runJj(cwd, ["bookmark", "create", "main", "-r", "@"]).pipe(Effect.asVoid); + yield* runJj(cwd, ["new"]).pipe(Effect.asVoid); + // Export to git so that bare-remote push tests see a main branch. + yield* runJj(cwd, ["git", "export"]).pipe(Effect.asVoid); + + return { initialBranch: "main" }; + }); +} + +/** + * Add a named git remote, fetch to discover remote state, track a bookmark, + * and push it. + */ +export function addRemoteAndPush( + cwd: string, + remoteName: string, + remoteDir: string, + bookmark = "main", +): Effect.Effect { + return Effect.gen(function* () { + yield* runJj(cwd, ["git", "remote", "add", remoteName, remoteDir]); + // Fetch so jj sees the (empty) remote state, then track + push. + yield* runJj(cwd, ["git", "fetch", "--remote", remoteName], true); + yield* runJj(cwd, ["bookmark", "track", `${bookmark}@${remoteName}`], true); + yield* runJj(cwd, ["git", "push", "--remote", remoteName, "-b", bookmark]); + }).pipe(Effect.asVoid); +} + +/** + * Create a bare git remote. This is intentionally git — JJ push/fetch + * operates over git remotes. + */ +export function createBareRemote(): Effect.Effect< + string, + PlatformError.PlatformError | GitCommandError, + FileSystem.FileSystem | Scope.Scope | GitCore +> { + return Effect.gen(function* () { + const remoteDir = yield* makeTempDir("t3code-jj-remote-"); + yield* runGit(remoteDir, ["init", "--bare"]); + return remoteDir; + }); +} diff --git a/apps/server/src/jj/Services/JjCore.ts b/apps/server/src/jj/Services/JjCore.ts new file mode 100644 index 0000000000..aecfc4e354 --- /dev/null +++ b/apps/server/src/jj/Services/JjCore.ts @@ -0,0 +1,7 @@ +import { ServiceMap } from "effect"; + +import type { GitCoreShape } from "../../git/Services/GitCore.ts"; + +export interface JjCoreShape extends GitCoreShape {} + +export class JjCore extends ServiceMap.Service()("t3/jj/Services/JjCore") {} diff --git a/apps/server/src/jj/Services/JjManager.ts b/apps/server/src/jj/Services/JjManager.ts new file mode 100644 index 0000000000..3fff829e2f --- /dev/null +++ b/apps/server/src/jj/Services/JjManager.ts @@ -0,0 +1,9 @@ +import { ServiceMap } from "effect"; + +import type { GitManagerShape } from "../../git/Services/GitManager.ts"; + +export interface JjManagerShape extends GitManagerShape {} + +export class JjManager extends ServiceMap.Service()( + "t3/jj/Services/JjManager", +) {} diff --git a/apps/server/src/jj/Utils.ts b/apps/server/src/jj/Utils.ts new file mode 100644 index 0000000000..ca6c82fd0e --- /dev/null +++ b/apps/server/src/jj/Utils.ts @@ -0,0 +1,380 @@ +import { spawn } from "node:child_process"; +import { realpathSync } from "node:fs"; +import fsPromises from "node:fs/promises"; +import path from "node:path"; + +import { Effect, Schema } from "effect"; + +import { GitCommandError } from "@t3tools/contracts"; + +export interface JjCommandResult { + readonly code: number; + readonly stdout: string; + readonly stderr: string; + readonly stdoutTruncated: boolean; + readonly stderrTruncated: boolean; +} + +export interface RunJjCommandInput { + readonly operation: string; + readonly cwd: string; + readonly args: ReadonlyArray; + readonly allowNonZeroExit?: boolean; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; + readonly truncateOutputAtMaxBytes?: boolean; + readonly env?: NodeJS.ProcessEnv; +} + +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; +const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; + +function commandLabel(args: ReadonlyArray): string { + return ["jj", ...args].join(" "); +} + +function toGitCommandError(input: { + operation: string; + cwd: string; + args: ReadonlyArray; + detail: string; + cause?: unknown; +}) { + return new GitCommandError({ + operation: input.operation, + command: commandLabel(input.args), + cwd: input.cwd, + detail: input.detail, + ...(input.cause !== undefined ? { cause: input.cause } : {}), + }); +} + +function truncateOutput( + value: string, + maxOutputBytes: number, +): { text: string; truncated: boolean } { + const byteLength = Buffer.byteLength(value); + if (byteLength <= maxOutputBytes) { + return { text: value, truncated: false }; + } + + let text = value; + while (Buffer.byteLength(text) > maxOutputBytes) { + text = text.slice(0, Math.max(0, Math.floor(text.length * 0.9))); + } + + return { + text: `${text}${OUTPUT_TRUNCATED_MARKER}`, + truncated: true, + }; +} + +function withSizeLimit( + value: string, + maxOutputBytes: number, + truncateOutputAtMaxBytes: boolean, +): { text: string; truncated: boolean } { + if (!truncateOutputAtMaxBytes) { + return { text: value, truncated: false }; + } + return truncateOutput(value, maxOutputBytes); +} + +function isStaleWorkingCopyMessage(detail: string): boolean { + const normalized = detail.toLowerCase(); + return ( + normalized.includes("working copy is stale") || + normalized.includes("concurrent modification detected") + ); +} + +function isGitCommandError(value: unknown): value is GitCommandError { + return Schema.is(GitCommandError)(value); +} + +const spawnCommand = Effect.fn("spawnCommand")(function* ( + command: string, + input: RunJjCommandInput, +): Effect.fn.Return { + return yield* Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; + let stdout = ""; + let stderr = ""; + let stdoutTruncated = false; + let stderrTruncated = false; + let finished = false; + + const child = spawn(command, [...input.args], { + cwd: input.cwd, + env: input.env, + stdio: ["ignore", "pipe", "pipe"], + }); + + const finish = (callback: () => void) => { + if (finished) return; + finished = true; + clearTimeout(timer); + callback(); + }; + + const append = ( + chunk: Buffer, + current: string, + truncated: boolean, + ): { value: string; truncated: boolean } => { + if (truncated) { + return { value: current, truncated: true }; + } + + const combined = current + chunk.toString("utf8"); + const limited = withSizeLimit( + combined, + maxOutputBytes, + input.truncateOutputAtMaxBytes ?? false, + ); + return { + value: limited.text, + truncated: limited.truncated, + }; + }; + + child.stdout.on("data", (chunk: Buffer | string) => { + const next = append( + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk), + stdout, + stdoutTruncated, + ); + stdout = next.value; + stdoutTruncated = next.truncated; + }); + child.stderr.on("data", (chunk: Buffer | string) => { + const next = append( + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk), + stderr, + stderrTruncated, + ); + stderr = next.value; + stderrTruncated = next.truncated; + }); + + child.on("error", (cause) => { + finish(() => + reject( + toGitCommandError({ + operation: input.operation, + cwd: input.cwd, + args: input.args, + detail: + cause instanceof Error && cause.message.toLowerCase().includes("enoent") + ? "Jujutsu CLI (`jj`) is required but not available on PATH." + : cause instanceof Error + ? cause.message + : String(cause), + cause, + }), + ), + ); + }); + + child.on("close", (code) => { + const result = { + code: code ?? 1, + stdout, + stderr, + stdoutTruncated, + stderrTruncated, + } satisfies JjCommandResult; + + if (result.code !== 0 && !input.allowNonZeroExit) { + finish(() => + reject( + toGitCommandError({ + operation: input.operation, + cwd: input.cwd, + args: input.args, + detail: + result.stderr.trim().length > 0 ? result.stderr.trim() : `${command} failed`, + }), + ), + ); + return; + } + + finish(() => resolve(result)); + }); + + const timer = setTimeout(() => { + child.kill("SIGTERM"); + finish(() => + reject( + toGitCommandError({ + operation: input.operation, + cwd: input.cwd, + args: input.args, + detail: `${command} timed out after ${timeoutMs}ms`, + }), + ), + ); + }, timeoutMs); + }), + catch: (cause) => + isGitCommandError(cause) + ? cause + : toGitCommandError({ + operation: input.operation, + cwd: input.cwd, + args: input.args, + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }); +}); + +export const runJjCommand = Effect.fn("runJjCommand")(function* ( + input: RunJjCommandInput, +): Effect.fn.Return { + const attempt = (shouldRetryStale: boolean) => + spawnCommand("jj", input).pipe( + Effect.catch((error) => { + if (!shouldRetryStale || !isStaleWorkingCopyMessage(error.detail)) { + return Effect.fail(error); + } + + return spawnCommand("jj", { + operation: `${input.operation}.updateStale`, + cwd: input.cwd, + args: ["workspace", "update-stale"], + allowNonZeroExit: false, + }).pipe(Effect.flatMap(() => spawnCommand("jj", input))); + }), + ); + + return yield* attempt(true); +}); + +export const runJjStdout = Effect.fn("runJjStdout")(function* ( + operation: string, + cwd: string, + args: ReadonlyArray, + options?: Omit, +): Effect.fn.Return { + const result = yield* runJjCommand({ + operation, + cwd, + args, + ...options, + }); + return result.stdout; +}); + +export const resolveJjRoot = (cwd: string) => + runJjStdout("Jj.resolveRoot", cwd, ["root"]).pipe( + Effect.map((stdout) => stdout.trim()), + Effect.mapError((error) => + toGitCommandError({ + operation: "Jj.resolveRoot", + cwd, + args: ["root"], + detail: error.detail, + cause: error, + }), + ), + ); + +export const resolveJjRepoDir = (cwd: string) => + resolveJjRoot(cwd).pipe( + Effect.flatMap((workspaceRoot) => + Effect.tryPromise({ + try: async () => { + const repoPointerPath = path.join(workspaceRoot, ".jj", "repo"); + try { + const repoPointerStats = await fsPromises.stat(repoPointerPath); + if (repoPointerStats.isFile()) { + const repoDir = (await fsPromises.readFile(repoPointerPath, "utf8")).trim(); + if (repoDir.length > 0) { + return repoDir; + } + } + } catch { + // Fall back to the default repo location inside the workspace root. + } + + return path.join(workspaceRoot, ".jj", "repo"); + }, + catch: (cause) => + toGitCommandError({ + operation: "Jj.resolveRepoDir", + cwd, + args: ["root"], + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }), + ), + ); + +export const readJjRepoBackendType = (cwd: string) => + resolveJjRepoDir(cwd).pipe( + Effect.flatMap((repoDir) => + Effect.tryPromise({ + try: async () => { + const backendTypePath = path.join(repoDir, "store", "type"); + return (await fsPromises.readFile(backendTypePath, "utf8")).trim(); + }, + catch: (cause) => + toGitCommandError({ + operation: "Jj.readRepoBackendType", + cwd, + args: ["root"], + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }), + ), + ); + +export function formatJjConfigKeySegment(segment: string): string { + return /^[A-Za-z0-9_-]+$/u.test(segment) + ? segment + : `"${segment.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +export function branchConfigKey(branch: string, key: string): string { + return `branch.${formatJjConfigKeySegment(branch)}.${key}`; +} + +export function parseMergeRefBranchName(value: string | null | undefined): string | null { + const trimmed = value?.trim() ?? ""; + if (trimmed.length === 0) { + return null; + } + + const refsHeadsPrefix = "refs/heads/"; + return trimmed.startsWith(refsHeadsPrefix) ? trimmed.slice(refsHeadsPrefix.length) : trimmed; +} + +export function canonicalizePath(value: string): string { + try { + return realpathSync.native(value); + } catch { + return value; + } +} + +export function parseJsonLines(stdout: string): T[] { + return stdout + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .flatMap((line) => { + try { + return [JSON.parse(line) as T]; + } catch { + return []; + } + }); +} diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 72adb175f9..0ea0cdd14f 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -20,6 +20,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; import { GitCoreLive } from "../../git/Layers/GitCore.ts"; +import { VcsCoreFromGitLive } from "../../vcs/Layers/VcsCore.ts"; import { CheckpointReactorLive } from "./CheckpointReactor.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; @@ -270,6 +271,7 @@ describe("CheckpointReactor", () => { Layer.provideMerge(CheckpointStoreLive), Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), + Layer.provideMerge(VcsCoreFromGitLive.pipe(Layer.provideMerge(GitCoreLive))), Layer.provideMerge(GitCoreLive), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 03abebaf3a..9585fa6cc6 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -23,8 +23,8 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts"; import { CheckpointStoreError } from "../../checkpointing/Errors.ts"; import { OrchestrationDispatchError } from "../Errors.ts"; -import { isGitRepository } from "../../git/Utils.ts"; import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts"; +import { isVersionControlledRepository } from "../../vcs/Utils.ts"; type ReactorInput = | { @@ -147,7 +147,7 @@ const make = Effect.gen(function* () { return Option.none(); }); - const isGitWorkspace = (cwd: string) => isGitRepository(cwd); + const isVersionControlledWorkspace = (cwd: string) => isVersionControlledRepository(cwd); // Resolves the workspace CWD for checkpoint operations, preferring the // active provider session CWD and falling back to the thread/project config. @@ -179,7 +179,7 @@ const make = Effect.gen(function* () { if (!cwd) { return undefined; } - if (!isGitWorkspace(cwd)) { + if (!isVersionControlledWorkspace(cwd)) { return undefined; } return cwd; @@ -586,7 +586,7 @@ const make = Effect.gen(function* () { }).pipe(Effect.catch(() => Effect.void)); return; } - if (!isGitWorkspace(sessionRuntime.value.cwd)) { + if (!isVersionControlledWorkspace(sessionRuntime.value.cwd)) { yield* appendRevertFailureActivity({ threadId: event.payload.threadId, turnCount: event.payload.turnCount, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index fe6cb9caf5..248a90dd8c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -36,6 +36,7 @@ import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { VcsCore } from "../../vcs/Services/VcsCore.ts"; const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value); const asApprovalRequestId = (value: string): ApprovalRequestId => @@ -224,6 +225,7 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge(orchestrationLayer), Layer.provideMerge(Layer.succeed(ProviderService, service)), Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)), + Layer.provideMerge(Layer.succeed(VcsCore, { renameBranch } as unknown as GitCoreShape)), Layer.provideMerge( Layer.mock(TextGeneration, { generateBranchName, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 419e3f3bf2..a15c41c3cc 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -15,7 +15,6 @@ import { Cache, Cause, Duration, Effect, Equal, Layer, Option, Schema, Stream } import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; -import { GitCore } from "../../git/Services/GitCore.ts"; import { increment, orchestrationEventsProcessedTotal } from "../../observability/Metrics.ts"; import { ProviderAdapterRequestError, ProviderServiceError } from "../../provider/Errors.ts"; import { TextGeneration } from "../../git/Services/TextGeneration.ts"; @@ -26,6 +25,7 @@ import { type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { VcsCore } from "../../vcs/Services/VcsCore.ts"; type ProviderIntentEvent = Extract< OrchestrationEvent, @@ -149,7 +149,7 @@ function buildGeneratedWorktreeBranchName(raw: string): string { const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; - const git = yield* GitCore; + const vcs = yield* VcsCore; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; const handledTurnStartKeys = yield* Cache.make({ @@ -444,7 +444,7 @@ const make = Effect.gen(function* () { const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); if (targetBranch === oldBranch) return; - const renamed = yield* git.renameBranch({ cwd, oldBranch, newBranch: targetBranch }); + const renamed = yield* vcs.renameBranch({ cwd, oldBranch, newBranch: targetBranch }); yield* orchestrationEngine.dispatch({ type: "thread.meta.update", commandId: serverCommandId("worktree-branch-rename"), diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 8d3fb5d752..262b865e96 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -20,13 +20,13 @@ import { ProviderService } from "../../provider/Services/ProviderService.ts"; import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts"; import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; -import { isGitRepository } from "../../git/Utils.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderRuntimeIngestionService, type ProviderRuntimeIngestionShape, } from "../Services/ProviderRuntimeIngestion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { isVersionControlledRepository } from "../../vcs/Utils.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId => @@ -524,7 +524,9 @@ const make = Effect.fn("make")(function* () { lookup: () => Effect.succeed({ text: "", createdAt: "" }), }); - const isGitRepoForThread = Effect.fn("isGitRepoForThread")(function* (threadId: ThreadId) { + const isVersionControlledRepoForThread = Effect.fn("isVersionControlledRepoForThread")(function* ( + threadId: ThreadId, + ) { const readModel = yield* orchestrationEngine.getReadModel(); const thread = readModel.threads.find((entry) => entry.id === threadId); if (!thread) { @@ -537,7 +539,7 @@ const make = Effect.fn("make")(function* () { if (!workspaceCwd) { return false; } - return isGitRepository(workspaceCwd); + return isVersionControlledRepository(workspaceCwd); }); const rememberAssistantMessageId = (threadId: ThreadId, turnId: TurnId, messageId: MessageId) => @@ -1178,7 +1180,7 @@ const make = Effect.fn("make")(function* () { if (event.type === "turn.diff.updated") { const turnId = toTurnId(event.turnId); - if (turnId && (yield* isGitRepoForThread(thread.id))) { + if (turnId && (yield* isVersionControlledRepoForThread(thread.id))) { // Skip if a checkpoint already exists for this turn. A real // (non-placeholder) capture from CheckpointReactor should not // be clobbered, and dispatching a duplicate placeholder for the diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 7a23058fc7..9962682936 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -73,6 +73,8 @@ import { ProjectSetupScriptRunner, type ProjectSetupScriptRunnerShape, } from "./project/Services/ProjectSetupScriptRunner.ts"; +import { VcsCore } from "./vcs/Services/VcsCore.ts"; +import { VcsManager } from "./vcs/Services/VcsManager.ts"; import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; @@ -333,11 +335,21 @@ const buildAppUnderTest = (options?: { ...options?.layers?.gitCore, }), ), + Layer.provide( + Layer.mock(VcsCore)({ + ...options?.layers?.gitCore, + }), + ), Layer.provide( Layer.mock(GitManager)({ ...options?.layers?.gitManager, }), ), + Layer.provide( + Layer.mock(VcsManager)({ + ...options?.layers?.gitManager, + }), + ), Layer.provide( Layer.mock(ProjectSetupScriptRunner)({ runForThread: () => Effect.succeed({ status: "no-script" as const }), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f56edde6fa..8bc88eac86 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -33,6 +33,8 @@ import { GitHubCliLive } from "./git/Layers/GitHubCli"; import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration"; import { TerminalManagerLive } from "./terminal/Layers/Manager"; import { GitManagerLive } from "./git/Layers/GitManager"; +import { JjCoreLive } from "./jj/Layers/JjCore"; +import { JjManagerLive } from "./jj/Layers/JjManager"; import { KeybindingsLive } from "./keybindings"; import { ServerRuntimeStartup, ServerRuntimeStartupLive } from "./serverRuntimeStartup"; import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor"; @@ -48,6 +50,8 @@ import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem" import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner"; import { ObservabilityLive } from "./observability/Layers/Observability"; +import { VcsCoreLive } from "./vcs/Layers/VcsCore"; +import { VcsManagerLive } from "./vcs/Layers/VcsManager"; const PtyAdapterLive = Layer.unwrap( Effect.gen(function* () { @@ -161,7 +165,7 @@ const ProviderLayerLive = Layer.unwrap( const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); -const GitLayerLive = Layer.empty.pipe( +const GitBackendLayerLive = Layer.empty.pipe( Layer.provideMerge( GitManagerLive.pipe( Layer.provideMerge(ProjectSetupScriptRunnerLive), @@ -173,6 +177,33 @@ const GitLayerLive = Layer.empty.pipe( Layer.provideMerge(GitCoreLive), ); +const JjBackendLayerLive = Layer.empty.pipe( + Layer.provideMerge( + JjManagerLive.pipe( + Layer.provideMerge(ProjectSetupScriptRunnerLive), + Layer.provideMerge(JjCoreLive.pipe(Layer.provideMerge(GitCoreLive))), + Layer.provideMerge(GitHubCliLive), + Layer.provideMerge(RoutingTextGenerationLive), + ), + ), + Layer.provideMerge(JjCoreLive.pipe(Layer.provideMerge(GitCoreLive))), +); + +const VcsLayerLive = Layer.empty.pipe( + Layer.provideMerge( + VcsCoreLive.pipe( + Layer.provideMerge(JjBackendLayerLive), + Layer.provideMerge(GitBackendLayerLive), + ), + ), + Layer.provideMerge( + VcsManagerLive.pipe( + Layer.provideMerge(JjBackendLayerLive), + Layer.provideMerge(GitBackendLayerLive), + ), + ), +); + const TerminalLayerLive = TerminalManagerLive.pipe(Layer.provide(PtyAdapterLive)); const WorkspaceLayerLive = Layer.mergeAll( @@ -187,7 +218,9 @@ const WorkspaceLayerLive = Layer.mergeAll( const RuntimeDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(CheckpointingLayerLive), - Layer.provideMerge(GitLayerLive), + Layer.provideMerge(GitBackendLayerLive), + Layer.provideMerge(JjBackendLayerLive), + Layer.provideMerge(VcsLayerLive), Layer.provideMerge(OrchestrationLayerLive), Layer.provideMerge(ProviderLayerLive), Layer.provideMerge(TerminalLayerLive), diff --git a/apps/server/src/vcs/Layers/VcsCore.test.ts b/apps/server/src/vcs/Layers/VcsCore.test.ts new file mode 100644 index 0000000000..37993cb18b --- /dev/null +++ b/apps/server/src/vcs/Layers/VcsCore.test.ts @@ -0,0 +1,117 @@ +import path from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, PlatformError } from "effect"; +import { describe, expect } from "vitest"; + +import { GitCommandError } from "@t3tools/contracts"; +import { ServerConfig } from "../../config.ts"; +import { GitCoreLive } from "../../git/Layers/GitCore.ts"; +import { GitCore } from "../../git/Services/GitCore.ts"; +import { initJjRepo, makeTempDir, writeTextFile } from "../../jj/Layers/JjTestUtils.ts"; +import { JjCoreLive } from "../../jj/Layers/JjCore.ts"; +import { VcsCore } from "../Services/VcsCore.ts"; +import { VcsCoreLive } from "./VcsCore.ts"; + +const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-vcs-core-test-", +}); +const GitCoreTestLayer = GitCoreLive.pipe( + Layer.provide(ServerConfigLayer), + Layer.provide(NodeServices.layer), +); +const JjCoreTestLayer = JjCoreLive.pipe( + Layer.provide(ServerConfigLayer), + Layer.provide(GitCoreTestLayer), + Layer.provideMerge(NodeServices.layer), +); +const VcsCoreTestLayer = VcsCoreLive.pipe( + Layer.provideMerge(GitCoreTestLayer), + Layer.provideMerge(JjCoreTestLayer), +); +const TestLayer = Layer.mergeAll( + NodeServices.layer, + GitCoreTestLayer, + JjCoreTestLayer, + VcsCoreTestLayer, +); + +function git( + cwd: string, + args: ReadonlyArray, +): Effect.Effect { + return Effect.gen(function* () { + const gitCore = yield* GitCore; + const result = yield* gitCore.execute({ + operation: "VcsCore.test.git", + cwd, + args, + timeoutMs: 10_000, + }); + return result.stdout.trim(); + }); +} + +function initGitRepoWithCommit( + cwd: string, +): Effect.Effect< + void, + GitCommandError | PlatformError.PlatformError, + GitCore | FileSystem.FileSystem +> { + return Effect.gen(function* () { + const core = yield* GitCore; + yield* core.initRepo({ cwd }); + yield* git(cwd, ["config", "user.email", "test@example.com"]); + yield* git(cwd, ["config", "user.name", "Test User"]); + yield* writeTextFile(path.join(cwd, "README.md"), "hello\n"); + yield* git(cwd, ["add", "README.md"]); + yield* git(cwd, ["commit", "-m", "Initial commit"]); + }); +} + +it.layer(TestLayer)("VcsCore", (it) => { + describe("routing", () => { + it.effect("routes git repositories through the generic VCS interface", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir("t3-vcs-core-git-"); + yield* initGitRepoWithCommit(cwd); + yield* writeTextFile(path.join(cwd, "git-change.txt"), "git change\n"); + + const vcs = yield* VcsCore; + const status = yield* vcs.statusDetails(cwd); + const branches = yield* vcs.listLocalBranchNames(cwd); + + expect(status.isRepo).toBe(true); + expect(status.branch).toBeTruthy(); + expect(status.hasWorkingTreeChanges).toBe(true); + expect(branches.length).toBeGreaterThan(0); + }), + ); + + it.effect( + "routes jj repositories through the generic VCS interface and tracks new files natively", + () => + Effect.gen(function* () { + const cwd = yield* makeTempDir("t3-vcs-core-jj-"); + yield* initJjRepo(cwd); + yield* writeTextFile(path.join(cwd, "native.txt"), "native jj\n"); + + const vcs = yield* VcsCore; + const context = yield* vcs.prepareCommitContext(cwd); + expect(context?.stagedSummary).toContain("A native.txt"); + + yield* vcs.commit(cwd, "Add native file", ""); + + const status = yield* vcs.statusDetails(cwd); + const branches = yield* vcs.listLocalBranchNames(cwd); + + expect(status.isRepo).toBe(true); + expect(status.branch).toBe("main"); + expect(status.hasWorkingTreeChanges).toBe(false); + expect(branches).toContain("main"); + }), + ); + }); +}); diff --git a/apps/server/src/vcs/Layers/VcsCore.ts b/apps/server/src/vcs/Layers/VcsCore.ts new file mode 100644 index 0000000000..d7fec94538 --- /dev/null +++ b/apps/server/src/vcs/Layers/VcsCore.ts @@ -0,0 +1,55 @@ +import { Effect, Layer } from "effect"; + +import { GitCore } from "../../git/Services/GitCore.ts"; +import { JjCore } from "../../jj/Services/JjCore.ts"; +import { VcsCore, type VcsCoreShape } from "../Services/VcsCore.ts"; +import { detectRepoKind } from "../Utils.ts"; + +export const VcsCoreLive = Layer.effect( + VcsCore, + Effect.gen(function* () { + const gitCore = yield* GitCore; + const jjCore = yield* JjCore; + + const selectCore = (cwd: string) => (detectRepoKind(cwd) === "jj" ? jjCore : gitCore); + + const routed = { + execute: (input) => selectCore(input.cwd).execute(input), + status: (input) => selectCore(input.cwd).status(input), + statusDetails: (cwd) => selectCore(cwd).statusDetails(cwd), + prepareCommitContext: (cwd, filePaths) => + selectCore(cwd).prepareCommitContext(cwd, filePaths), + commit: (cwd, subject, body, options) => selectCore(cwd).commit(cwd, subject, body, options), + pushCurrentBranch: (cwd, fallbackBranch) => + selectCore(cwd).pushCurrentBranch(cwd, fallbackBranch), + readRangeContext: (cwd, baseBranch) => selectCore(cwd).readRangeContext(cwd, baseBranch), + readConfigValue: (cwd, key) => selectCore(cwd).readConfigValue(cwd, key), + isInsideWorkTree: (cwd) => selectCore(cwd).isInsideWorkTree(cwd), + listWorkspaceFiles: (cwd) => selectCore(cwd).listWorkspaceFiles(cwd), + filterIgnoredPaths: (cwd, relativePaths) => + selectCore(cwd).filterIgnoredPaths(cwd, relativePaths), + listBranches: (input) => selectCore(input.cwd).listBranches(input), + pullCurrentBranch: (cwd) => selectCore(cwd).pullCurrentBranch(cwd), + createWorktree: (input) => selectCore(input.cwd).createWorktree(input), + fetchPullRequestBranch: (input) => selectCore(input.cwd).fetchPullRequestBranch(input), + ensureRemote: (input) => selectCore(input.cwd).ensureRemote(input), + fetchRemoteBranch: (input) => selectCore(input.cwd).fetchRemoteBranch(input), + setBranchUpstream: (input) => selectCore(input.cwd).setBranchUpstream(input), + removeWorktree: (input) => selectCore(input.cwd).removeWorktree(input), + renameBranch: (input) => selectCore(input.cwd).renameBranch(input), + createBranch: (input) => selectCore(input.cwd).createBranch(input), + checkoutBranch: (input) => selectCore(input.cwd).checkoutBranch(input), + initRepo: (input) => selectCore(input.cwd).initRepo(input), + listLocalBranchNames: (cwd) => selectCore(cwd).listLocalBranchNames(cwd), + } satisfies VcsCoreShape; + + return routed; + }), +); + +export const VcsCoreFromGitLive = Layer.effect( + VcsCore, + Effect.gen(function* () { + return yield* GitCore; + }), +); diff --git a/apps/server/src/vcs/Layers/VcsManager.test.ts b/apps/server/src/vcs/Layers/VcsManager.test.ts new file mode 100644 index 0000000000..b2ef98d6f6 --- /dev/null +++ b/apps/server/src/vcs/Layers/VcsManager.test.ts @@ -0,0 +1,147 @@ +import fs from "node:fs"; +import path from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { expect } from "vitest"; + +import { type GitManagerShape, GitManager } from "../../git/Services/GitManager.ts"; +import { JjManager } from "../../jj/Services/JjManager.ts"; +import { makeTempDir } from "../../jj/Layers/JjTestUtils.ts"; +import { VcsManager } from "../Services/VcsManager.ts"; +import { VcsManagerLive } from "./VcsManager.ts"; + +function createManagerStub(kind: "git" | "jj") { + const calls = { + status: [] as string[], + preparePullRequestThread: [] as string[], + }; + + const manager: GitManagerShape = { + status: (input) => + Effect.sync(() => { + calls.status.push(input.cwd); + return { + isRepo: true, + hasOriginRemote: kind === "git", + isDefaultBranch: false, + branch: `${kind}-branch`, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + }), + resolvePullRequest: () => + Effect.succeed({ + pullRequest: { + number: kind === "git" ? 7 : 11, + title: `${kind} PR`, + url: `https://github.com/pingdotgg/t3code/pull/${kind === "git" ? 7 : 11}`, + baseBranch: "main", + headBranch: `${kind}-head`, + state: "open" as const, + }, + }), + preparePullRequestThread: (input) => + Effect.sync(() => { + calls.preparePullRequestThread.push(input.cwd); + return { + pullRequest: { + number: kind === "git" ? 7 : 11, + title: `${kind} PR`, + url: `https://github.com/pingdotgg/t3code/pull/${kind === "git" ? 7 : 11}`, + baseBranch: "main", + headBranch: `${kind}-head`, + state: "open" as const, + }, + branch: `${kind}-branch`, + worktreePath: kind === "jj" ? path.join(input.cwd, "workspace") : null, + }; + }), + runStackedAction: () => + Effect.succeed({ + action: "commit", + branch: { + status: "skipped_not_requested" as const, + }, + commit: { + status: "skipped_no_changes" as const, + }, + push: { + status: "skipped_not_requested" as const, + }, + pr: { + status: "skipped_not_requested" as const, + }, + toast: { + title: `${kind} action`, + description: `${kind} action was skipped`, + cta: { + kind: "none" as const, + }, + }, + }), + }; + + return { manager, calls }; +} + +function makeVcsManager() { + const git = createManagerStub("git"); + const jj = createManagerStub("jj"); + const dependencies = Layer.mergeAll( + Layer.succeed(GitManager, git.manager), + Layer.succeed(JjManager, jj.manager), + ); + const layer = VcsManagerLive.pipe(Layer.provide(dependencies)); + + return Effect.gen(function* () { + const vcsManager = yield* VcsManager; + return { vcsManager, gitCalls: git.calls, jjCalls: jj.calls }; + }).pipe(Effect.provide(layer)); +} + +const TestLayer = Layer.mergeAll(NodeServices.layer); + +it.layer(TestLayer)("VcsManager", (it) => { + it.effect("routes manager status to git repositories", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-vcs-manager-git-"); + fs.mkdirSync(path.join(repoDir, ".git")); + const { vcsManager, gitCalls, jjCalls } = yield* makeVcsManager(); + + const status = yield* vcsManager.status({ cwd: repoDir }); + + expect(status.branch).toBe("git-branch"); + expect(gitCalls.status).toEqual([repoDir]); + expect(jjCalls.status).toEqual([]); + }), + ); + + it.effect("routes pull request thread prep to jj repositories", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-vcs-manager-jj-"); + fs.mkdirSync(path.join(repoDir, ".jj")); + const { vcsManager, gitCalls, jjCalls } = yield* makeVcsManager(); + + const result = yield* vcsManager.preparePullRequestThread({ + cwd: repoDir, + reference: "11", + mode: "worktree", + }); + + expect(result.branch).toBe("jj-branch"); + expect(result.worktreePath).toBe(path.join(repoDir, "workspace")); + expect(gitCalls.preparePullRequestThread).toEqual([]); + expect(jjCalls.preparePullRequestThread).toEqual([repoDir]); + }), + ); +}); diff --git a/apps/server/src/vcs/Layers/VcsManager.ts b/apps/server/src/vcs/Layers/VcsManager.ts new file mode 100644 index 0000000000..b7f23497e5 --- /dev/null +++ b/apps/server/src/vcs/Layers/VcsManager.ts @@ -0,0 +1,33 @@ +import { Effect, Layer } from "effect"; + +import { GitManager } from "../../git/Services/GitManager.ts"; +import { JjManager } from "../../jj/Services/JjManager.ts"; +import { VcsManager, type VcsManagerShape } from "../Services/VcsManager.ts"; +import { detectRepoKind } from "../Utils.ts"; + +export const VcsManagerLive = Layer.effect( + VcsManager, + Effect.gen(function* () { + const gitManager = yield* GitManager; + const jjManager = yield* JjManager; + + const selectManager = (cwd: string) => (detectRepoKind(cwd) === "jj" ? jjManager : gitManager); + + const routed = { + status: (input) => selectManager(input.cwd).status(input), + resolvePullRequest: (input) => selectManager(input.cwd).resolvePullRequest(input), + preparePullRequestThread: (input) => selectManager(input.cwd).preparePullRequestThread(input), + runStackedAction: (input, options) => + selectManager(input.cwd).runStackedAction(input, options), + } satisfies VcsManagerShape; + + return routed; + }), +); + +export const VcsManagerFromGitLive = Layer.effect( + VcsManager, + Effect.gen(function* () { + return yield* GitManager; + }), +); diff --git a/apps/server/src/vcs/Services/VcsCore.ts b/apps/server/src/vcs/Services/VcsCore.ts new file mode 100644 index 0000000000..c48c688d3f --- /dev/null +++ b/apps/server/src/vcs/Services/VcsCore.ts @@ -0,0 +1,9 @@ +import { ServiceMap } from "effect"; + +import type { GitCoreShape } from "../../git/Services/GitCore.ts"; + +export interface VcsCoreShape extends GitCoreShape {} + +export class VcsCore extends ServiceMap.Service()( + "t3/vcs/Services/VcsCore", +) {} diff --git a/apps/server/src/vcs/Services/VcsManager.ts b/apps/server/src/vcs/Services/VcsManager.ts new file mode 100644 index 0000000000..eba4473ee4 --- /dev/null +++ b/apps/server/src/vcs/Services/VcsManager.ts @@ -0,0 +1,9 @@ +import { ServiceMap } from "effect"; + +import type { GitManagerShape } from "../../git/Services/GitManager.ts"; + +export interface VcsManagerShape extends GitManagerShape {} + +export class VcsManager extends ServiceMap.Service()( + "t3/vcs/Services/VcsManager", +) {} diff --git a/apps/server/src/vcs/Utils.ts b/apps/server/src/vcs/Utils.ts new file mode 100644 index 0000000000..bf9a49a614 --- /dev/null +++ b/apps/server/src/vcs/Utils.ts @@ -0,0 +1,51 @@ +import { existsSync, statSync } from "node:fs"; +import path from "node:path"; + +export type RepoKind = "git" | "jj"; + +function hasRepositoryMarker(cwd: string, marker: ".git" | ".jj"): boolean { + let current = path.resolve(cwd); + + while (true) { + const candidate = path.join(current, marker); + if (existsSync(candidate)) { + if (marker === ".git") { + return true; + } + + try { + return statSync(candidate).isDirectory(); + } catch { + return false; + } + } + + const parent = path.dirname(current); + if (parent === current) { + return false; + } + current = parent; + } +} + +export function detectRepoKind(cwd: string): RepoKind | null { + if (hasRepositoryMarker(cwd, ".jj")) { + return "jj"; + } + if (hasRepositoryMarker(cwd, ".git")) { + return "git"; + } + return null; +} + +export function isGitRepository(cwd: string): boolean { + return detectRepoKind(cwd) === "git"; +} + +export function isJjRepository(cwd: string): boolean { + return detectRepoKind(cwd) === "jj"; +} + +export function isVersionControlledRepository(cwd: string): boolean { + return detectRepoKind(cwd) !== null; +} diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts index 960cb69bf1..faaedec0fb 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.test.ts @@ -7,6 +7,7 @@ import { Effect, FileSystem, Layer, Path, PlatformError } from "effect"; import { ServerConfig } from "../../config.ts"; import { GitCoreLive } from "../../git/Layers/GitCore.ts"; import { GitCore } from "../../git/Services/GitCore.ts"; +import { VcsCoreFromGitLive } from "../../vcs/Layers/VcsCore.ts"; import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; import { WorkspacePathsLive } from "./WorkspacePaths.ts"; @@ -14,6 +15,7 @@ import { WorkspacePathsLive } from "./WorkspacePaths.ts"; const TestLayer = Layer.empty.pipe( Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), + Layer.provideMerge(VcsCoreFromGitLive.pipe(Layer.provideMerge(GitCoreLive))), Layer.provideMerge(GitCoreLive), Layer.provide( ServerConfig.layerTest(process.cwd(), { diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts index 12af8601ca..5ae39117ce 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.ts @@ -5,13 +5,13 @@ import { Cache, Duration, Effect, Exit, Layer, Option, Path } from "effect"; import { type ProjectEntry } from "@t3tools/contracts"; -import { GitCore } from "../../git/Services/GitCore.ts"; import { WorkspaceEntries, WorkspaceEntriesError, type WorkspaceEntriesShape, } from "../Services/WorkspaceEntries.ts"; import { WorkspacePaths } from "../Services/WorkspacePaths.ts"; +import { VcsCore } from "../../vcs/Services/VcsCore.ts"; const WORKSPACE_CACHE_TTL_MS = 15_000; const WORKSPACE_CACHE_MAX_KEYS = 4; @@ -19,6 +19,7 @@ const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; const WORKSPACE_SCAN_READDIR_CONCURRENCY = 32; const IGNORED_DIRECTORY_NAMES = new Set([ ".git", + ".jj", ".convex", "node_modules", ".next", @@ -219,38 +220,38 @@ const processErrorDetail = (cause: unknown): string => export const makeWorkspaceEntries = Effect.gen(function* () { const path = yield* Path.Path; - const gitOption = yield* Effect.serviceOption(GitCore); + const vcsOption = yield* Effect.serviceOption(VcsCore); const workspacePaths = yield* WorkspacePaths; - const isInsideGitWorkTree = (cwd: string): Effect.Effect => - Option.match(gitOption, { - onSome: (git) => git.isInsideWorkTree(cwd).pipe(Effect.catch(() => Effect.succeed(false))), + const isInsideVersionControlledWorkTree = (cwd: string): Effect.Effect => + Option.match(vcsOption, { + onSome: (vcs) => vcs.isInsideWorkTree(cwd).pipe(Effect.catch(() => Effect.succeed(false))), onNone: () => Effect.succeed(false), }); - const filterGitIgnoredPaths = ( + const filterVcsIgnoredPaths = ( cwd: string, relativePaths: string[], ): Effect.Effect => - Option.match(gitOption, { - onSome: (git) => - git.filterIgnoredPaths(cwd, relativePaths).pipe( + Option.match(vcsOption, { + onSome: (vcs) => + vcs.filterIgnoredPaths(cwd, relativePaths).pipe( Effect.map((paths) => [...paths]), Effect.catch(() => Effect.succeed(relativePaths)), ), onNone: () => Effect.succeed(relativePaths), }); - const buildWorkspaceIndexFromGit = Effect.fn("WorkspaceEntries.buildWorkspaceIndexFromGit")( + const buildWorkspaceIndexFromVcs = Effect.fn("WorkspaceEntries.buildWorkspaceIndexFromVcs")( function* (cwd: string) { - if (Option.isNone(gitOption)) { + if (Option.isNone(vcsOption)) { return null; } - if (!(yield* isInsideGitWorkTree(cwd))) { + if (!(yield* isInsideVersionControlledWorkTree(cwd))) { return null; } - const listedFiles = yield* gitOption.value + const listedFiles = yield* vcsOption.value .listWorkspaceFiles(cwd) .pipe(Effect.catch(() => Effect.succeed(null))); @@ -261,7 +262,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () { const listedPaths = [...listedFiles.paths] .map((entry) => toPosixPath(entry)) .filter((entry) => entry.length > 0 && !isPathInIgnoredDirectory(entry)); - const filePaths = yield* filterGitIgnoredPaths(cwd, listedPaths); + const filePaths = yield* filterVcsIgnoredPaths(cwd, listedPaths); const directorySet = new Set(); for (const filePath of filePaths) { @@ -333,7 +334,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () { const buildWorkspaceIndexFromFilesystem = Effect.fn( "WorkspaceEntries.buildWorkspaceIndexFromFilesystem", )(function* (cwd: string): Effect.fn.Return { - const shouldFilterWithGitIgnore = yield* isInsideGitWorkTree(cwd); + const shouldFilterIgnoredPathsWithVcs = yield* isInsideVersionControlledWorkTree(cwd); let pendingDirectories: string[] = [""]; const entries: SearchableWorkspaceEntry[] = []; @@ -380,8 +381,8 @@ export const makeWorkspaceEntries = Effect.gen(function* () { const candidatePaths = candidateEntriesByDirectory.flatMap((candidateEntries) => candidateEntries.map((entry) => entry.relativePath), ); - const allowedPathSet = shouldFilterWithGitIgnore - ? new Set(yield* filterGitIgnoredPaths(cwd, candidatePaths)) + const allowedPathSet = shouldFilterIgnoredPathsWithVcs + ? new Set(yield* filterVcsIgnoredPaths(cwd, candidatePaths)) : null; for (const candidateEntries of candidateEntriesByDirectory) { @@ -423,9 +424,9 @@ export const makeWorkspaceEntries = Effect.gen(function* () { const buildWorkspaceIndex = Effect.fn("WorkspaceEntries.buildWorkspaceIndex")(function* ( cwd: string, ): Effect.fn.Return { - const gitIndexed = yield* buildWorkspaceIndexFromGit(cwd); - if (gitIndexed) { - return gitIndexed; + const vcsIndexed = yield* buildWorkspaceIndexFromVcs(cwd); + if (vcsIndexed) { + return vcsIndexed; } return yield* buildWorkspaceIndexFromFilesystem(cwd); }); diff --git a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts index fcfd13c912..d5abc0dcf0 100644 --- a/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/Layers/WorkspaceFileSystem.test.ts @@ -4,6 +4,7 @@ import { Effect, FileSystem, Layer, Path } from "effect"; import { ServerConfig } from "../../config.ts"; import { GitCoreLive } from "../../git/Layers/GitCore.ts"; +import { VcsCoreFromGitLive } from "../../vcs/Layers/VcsCore.ts"; import { WorkspaceEntries } from "../Services/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "../Services/WorkspaceFileSystem.ts"; import { WorkspaceEntriesLive } from "./WorkspaceEntries.ts"; @@ -19,6 +20,7 @@ const TestLayer = Layer.empty.pipe( Layer.provideMerge(ProjectLayer), Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))), Layer.provideMerge(WorkspacePathsLive), + Layer.provideMerge(VcsCoreFromGitLive.pipe(Layer.provideMerge(GitCoreLive))), Layer.provideMerge(GitCoreLive), Layer.provide( ServerConfig.layerTest(process.cwd(), { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 33a0518611..923faf00c8 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -25,8 +25,6 @@ import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; import { ServerConfig } from "./config"; -import { GitCore } from "./git/Services/GitCore"; -import { GitManager } from "./git/Services/GitManager"; import { Keybindings } from "./keybindings"; import { Open, resolveAvailableEditors } from "./open"; import { normalizeDispatchCommand } from "./orchestration/Normalizer"; @@ -46,6 +44,8 @@ import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem"; import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths"; import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner"; +import { VcsCore } from "./vcs/Services/VcsCore"; +import { VcsManager } from "./vcs/Services/VcsManager"; const WsRpcLayer = WsRpcGroup.toLayer( Effect.gen(function* () { @@ -54,8 +54,8 @@ const WsRpcLayer = WsRpcGroup.toLayer( const checkpointDiffQuery = yield* CheckpointDiffQuery; const keybindings = yield* Keybindings; const open = yield* Open; - const gitManager = yield* GitManager; - const git = yield* GitCore; + const vcsManager = yield* VcsManager; + const vcs = yield* VcsCore; const terminalManager = yield* TerminalManager; const providerRegistry = yield* ProviderRegistry; const config = yield* ServerConfig; @@ -269,7 +269,7 @@ const WsRpcLayer = WsRpcGroup.toLayer( } if (bootstrap?.prepareWorktree) { - const worktree = yield* git.createWorktree({ + const worktree = yield* vcs.createWorktree({ cwd: bootstrap.prepareWorktree.projectCwd, branch: bootstrap.prepareWorktree.baseBranch, newBranch: bootstrap.prepareWorktree.branch, @@ -560,18 +560,18 @@ const WsRpcLayer = WsRpcGroup.toLayer( "rpc.aggregate": "workspace", }), [WS_METHODS.gitStatus]: (input) => - observeRpcEffect(WS_METHODS.gitStatus, gitManager.status(input), { + observeRpcEffect(WS_METHODS.gitStatus, vcsManager.status(input), { "rpc.aggregate": "git", }), [WS_METHODS.gitPull]: (input) => - observeRpcEffect(WS_METHODS.gitPull, git.pullCurrentBranch(input.cwd), { + observeRpcEffect(WS_METHODS.gitPull, vcs.pullCurrentBranch(input.cwd), { "rpc.aggregate": "git", }), [WS_METHODS.gitRunStackedAction]: (input) => observeRpcStream( WS_METHODS.gitRunStackedAction, Stream.callback((queue) => - gitManager + vcsManager .runStackedAction(input, { actionId: input.actionId, progressReporter: { @@ -588,37 +588,37 @@ const WsRpcLayer = WsRpcGroup.toLayer( { "rpc.aggregate": "git" }, ), [WS_METHODS.gitResolvePullRequest]: (input) => - observeRpcEffect(WS_METHODS.gitResolvePullRequest, gitManager.resolvePullRequest(input), { + observeRpcEffect(WS_METHODS.gitResolvePullRequest, vcsManager.resolvePullRequest(input), { "rpc.aggregate": "git", }), [WS_METHODS.gitPreparePullRequestThread]: (input) => observeRpcEffect( WS_METHODS.gitPreparePullRequestThread, - gitManager.preparePullRequestThread(input), + vcsManager.preparePullRequestThread(input), { "rpc.aggregate": "git" }, ), [WS_METHODS.gitListBranches]: (input) => - observeRpcEffect(WS_METHODS.gitListBranches, git.listBranches(input), { + observeRpcEffect(WS_METHODS.gitListBranches, vcs.listBranches(input), { "rpc.aggregate": "git", }), [WS_METHODS.gitCreateWorktree]: (input) => - observeRpcEffect(WS_METHODS.gitCreateWorktree, git.createWorktree(input), { + observeRpcEffect(WS_METHODS.gitCreateWorktree, vcs.createWorktree(input), { "rpc.aggregate": "git", }), [WS_METHODS.gitRemoveWorktree]: (input) => - observeRpcEffect(WS_METHODS.gitRemoveWorktree, git.removeWorktree(input), { + observeRpcEffect(WS_METHODS.gitRemoveWorktree, vcs.removeWorktree(input), { "rpc.aggregate": "git", }), [WS_METHODS.gitCreateBranch]: (input) => - observeRpcEffect(WS_METHODS.gitCreateBranch, git.createBranch(input), { + observeRpcEffect(WS_METHODS.gitCreateBranch, vcs.createBranch(input), { "rpc.aggregate": "git", }), [WS_METHODS.gitCheckout]: (input) => - observeRpcEffect(WS_METHODS.gitCheckout, Effect.scoped(git.checkoutBranch(input)), { + observeRpcEffect(WS_METHODS.gitCheckout, Effect.scoped(vcs.checkoutBranch(input)), { "rpc.aggregate": "git", }), [WS_METHODS.gitInit]: (input) => - observeRpcEffect(WS_METHODS.gitInit, git.initRepo(input), { "rpc.aggregate": "git" }), + observeRpcEffect(WS_METHODS.gitInit, vcs.initRepo(input), { "rpc.aggregate": "git" }), [WS_METHODS.terminalOpen]: (input) => observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { "rpc.aggregate": "terminal", diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 2f66e063ad..64bd921c19 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -48,7 +48,7 @@ describe("resolveBranchToolbarValue", () => { envMode: "worktree", activeWorktreePath: null, activeThreadBranch: null, - currentGitBranch: "main", + currentBranch: "main", }), ).toBe("main"); }); @@ -59,7 +59,7 @@ describe("resolveBranchToolbarValue", () => { envMode: "worktree", activeWorktreePath: null, activeThreadBranch: "feature/base", - currentGitBranch: "main", + currentBranch: "main", }), ).toBe("feature/base"); }); @@ -70,7 +70,7 @@ describe("resolveBranchToolbarValue", () => { envMode: "local", activeWorktreePath: null, activeThreadBranch: "feature/base", - currentGitBranch: "main", + currentBranch: "main", }), ).toBe("main"); }); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index c9e336bf48..5249bb5a27 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -3,7 +3,7 @@ import { Schema } from "effect"; export { dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, -} from "@t3tools/shared/git"; +} from "@t3tools/shared/vcs"; export const EnvMode = Schema.Literals(["local", "worktree"]); export type EnvMode = typeof EnvMode.Type; @@ -38,13 +38,13 @@ export function resolveBranchToolbarValue(input: { envMode: EnvMode; activeWorktreePath: string | null; activeThreadBranch: string | null; - currentGitBranch: string | null; + currentBranch: string | null; }): string | null { - const { envMode, activeWorktreePath, activeThreadBranch, currentGitBranch } = input; + const { envMode, activeWorktreePath, activeThreadBranch, currentBranch } = input; if (envMode === "worktree" && !activeWorktreePath) { - return activeThreadBranch ?? currentGitBranch; + return activeThreadBranch ?? currentBranch; } - return currentGitBranch ?? activeThreadBranch; + return currentBranch ?? activeThreadBranch; } export function resolveBranchSelectionTarget(input: { diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index e1dbb8756c..eb7959fc24 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -15,11 +15,11 @@ import { } from "react"; import { - gitBranchSearchInfiniteQueryOptions, - gitQueryKeys, - gitStatusQueryOptions, - invalidateGitQueries, -} from "../lib/gitReactQuery"; + invalidateVcsQueries, + vcsBranchSearchInfiniteQueryOptions, + vcsQueryKeys, + vcsStatusQueryOptions, +} from "../lib/vcsReactQuery"; import { readNativeApi } from "../nativeApi"; import { parsePullRequestReference } from "../pullRequestReference"; import { @@ -89,14 +89,14 @@ export function BranchToolbarBranchSelector({ const [branchQuery, setBranchQuery] = useState(""); const deferredBranchQuery = useDeferredValue(branchQuery); - const branchStatusQuery = useQuery(gitStatusQueryOptions(branchCwd)); + const branchStatusQuery = useQuery(vcsStatusQueryOptions(branchCwd)); const trimmedBranchQuery = branchQuery.trim(); const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); useEffect(() => { if (!branchCwd) return; void queryClient.prefetchInfiniteQuery( - gitBranchSearchInfiniteQueryOptions({ cwd: branchCwd, query: "" }), + vcsBranchSearchInfiniteQueryOptions({ cwd: branchCwd, query: "" }), ); }, [branchCwd, queryClient]); @@ -107,7 +107,7 @@ export function BranchToolbarBranchSelector({ isFetchingNextPage, isPending: isBranchesSearchPending, } = useInfiniteQuery( - gitBranchSearchInfiniteQueryOptions({ + vcsBranchSearchInfiniteQueryOptions({ cwd: branchCwd, query: deferredTrimmedBranchQuery, enabled: isBranchMenuOpen, @@ -117,13 +117,13 @@ export function BranchToolbarBranchSelector({ () => branchesSearchData?.pages.flatMap((page) => page.branches) ?? [], [branchesSearchData?.pages], ); - const currentGitBranch = + const currentBranch = branchStatusQuery.data?.branch ?? branches.find((branch) => branch.current)?.name ?? null; const canonicalActiveBranch = resolveBranchToolbarValue({ envMode: effectiveEnvMode, activeWorktreePath, activeThreadBranch, - currentGitBranch, + currentBranch, }); const branchNames = useMemo(() => branches.map((branch) => branch.name), [branches]); const branchByName = useMemo( @@ -188,7 +188,7 @@ export function BranchToolbarBranchSelector({ const runBranchAction = (action: () => Promise) => { startBranchActionTransition(async () => { await action().catch(() => undefined); - await invalidateGitQueries(queryClient).catch(() => undefined); + await invalidateVcsQueries(queryClient).catch(() => undefined); }); }; @@ -229,7 +229,7 @@ export function BranchToolbarBranchSelector({ setOptimisticBranch(selectedBranchName); try { await api.git.checkout({ cwd: selectionTarget.checkoutCwd, branch: branch.name }); - await invalidateGitQueries(queryClient); + await invalidateVcsQueries(queryClient); } catch (error) { toastManager.add({ type: "error", @@ -295,18 +295,12 @@ export function BranchToolbarBranchSelector({ effectiveEnvMode !== "worktree" || activeWorktreePath || activeThreadBranch || - !currentGitBranch + !currentBranch ) { return; } - onSetThreadBranch(currentGitBranch, null); - }, [ - activeThreadBranch, - activeWorktreePath, - currentGitBranch, - effectiveEnvMode, - onSetThreadBranch, - ]); + onSetThreadBranch(currentBranch, null); + }, [activeThreadBranch, activeWorktreePath, currentBranch, effectiveEnvMode, onSetThreadBranch]); const handleOpenChange = useCallback( (open: boolean) => { @@ -316,7 +310,7 @@ export function BranchToolbarBranchSelector({ return; } void queryClient.invalidateQueries({ - queryKey: gitQueryKeys.branches(branchCwd), + queryKey: vcsQueryKeys.branches(branchCwd), }); }, [branchCwd, queryClient], diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index aeab2d083a..ccf108f7da 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -27,7 +27,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { useQuery } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; -import { gitStatusQueryOptions } from "~/lib/gitReactQuery"; +import { vcsStatusQueryOptions } from "~/lib/vcsReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; @@ -1399,7 +1399,7 @@ export default function ChatView({ threadId }: ChatViewProps) { (debouncerState) => ({ isPending: debouncerState.isPending }), ); const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; - const gitStatusQuery = useQuery(gitStatusQueryOptions(gitCwd)); + const vcsStatusQuery = useQuery(vcsStatusQueryOptions(gitCwd)); const keybindings = useServerKeybindings(); const availableEditors = useServerAvailableEditors(); const modelOptionsByProvider = useMemo( @@ -1532,7 +1532,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ? terminalLaunchContext : (storeServerTerminalLaunchContext ?? null); // Default true while loading to avoid toolbar flicker. - const isGitRepo = gitStatusQuery.data?.isRepo ?? true; + const isVersionControlledRepo = vcsStatusQuery.data?.isRepo ?? true; const terminalShortcutLabelOptions = useMemo( () => ({ context: { @@ -3931,7 +3931,7 @@ export default function ChatView({ threadId }: ChatViewProps) { activeThreadId={activeThread.id} activeThreadTitle={activeThread.title} activeProjectName={activeProject?.name} - isGitRepo={isGitRepo} + isGitRepo={isVersionControlledRepo} openInCwd={gitCwd} activeProjectScripts={activeProject?.scripts} preferredScriptId={ @@ -4025,7 +4025,12 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Input bar */} -
+
- {isGitRepo && ( + {isVersionControlledRepo && ( project.id === activeProjectId) : undefined, ); const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; - const gitStatusQuery = useQuery(gitStatusQueryOptions(activeCwd ?? null)); - const isGitRepo = gitStatusQuery.data?.isRepo ?? true; + const vcsStatusQuery = useQuery(vcsStatusQueryOptions(activeCwd ?? null)); + const isVersionControlledRepo = vcsStatusQuery.data?.isRepo ?? true; const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); const orderedTurnDiffSummaries = useMemo( @@ -266,7 +266,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, toTurnCount: activeCheckpointRange?.toTurnCount ?? null, cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope, - enabled: isGitRepo, + enabled: isVersionControlledRepo, }), ); const selectedTurnCheckpointDiff = selectedTurn @@ -548,7 +548,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
Select a thread to inspect turn diffs.
- ) : !isGitRepo ? ( + ) : !isVersionControlledRepo ? (
Turn diffs are unavailable because this project is not a git repository.
diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx index a975a65bbe..244328f793 100644 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -9,8 +9,8 @@ const GIT_CWD = "/repo/project"; const BRANCH_NAME = "feature/toast-scope"; const { - invalidateGitQueriesSpy, - invalidateGitStatusQuerySpy, + invalidateVcsQueriesSpy, + invalidateVcsStatusQuerySpy, runStackedActionMutateAsyncSpy, setThreadBranchSpy, toastAddSpy, @@ -18,8 +18,8 @@ const { toastPromiseSpy, toastUpdateSpy, } = vi.hoisted(() => ({ - invalidateGitQueriesSpy: vi.fn(() => Promise.resolve()), - invalidateGitStatusQuerySpy: vi.fn(() => Promise.resolve()), + invalidateVcsQueriesSpy: vi.fn(() => Promise.resolve()), + invalidateVcsStatusQuerySpy: vi.fn(() => Promise.resolve()), runStackedActionMutateAsyncSpy: vi.fn(() => new Promise(() => undefined)), setThreadBranchSpy: vi.fn(), toastAddSpy: vi.fn(() => "toast-1"), @@ -109,18 +109,17 @@ vi.mock("~/editorPreferences", () => ({ openInPreferredEditor: vi.fn(), })); -vi.mock("~/lib/gitReactQuery", () => ({ - gitBranchesQueryOptions: vi.fn(() => ({ queryKey: ["git-branches"] })), - gitInitMutationOptions: vi.fn(() => ({ __kind: "init" })), - gitMutationKeys: { +vi.mock("~/lib/vcsReactQuery", () => ({ + vcsInitMutationOptions: vi.fn(() => ({ __kind: "init" })), + vcsMutationKeys: { pull: vi.fn(() => ["pull"]), runStackedAction: vi.fn(() => ["run-stacked-action"]), }, - gitPullMutationOptions: vi.fn(() => ({ __kind: "pull" })), - gitRunStackedActionMutationOptions: vi.fn(() => ({ __kind: "run-stacked-action" })), - gitStatusQueryOptions: vi.fn(() => ({ queryKey: ["git-status"] })), - invalidateGitQueries: invalidateGitQueriesSpy, - invalidateGitStatusQuery: invalidateGitStatusQuerySpy, + vcsPullMutationOptions: vi.fn(() => ({ __kind: "pull" })), + vcsRunStackedActionMutationOptions: vi.fn(() => ({ __kind: "run-stacked-action" })), + vcsStatusQueryOptions: vi.fn(() => ({ queryKey: ["git-status"] })), + invalidateVcsQueries: invalidateVcsQueriesSpy, + invalidateVcsStatusQuery: invalidateVcsStatusQuerySpy, })); vi.mock("~/lib/utils", async () => { diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index b4b0b98b0b..2bc5ea80a0 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -360,4 +360,4 @@ export function resolveLiveThreadBranchUpdate(input: { } // Re-export from shared for backwards compatibility in this module's exports -export { resolveAutoFeatureBranchName } from "@t3tools/shared/git"; +export { resolveAutoFeatureBranchName } from "@t3tools/shared/vcs"; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 42882d000d..b00df04a60 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -41,13 +41,13 @@ import { Textarea } from "~/components/ui/textarea"; import { toastManager, type ThreadToastData } from "~/components/ui/toast"; import { openInPreferredEditor } from "~/editorPreferences"; import { - gitInitMutationOptions, - gitMutationKeys, - gitPullMutationOptions, - gitRunStackedActionMutationOptions, - gitStatusQueryOptions, - invalidateGitStatusQuery, -} from "~/lib/gitReactQuery"; + invalidateVcsStatusQuery, + vcsInitMutationOptions, + vcsMutationKeys, + vcsPullMutationOptions, + vcsRunStackedActionMutationOptions, + vcsStatusQueryOptions, +} from "~/lib/vcsReactQuery"; import { newCommandId, randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; import { readNativeApi } from "~/nativeApi"; @@ -114,24 +114,24 @@ function resolveProgressDescription(progress: ActiveGitActionProgress): string | function getMenuActionDisabledReason({ item, - gitStatus, + status, isBusy, hasOriginRemote, }: { item: GitActionMenuItem; - gitStatus: GitStatusResult | null; + status: GitStatusResult | null; isBusy: boolean; hasOriginRemote: boolean; }): string | null { if (!item.disabled) return null; if (isBusy) return "Git action in progress."; - if (!gitStatus) return "Git status is unavailable."; + if (!status) return "Git status is unavailable."; - const hasBranch = gitStatus.branch !== null; - const hasChanges = gitStatus.hasWorkingTreeChanges; - const hasOpenPr = gitStatus.pr?.state === "open"; - const isAhead = gitStatus.aheadCount > 0; - const isBehind = gitStatus.behindCount > 0; + const hasBranch = status.branch !== null; + const hasChanges = status.hasWorkingTreeChanges; + const hasOpenPr = status.pr?.state === "open"; + const isAhead = status.aheadCount > 0; + const isBehind = status.behindCount > 0; if (item.id === "commit") { if (!hasChanges) { @@ -150,7 +150,7 @@ function getMenuActionDisabledReason({ if (isBehind) { return "Branch is behind upstream. Pull/rebase before pushing."; } - if (!gitStatus.hasUpstream && !hasOriginRemote) { + if (!status.hasUpstream && !hasOriginRemote) { return 'Add an "origin" remote before pushing.'; } if (!isAhead) { @@ -168,7 +168,7 @@ function getMenuActionDisabledReason({ if (hasChanges) { return "Commit local changes before creating a PR."; } - if (!gitStatus.hasUpstream && !hasOriginRemote) { + if (!status.hasUpstream && !hasOriginRemote) { return 'Add an "origin" remote before creating a PR.'; } if (!isAhead) { @@ -275,30 +275,30 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions [persistThreadBranchSync], ); - const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); + const { data: vcsStatus = null, error: vcsStatusError } = useQuery(vcsStatusQueryOptions(gitCwd)); // Default to true while loading so we don't flash init controls. - const isRepo = gitStatus?.isRepo ?? true; - const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; - const gitStatusForActions = gitStatus; + const isRepo = vcsStatus?.isRepo ?? true; + const hasOriginRemote = vcsStatus?.hasOriginRemote ?? false; + const vcsStatusForActions = vcsStatus; - const allFiles = gitStatusForActions?.workingTree.files ?? []; + const allFiles = vcsStatusForActions?.workingTree.files ?? []; const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); const allSelected = excludedFiles.size === 0; const noneSelected = selectedFiles.length === 0; - const initMutation = useMutation(gitInitMutationOptions({ cwd: gitCwd, queryClient })); + const initMutation = useMutation(vcsInitMutationOptions({ cwd: gitCwd, queryClient })); const runImmediateGitActionMutation = useMutation( - gitRunStackedActionMutationOptions({ + vcsRunStackedActionMutationOptions({ cwd: gitCwd, queryClient, }), ); - const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); + const pullMutation = useMutation(vcsPullMutationOptions({ cwd: gitCwd, queryClient })); const isRunStackedActionRunning = - useIsMutating({ mutationKey: gitMutationKeys.runStackedAction(gitCwd) }) > 0; - const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(gitCwd) }) > 0; + useIsMutating({ mutationKey: vcsMutationKeys.runStackedAction(gitCwd) }) > 0; + const isPullRunning = useIsMutating({ mutationKey: vcsMutationKeys.pull(gitCwd) }) > 0; const isGitActionRunning = isRunStackedActionRunning || isPullRunning; useEffect(() => { @@ -308,7 +308,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions const branchUpdate = resolveLiveThreadBranchUpdate({ threadBranch: activeServerThread?.branch ?? null, - gitStatus: gitStatusForActions, + gitStatus: vcsStatusForActions, }); if (!branchUpdate) { return; @@ -317,23 +317,23 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions persistThreadBranchSync(branchUpdate.branch); }, [ activeServerThread?.branch, - gitStatusForActions, + vcsStatusForActions, isGitActionRunning, persistThreadBranchSync, ]); const isDefaultBranch = useMemo(() => { - return gitStatusForActions?.isDefaultBranch ?? false; - }, [gitStatusForActions?.isDefaultBranch]); + return vcsStatusForActions?.isDefaultBranch ?? false; + }, [vcsStatusForActions?.isDefaultBranch]); const gitActionMenuItems = useMemo( - () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasOriginRemote), - [gitStatusForActions, hasOriginRemote, isGitActionRunning], + () => buildMenuItems(vcsStatusForActions, isGitActionRunning, hasOriginRemote), + [vcsStatusForActions, hasOriginRemote, isGitActionRunning], ); const quickAction = useMemo( () => - resolveQuickAction(gitStatusForActions, isGitActionRunning, isDefaultBranch, hasOriginRemote), - [gitStatusForActions, hasOriginRemote, isDefaultBranch, isGitActionRunning], + resolveQuickAction(vcsStatusForActions, isGitActionRunning, isDefaultBranch, hasOriginRemote), + [vcsStatusForActions, hasOriginRemote, isDefaultBranch, isGitActionRunning], ); const quickActionDisabledReason = quickAction.disabled ? (quickAction.hint ?? "This action is currently unavailable.") @@ -369,7 +369,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions }); return; } - const prUrl = gitStatusForActions?.pr?.state === "open" ? gitStatusForActions.pr.url : null; + const prUrl = vcsStatusForActions?.pr?.state === "open" ? vcsStatusForActions.pr.url : null; if (!prUrl) { toastManager.add({ type: "error", @@ -386,7 +386,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions data: threadToastData, }); }); - }, [gitStatusForActions, threadToastData]); + }, [vcsStatusForActions, threadToastData]); runGitActionWithToast = useEffectEvent( async ({ @@ -399,7 +399,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions progressToastId, filePaths, }: RunGitActionWithToastInput) => { - const actionStatus = statusOverride ?? gitStatusForActions; + const actionStatus = statusOverride ?? vcsStatusForActions; const actionBranch = actionStatus?.branch ?? null; const actionIsDefaultBranch = featureBranch ? false : isDefaultBranch; const actionCanCommit = @@ -801,7 +801,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions { - if (open) void invalidateGitStatusQuery(queryClient, gitCwd); + if (open) void invalidateVcsStatusQuery(queryClient, gitCwd); }} > { const disabledReason = getMenuActionDisabledReason({ item, - gitStatus: gitStatusForActions, + status: vcsStatusForActions, isBusy: isGitActionRunning, hasOriginRemote, }); @@ -851,22 +851,22 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions ); })} - {gitStatusForActions?.branch === null && ( + {vcsStatusForActions?.branch === null && (

Detached HEAD: create and checkout a branch to enable push and PR actions.

)} - {gitStatusForActions && - gitStatusForActions.branch !== null && - !gitStatusForActions.hasWorkingTreeChanges && - gitStatusForActions.behindCount > 0 && - gitStatusForActions.aheadCount === 0 && ( + {vcsStatusForActions && + vcsStatusForActions.branch !== null && + !vcsStatusForActions.hasWorkingTreeChanges && + vcsStatusForActions.behindCount > 0 && + vcsStatusForActions.aheadCount === 0 && (

Behind upstream. Pull/rebase first.

)} - {gitStatusError && ( -

{gitStatusError.message}

+ {vcsStatusError && ( +

{vcsStatusError.message}

)}
@@ -895,7 +895,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions Branch - {gitStatusForActions?.branch ?? "(detached HEAD)"} + {vcsStatusForActions?.branch ?? "(detached HEAD)"} {isDefaultBranch && ( Warning: default branch @@ -933,7 +933,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions )}
- {!gitStatusForActions || allFiles.length === 0 ? ( + {!vcsStatusForActions || allFiles.length === 0 ? (

none

) : (
diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index 8fa899343e..a8be6a2408 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -4,9 +4,9 @@ import { useDebouncedValue } from "@tanstack/react-pacer"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { - gitPreparePullRequestThreadMutationOptions, - gitResolvePullRequestQueryOptions, -} from "~/lib/gitReactQuery"; + vcsPreparePullRequestThreadMutationOptions, + vcsResolvePullRequestQueryOptions, +} from "~/lib/vcsReactQuery"; import { cn } from "~/lib/utils"; import { parsePullRequestReference } from "~/pullRequestReference"; import { Button } from "./ui/button"; @@ -71,7 +71,7 @@ export function PullRequestThreadDialog({ const parsedReference = parsePullRequestReference(reference); const parsedDebouncedReference = parsePullRequestReference(debouncedReference); const resolvePullRequestQuery = useQuery( - gitResolvePullRequestQueryOptions({ + vcsResolvePullRequestQueryOptions({ cwd, reference: open ? parsedDebouncedReference : null, }), @@ -89,7 +89,7 @@ export function PullRequestThreadDialog({ return cached?.pullRequest ?? null; }, [cwd, parsedReference, queryClient]); const preparePullRequestThreadMutation = useMutation( - gitPreparePullRequestThreadMutationOptions({ cwd, queryClient }), + vcsPreparePullRequestThreadMutationOptions({ cwd, queryClient }), ); const liveResolvedPullRequest = diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index a4f4468279..f6544f4975 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -206,7 +206,26 @@ describe("resolveSidebarNewThreadSeedContext", () => { }), ).toEqual({ branch: "feature/new-draft", - worktreePath: "/repo/worktree", + worktreePath: null, + envMode: "worktree", + }); + }); + + it("does not reuse an active server worktree path for a fresh thread", () => { + expect( + resolveSidebarNewThreadSeedContext({ + projectId: "project-1", + defaultEnvMode: "local", + activeThread: { + projectId: "project-1", + branch: "feature/existing-worktree", + worktreePath: "/repo/worktrees/existing", + }, + activeDraftThread: null, + }), + ).toEqual({ + branch: "feature/existing-worktree", + worktreePath: null, envMode: "worktree", }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index ed151c6da8..c36fc50330 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -181,20 +181,42 @@ export function resolveSidebarNewThreadSeedContext(input: { worktreePath?: string | null; envMode: SidebarNewThreadEnvMode; } { - if (input.activeDraftThread?.projectId === input.projectId) { + const normalizeSeedContext = ( + context: + | { + branch: string | null; + worktreePath: string | null; + envMode: SidebarNewThreadEnvMode; + } + | undefined, + ) => { + if (!context) { + return null; + } + return { - branch: input.activeDraftThread.branch, - worktreePath: input.activeDraftThread.worktreePath, - envMode: input.activeDraftThread.envMode, + branch: context.branch, + // A fresh thread should inherit the branch selection, not attach itself + // to another thread's existing worktree/workspace. + worktreePath: null, + envMode: context.worktreePath ? "worktree" : context.envMode, + } satisfies { + branch: string | null; + worktreePath: string | null; + envMode: SidebarNewThreadEnvMode; }; + }; + + if (input.activeDraftThread?.projectId === input.projectId) { + return normalizeSeedContext(input.activeDraftThread)!; } if (input.activeThread?.projectId === input.projectId) { - return { + return normalizeSeedContext({ branch: input.activeThread.branch, worktreePath: input.activeThread.worktreePath, envMode: input.activeThread.worktreePath ? "worktree" : "local", - }; + })!; } return { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 89ced46454..3e60c99385 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -70,7 +70,7 @@ import { threadJumpIndexFromCommand, threadTraversalDirectionFromCommand, } from "../keybindings"; -import { gitStatusQueryOptions } from "../lib/gitReactQuery"; +import { vcsStatusQueryOptions } from "../lib/vcsReactQuery"; import { readNativeApi } from "../nativeApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; @@ -762,7 +762,7 @@ export default function Sidebar() { }), [platform, routeTerminalOpen], ); - const threadGitTargets = useMemo( + const threadVcsTargets = useMemo( () => sidebarThreads.map((thread) => ({ threadId: thread.id, @@ -771,44 +771,44 @@ export default function Sidebar() { })), [projectCwdById, sidebarThreads], ); - const threadGitStatusCwds = useMemo( + const threadVcsStatusCwds = useMemo( () => [ ...new Set( - threadGitTargets + threadVcsTargets .filter((target) => target.branch !== null) .map((target) => target.cwd) .filter((cwd): cwd is string => cwd !== null), ), ], - [threadGitTargets], + [threadVcsTargets], ); - const threadGitStatusQueries = useQueries({ - queries: threadGitStatusCwds.map((cwd) => ({ - ...gitStatusQueryOptions(cwd), + const threadVcsStatusQueries = useQueries({ + queries: threadVcsStatusCwds.map((cwd) => ({ + ...vcsStatusQueryOptions(cwd), staleTime: 30_000, refetchInterval: 60_000, })), }); const prByThreadId = useMemo(() => { const statusByCwd = new Map(); - for (let index = 0; index < threadGitStatusCwds.length; index += 1) { - const cwd = threadGitStatusCwds[index]; + for (let index = 0; index < threadVcsStatusCwds.length; index += 1) { + const cwd = threadVcsStatusCwds[index]; if (!cwd) continue; - const status = threadGitStatusQueries[index]?.data; + const status = threadVcsStatusQueries[index]?.data; if (status) { statusByCwd.set(cwd, status); } } const map = new Map(); - for (const target of threadGitTargets) { + for (const target of threadVcsTargets) { const status = target.cwd ? statusByCwd.get(target.cwd) : undefined; const branchMatches = target.branch !== null && status?.branch !== null && status?.branch === target.branch; map.set(target.threadId, branchMatches ? (status?.pr ?? null) : null); } return map; - }, [threadGitStatusCwds, threadGitStatusQueries, threadGitTargets]); + }, [threadVcsStatusCwds, threadVcsStatusQueries, threadVcsTargets]); const openPrLink = useCallback((event: MouseEvent, prUrl: string) => { event.preventDefault(); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index d5557b4a96..403dd851e3 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -6,7 +6,7 @@ import { useCallback } from "react"; import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "./useHandleNewThread"; -import { gitRemoveWorktreeMutationOptions } from "../lib/gitReactQuery"; +import { vcsRemoveWorktreeMutationOptions } from "../lib/vcsReactQuery"; import { newCommandId } from "../lib/utils"; import { readNativeApi } from "../nativeApi"; import { useStore } from "../store"; @@ -29,7 +29,7 @@ export function useThreadActions() { const navigate = useNavigate(); const { handleNewThread } = useHandleNewThread(); const queryClient = useQueryClient(); - const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient })); + const removeWorktreeMutation = useMutation(vcsRemoveWorktreeMutationOptions({ queryClient })); const archiveThread = useCallback( async (threadId: ThreadId) => { diff --git a/apps/web/src/lib/vcsReactQuery.ts b/apps/web/src/lib/vcsReactQuery.ts new file mode 100644 index 0000000000..be2e2d5b35 --- /dev/null +++ b/apps/web/src/lib/vcsReactQuery.ts @@ -0,0 +1,16 @@ +export { + gitBranchSearchInfiniteQueryOptions as vcsBranchSearchInfiniteQueryOptions, + gitCheckoutMutationOptions as vcsCheckoutMutationOptions, + gitCreateWorktreeMutationOptions as vcsCreateWorktreeMutationOptions, + gitInitMutationOptions as vcsInitMutationOptions, + gitMutationKeys as vcsMutationKeys, + gitPreparePullRequestThreadMutationOptions as vcsPreparePullRequestThreadMutationOptions, + gitPullMutationOptions as vcsPullMutationOptions, + gitQueryKeys as vcsQueryKeys, + gitRemoveWorktreeMutationOptions as vcsRemoveWorktreeMutationOptions, + gitResolvePullRequestQueryOptions as vcsResolvePullRequestQueryOptions, + gitRunStackedActionMutationOptions as vcsRunStackedActionMutationOptions, + gitStatusQueryOptions as vcsStatusQueryOptions, + invalidateGitQueries as invalidateVcsQueries, + invalidateGitStatusQuery as invalidateVcsStatusQuery, +} from "./gitReactQuery"; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c60856bbe5..af3bb3de2f 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -8,6 +8,7 @@ export * from "./keybindings"; export * from "./server"; export * from "./settings"; export * from "./git"; +export * from "./vcs"; export * from "./orchestration"; export * from "./editor"; export * from "./project"; diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts new file mode 100644 index 0000000000..946a8db2d2 --- /dev/null +++ b/packages/contracts/src/vcs.ts @@ -0,0 +1,133 @@ +import { + GitActionProgressEvent, + GitActionProgressKind, + GitActionProgressPhase, + GitActionProgressStream, + GitBranch, + GitCheckoutInput, + GitCommandError, + GitCreateBranchInput, + GitCreateWorktreeInput, + GitCreateWorktreeResult, + GitInitInput, + GitListBranchesInput, + GitListBranchesResult, + GitManagerError, + GitManagerServiceError, + GitPreparePullRequestThreadInput, + GitPreparePullRequestThreadResult, + GitPullInput, + GitPullRequestRefInput, + GitPullResult, + GitRemoveWorktreeInput, + GitResolvePullRequestResult, + GitRunStackedActionInput, + GitRunStackedActionResult, + GitStackedAction, + GitStatusInput, + GitStatusResult, +} from "./git"; +import type { + GitActionProgressEvent as GitActionProgressEventData, + GitActionProgressKind as GitActionProgressKindData, + GitActionProgressPhase as GitActionProgressPhaseData, + GitActionProgressStream as GitActionProgressStreamData, + GitBranch as GitBranchData, + GitCheckoutInput as GitCheckoutInputData, + GitCreateBranchInput as GitCreateBranchInputData, + GitCreateWorktreeInput as GitCreateWorktreeInputData, + GitCreateWorktreeResult as GitCreateWorktreeResultData, + GitInitInput as GitInitInputData, + GitListBranchesInput as GitListBranchesInputData, + GitListBranchesResult as GitListBranchesResultData, + GitManagerServiceError as GitManagerServiceErrorData, + GitPreparePullRequestThreadInput as GitPreparePullRequestThreadInputData, + GitPreparePullRequestThreadResult as GitPreparePullRequestThreadResultData, + GitPullInput as GitPullInputData, + GitPullRequestRefInput as GitPullRequestRefInputData, + GitPullResult as GitPullResultData, + GitRemoveWorktreeInput as GitRemoveWorktreeInputData, + GitResolvePullRequestResult as GitResolvePullRequestResultData, + GitRunStackedActionInput as GitRunStackedActionInputData, + GitRunStackedActionResult as GitRunStackedActionResultData, + GitStackedAction as GitStackedActionData, + GitStatusInput as GitStatusInputData, + GitStatusResult as GitStatusResultData, +} from "./git"; + +export const VcsActionProgressEvent = GitActionProgressEvent; +export type VcsActionProgressEvent = GitActionProgressEventData; + +export const VcsActionProgressKind = GitActionProgressKind; +export type VcsActionProgressKind = GitActionProgressKindData; + +export const VcsActionProgressPhase = GitActionProgressPhase; +export type VcsActionProgressPhase = GitActionProgressPhaseData; + +export const VcsActionProgressStream = GitActionProgressStream; +export type VcsActionProgressStream = GitActionProgressStreamData; + +export const VcsAction = GitStackedAction; +export type VcsAction = GitStackedActionData; + +export const VcsBranch = GitBranch; +export type VcsBranch = GitBranchData; + +export const VcsCheckoutInput = GitCheckoutInput; +export type VcsCheckoutInput = GitCheckoutInputData; + +export const VcsCreateBranchInput = GitCreateBranchInput; +export type VcsCreateBranchInput = GitCreateBranchInputData; + +export const VcsCreateWorkspaceInput = GitCreateWorktreeInput; +export type VcsCreateWorkspaceInput = GitCreateWorktreeInputData; + +export const VcsCreateWorkspaceResult = GitCreateWorktreeResult; +export type VcsCreateWorkspaceResult = GitCreateWorktreeResultData; + +export const VcsInitInput = GitInitInput; +export type VcsInitInput = GitInitInputData; + +export const VcsListBranchesInput = GitListBranchesInput; +export type VcsListBranchesInput = GitListBranchesInputData; + +export const VcsListBranchesResult = GitListBranchesResult; +export type VcsListBranchesResult = GitListBranchesResultData; + +export const VcsPreparePullRequestThreadInput = GitPreparePullRequestThreadInput; +export type VcsPreparePullRequestThreadInput = GitPreparePullRequestThreadInputData; + +export const VcsPreparePullRequestThreadResult = GitPreparePullRequestThreadResult; +export type VcsPreparePullRequestThreadResult = GitPreparePullRequestThreadResultData; + +export const VcsPullInput = GitPullInput; +export type VcsPullInput = GitPullInputData; + +export const VcsPullRequestRefInput = GitPullRequestRefInput; +export type VcsPullRequestRefInput = GitPullRequestRefInputData; + +export const VcsPullResult = GitPullResult; +export type VcsPullResult = GitPullResultData; + +export const VcsRemoveWorkspaceInput = GitRemoveWorktreeInput; +export type VcsRemoveWorkspaceInput = GitRemoveWorktreeInputData; + +export const VcsResolvePullRequestResult = GitResolvePullRequestResult; +export type VcsResolvePullRequestResult = GitResolvePullRequestResultData; + +export const VcsRunActionInput = GitRunStackedActionInput; +export type VcsRunActionInput = GitRunStackedActionInputData; + +export const VcsRunActionResult = GitRunStackedActionResult; +export type VcsRunActionResult = GitRunStackedActionResultData; + +export const VcsStatusInput = GitStatusInput; +export type VcsStatusInput = GitStatusInputData; + +export const VcsStatusResult = GitStatusResult; +export type VcsStatusResult = GitStatusResultData; + +export { GitCommandError as VcsCommandError, GitManagerError as VcsManagerError }; + +export const VcsManagerServiceError = GitManagerServiceError; +export type VcsManagerServiceError = GitManagerServiceErrorData; diff --git a/packages/shared/package.json b/packages/shared/package.json index b71d52d700..5b94096278 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -12,6 +12,10 @@ "types": "./src/git.ts", "import": "./src/git.ts" }, + "./vcs": { + "types": "./src/vcs.ts", + "import": "./src/vcs.ts" + }, "./logging": { "types": "./src/logging.ts", "import": "./src/logging.ts" diff --git a/packages/shared/src/vcs.ts b/packages/shared/src/vcs.ts new file mode 100644 index 0000000000..df00684cac --- /dev/null +++ b/packages/shared/src/vcs.ts @@ -0,0 +1,7 @@ +export { + dedupeRemoteBranchesWithLocalMatches, + deriveLocalBranchNameFromRemoteRef, + resolveAutoFeatureBranchName, + sanitizeBranchFragment, + sanitizeFeatureBranchName, +} from "./git";