| author | |
| committer | |
| log | d415558915f3a06e92ce7e46acc07fce69374ea6 |
| tree | 74784db432e0a7599872ab67f35e9c6e072b318c |
| parent | 843d6d8678c917a434c63834a98ceb99e5367530 |
| signature | Signed by PGP key B5690EEEBB952194 |
17 files changed, 1481 insertions(+), 2196 deletions(-)
apps/server/integration/OrchestrationEngineHarness.integration.ts+3-1| ... | ... | @@ -24,6 +24,7 @@ import { |
| 24 | 24 | |
| 25 | 25 | import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore.ts"; |
| 26 | 26 | import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts"; |
| 27 | import { GitCoreLive } from "../src/git/Layers/GitCore.ts"; | |
| 27 | 28 | import { GitCore, type GitCoreShape } from "../src/git/Services/GitCore.ts"; |
| 28 | 29 | import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts"; |
| 29 | 30 | import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts"; |
| ... | ... | @@ -284,12 +285,13 @@ export const makeOrchestrationIntegrationHarness = ( |
| 284 | 285 | Layer.provide(AnalyticsService.layerTest), |
| 285 | 286 | ); |
| 286 | 287 | |
| 288 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive)); | |
| 287 | 289 | const runtimeServicesLayer = Layer.mergeAll( |
| 288 | 290 | orchestrationLayer, |
| 289 | 291 | OrchestrationProjectionSnapshotQueryLive, |
| 290 | 292 | ProjectionCheckpointRepositoryLive, |
| 291 | 293 | ProjectionPendingApprovalRepositoryLive, |
| 292 | CheckpointStoreLive, | |
| 294 | checkpointStoreLayer, | |
| 293 | 295 | providerLayer, |
| 294 | 296 | RuntimeReceiptBusLive, |
| 295 | 297 | ); |
apps/server/src/checkpointing/Layers/CheckpointStore.ts+3-6| ... | ... | @@ -15,15 +15,14 @@ import { Effect, Layer, FileSystem, Path } from "effect"; |
| 15 | 15 | |
| 16 | 16 | import { CheckpointInvariantError } from "../Errors.ts"; |
| 17 | 17 | import { GitCommandError } from "../../git/Errors.ts"; |
| 18 | import { GitServiceLive } from "../../git/Layers/GitService.ts"; | |
| 19 | import { GitService } from "../../git/Services/GitService.ts"; | |
| 18 | import { GitCore } from "../../git/Services/GitCore.ts"; | |
| 20 | 19 | import { CheckpointStore, type CheckpointStoreShape } from "../Services/CheckpointStore.ts"; |
| 21 | 20 | import { CheckpointRef } from "@t3tools/contracts"; |
| 22 | 21 | |
| 23 | 22 | const makeCheckpointStore = Effect.gen(function* () { |
| 24 | 23 | const fs = yield* FileSystem.FileSystem; |
| 25 | 24 | const path = yield* Path.Path; |
| 26 | const git = yield* GitService; | |
| 25 | const git = yield* GitCore; | |
| 27 | 26 | |
| 28 | 27 | const resolveHeadCommit = (cwd: string): Effect.Effect<string | null, GitCommandError> => |
| 29 | 28 | git |
| ... | ... | @@ -277,6 +276,4 @@ const makeCheckpointStore = Effect.gen(function* () { |
| 277 | 276 | } satisfies CheckpointStoreShape; |
| 278 | 277 | }); |
| 279 | 278 | |
| 280 | export const CheckpointStoreLive = Layer.effect(CheckpointStore, makeCheckpointStore).pipe( | |
| 281 | Layer.provideMerge(GitServiceLive), | |
| 282 | ); | |
| 279 | export const CheckpointStoreLive = Layer.effect(CheckpointStore, makeCheckpointStore); |
apps/server/src/git/Layers/GitCore.test.ts+244-335| ... | ... | @@ -6,9 +6,7 @@ import { it } from "@effect/vitest"; |
| 6 | 6 | import { Effect, FileSystem, Layer, PlatformError, Scope } from "effect"; |
| 7 | 7 | import { describe, expect, vi } from "vitest"; |
| 8 | 8 | |
| 9 | import { GitServiceLive } from "./GitService.ts"; | |
| 10 | import { GitService, type GitServiceShape } from "../Services/GitService.ts"; | |
| 11 | import { GitCoreLive } from "./GitCore.ts"; | |
| 9 | import { GitCoreLive, makeGitCore } from "./GitCore.ts"; | |
| 12 | 10 | import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; |
| 13 | 11 | import { GitCommandError } from "../Errors.ts"; |
| 14 | 12 | import { type ProcessRunResult, runProcess } from "../../processRunner.ts"; |
| ... | ... | @@ -16,14 +14,12 @@ import { ServerConfig } from "../../config.ts"; |
| 16 | 14 | |
| 17 | 15 | // ── Helpers ── |
| 18 | 16 | |
| 19 | const GitServiceTestLayer = GitServiceLive.pipe(Layer.provide(NodeServices.layer)); | |
| 20 | 17 | const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-core-test-" }); |
| 21 | 18 | const GitCoreTestLayer = GitCoreLive.pipe( |
| 22 | Layer.provide(GitServiceTestLayer), | |
| 23 | 19 | Layer.provide(ServerConfigLayer), |
| 24 | 20 | Layer.provide(NodeServices.layer), |
| 25 | 21 | ); |
| 26 | const TestLayer = Layer.mergeAll(NodeServices.layer, GitServiceTestLayer, GitCoreTestLayer); | |
| 22 | const TestLayer = Layer.mergeAll(NodeServices.layer, GitCoreTestLayer); | |
| 27 | 23 | |
| 28 | 24 | function makeTmpDir( |
| 29 | 25 | prefix = "git-test-", |
| ... | ... | @@ -49,10 +45,10 @@ function git( |
| 49 | 45 | cwd: string, |
| 50 | 46 | args: ReadonlyArray<string>, |
| 51 | 47 | env?: NodeJS.ProcessEnv, |
| 52 | ): Effect.Effect<string, GitCommandError, GitService> { | |
| 48 | ): Effect.Effect<string, GitCommandError, GitCore> { | |
| 53 | 49 | return Effect.gen(function* () { |
| 54 | const gitService = yield* GitService; | |
| 55 | const result = yield* gitService.execute({ | |
| 50 | const gitCore = yield* GitCore; | |
| 51 | const result = yield* gitCore.execute({ | |
| 56 | 52 | operation: "GitCore.test.git", |
| 57 | 53 | cwd, |
| 58 | 54 | args, |
| ... | ... | @@ -88,102 +84,10 @@ function runShellCommand(input: { |
| 88 | 84 | }); |
| 89 | 85 | } |
| 90 | 86 | |
| 91 | const makeIsolatedGitCore = (gitService: GitServiceShape) => | |
| 92 | Effect.promise(async () => { | |
| 93 | const gitServiceLayer = Layer.succeed(GitService, gitService); | |
| 94 | const coreLayer = GitCoreLive.pipe( | |
| 95 | Layer.provide(gitServiceLayer), | |
| 96 | Layer.provide(ServerConfigLayer), | |
| 97 | Layer.provide(NodeServices.layer), | |
| 98 | ); | |
| 99 | const core = await Effect.runPromise(Effect.service(GitCore).pipe(Effect.provide(coreLayer))); | |
| 100 | ||
| 101 | return { | |
| 102 | status: (input) => core.status(input), | |
| 103 | statusDetails: (cwd) => core.statusDetails(cwd), | |
| 104 | prepareCommitContext: (cwd, filePaths?) => core.prepareCommitContext(cwd, filePaths), | |
| 105 | commit: (cwd, subject, body) => core.commit(cwd, subject, body), | |
| 106 | pushCurrentBranch: (cwd, fallbackBranch) => core.pushCurrentBranch(cwd, fallbackBranch), | |
| 107 | pullCurrentBranch: (cwd) => core.pullCurrentBranch(cwd), | |
| 108 | readRangeContext: (cwd, baseBranch) => core.readRangeContext(cwd, baseBranch), | |
| 109 | readConfigValue: (cwd, key) => core.readConfigValue(cwd, key), | |
| 110 | listBranches: (input) => core.listBranches(input), | |
| 111 | createWorktree: (input) => core.createWorktree(input), | |
| 112 | fetchPullRequestBranch: (input) => core.fetchPullRequestBranch(input), | |
| 113 | ensureRemote: (input) => core.ensureRemote(input), | |
| 114 | fetchRemoteBranch: (input) => core.fetchRemoteBranch(input), | |
| 115 | setBranchUpstream: (input) => core.setBranchUpstream(input), | |
| 116 | removeWorktree: (input) => core.removeWorktree(input), | |
| 117 | renameBranch: (input) => core.renameBranch(input), | |
| 118 | createBranch: (input) => core.createBranch(input), | |
| 119 | checkoutBranch: (input) => core.checkoutBranch(input), | |
| 120 | initRepo: (input) => core.initRepo(input), | |
| 121 | listLocalBranchNames: (cwd) => core.listLocalBranchNames(cwd), | |
| 122 | } satisfies GitCoreShape; | |
| 123 | }); | |
| 124 | ||
| 125 | function listGitBranches(input: Parameters<GitCoreShape["listBranches"]>[0]) { | |
| 126 | return Effect.gen(function* () { | |
| 127 | const core = yield* GitCore; | |
| 128 | return yield* core.listBranches(input); | |
| 129 | }); | |
| 130 | } | |
| 131 | ||
| 132 | function initGitRepo(input: Parameters<GitCoreShape["initRepo"]>[0]) { | |
| 133 | return Effect.gen(function* () { | |
| 134 | const core = yield* GitCore; | |
| 135 | return yield* core.initRepo(input); | |
| 136 | }); | |
| 137 | } | |
| 138 | ||
| 139 | function createGitBranch(input: Parameters<GitCoreShape["createBranch"]>[0]) { | |
| 140 | return Effect.gen(function* () { | |
| 141 | const core = yield* GitCore; | |
| 142 | return yield* core.createBranch(input); | |
| 143 | }); | |
| 144 | } | |
| 145 | ||
| 146 | function checkoutGitBranch(input: Parameters<GitCoreShape["checkoutBranch"]>[0]) { | |
| 147 | return Effect.gen(function* () { | |
| 148 | const core = yield* GitCore; | |
| 149 | return yield* core.checkoutBranch(input); | |
| 150 | }); | |
| 151 | } | |
| 152 | ||
| 153 | function createGitWorktree(input: Parameters<GitCoreShape["createWorktree"]>[0]) { | |
| 154 | return Effect.gen(function* () { | |
| 155 | const core = yield* GitCore; | |
| 156 | return yield* core.createWorktree(input); | |
| 157 | }); | |
| 158 | } | |
| 159 | ||
| 160 | function fetchGitPullRequestBranch(input: Parameters<GitCoreShape["fetchPullRequestBranch"]>[0]) { | |
| 161 | return Effect.gen(function* () { | |
| 162 | const core = yield* GitCore; | |
| 163 | return yield* core.fetchPullRequestBranch(input); | |
| 164 | }); | |
| 165 | } | |
| 166 | ||
| 167 | function removeGitWorktree(input: Parameters<GitCoreShape["removeWorktree"]>[0]) { | |
| 168 | return Effect.gen(function* () { | |
| 169 | const core = yield* GitCore; | |
| 170 | return yield* core.removeWorktree(input); | |
| 171 | }); | |
| 172 | } | |
| 173 | ||
| 174 | function renameGitBranch(input: Parameters<GitCoreShape["renameBranch"]>[0]) { | |
| 175 | return Effect.gen(function* () { | |
| 176 | const core = yield* GitCore; | |
| 177 | return yield* core.renameBranch(input); | |
| 178 | }); | |
| 179 | } | |
| 180 | ||
| 181 | function pullGitBranch({ cwd }: { cwd: string }) { | |
| 182 | return Effect.gen(function* () { | |
| 183 | const core = yield* GitCore; | |
| 184 | return yield* core.pullCurrentBranch(cwd); | |
| 185 | }); | |
| 186 | } | |
| 87 | const makeIsolatedGitCore = (executeOverride: GitCoreShape["execute"]) => | |
| 88 | makeGitCore({ executeOverride }).pipe( | |
| 89 | Effect.provide(Layer.provideMerge(ServerConfigLayer, NodeServices.layer)), | |
| 90 | ); | |
| 187 | 91 | |
| 188 | 92 | /** Create a repo with an initial commit so branches work. */ |
| 189 | 93 | function initRepoWithCommit( |
| ... | ... | @@ -191,10 +95,11 @@ function initRepoWithCommit( |
| 191 | 95 | ): Effect.Effect< |
| 192 | 96 | { initialBranch: string }, |
| 193 | 97 | GitCommandError | PlatformError.PlatformError, |
| 194 | GitCore | GitService | FileSystem.FileSystem | |
| 98 | GitCore | FileSystem.FileSystem | |
| 195 | 99 | > { |
| 196 | 100 | return Effect.gen(function* () { |
| 197 | yield* initGitRepo({ cwd }); | |
| 101 | const core = yield* GitCore; | |
| 102 | yield* core.initRepo({ cwd }); | |
| 198 | 103 | yield* git(cwd, ["config", "user.email", "test@test.com"]); |
| 199 | 104 | yield* git(cwd, ["config", "user.name", "Test"]); |
| 200 | 105 | yield* writeTextFile(path.join(cwd, "README.md"), "# test\n"); |
| ... | ... | @@ -214,7 +119,7 @@ function commitWithDate( |
| 214 | 119 | ): Effect.Effect< |
| 215 | 120 | void, |
| 216 | 121 | GitCommandError | PlatformError.PlatformError, |
| 217 | GitService | FileSystem.FileSystem | |
| 122 | GitCore | FileSystem.FileSystem | |
| 218 | 123 | > { |
| 219 | 124 | return Effect.gen(function* () { |
| 220 | 125 | yield* writeTextFile(path.join(cwd, fileName), fileContents); |
| ... | ... | @@ -253,7 +158,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 253 | 158 | it.effect("creates a valid git repo", () => |
| 254 | 159 | Effect.gen(function* () { |
| 255 | 160 | const tmp = yield* makeTmpDir(); |
| 256 | yield* initGitRepo({ cwd: tmp }); | |
| 161 | yield* (yield* GitCore).initRepo({ cwd: tmp }); | |
| 257 | 162 | expect(existsSync(path.join(tmp, ".git"))).toBe(true); |
| 258 | 163 | }), |
| 259 | 164 | ); |
| ... | ... | @@ -262,7 +167,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 262 | 167 | Effect.gen(function* () { |
| 263 | 168 | const tmp = yield* makeTmpDir(); |
| 264 | 169 | yield* initRepoWithCommit(tmp); |
| 265 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 170 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | |
| 266 | 171 | expect(result.isRepo).toBe(true); |
| 267 | 172 | expect(result.hasOriginRemote).toBe(false); |
| 268 | 173 | expect(result.branches.length).toBeGreaterThanOrEqual(1); |
| ... | ... | @@ -276,7 +181,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 276 | 181 | it.effect("returns isRepo: false for non-git directory", () => |
| 277 | 182 | Effect.gen(function* () { |
| 278 | 183 | const tmp = yield* makeTmpDir(); |
| 279 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 184 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | |
| 280 | 185 | expect(result.isRepo).toBe(false); |
| 281 | 186 | expect(result.hasOriginRemote).toBe(false); |
| 282 | 187 | expect(result.branches).toEqual([]); |
| ... | ... | @@ -287,7 +192,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 287 | 192 | Effect.gen(function* () { |
| 288 | 193 | const tmp = yield* makeTmpDir(); |
| 289 | 194 | yield* initRepoWithCommit(tmp); |
| 290 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 195 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | |
| 291 | 196 | const current = result.branches.find((b) => b.current); |
| 292 | 197 | expect(current).toBeDefined(); |
| 293 | 198 | expect(current!.current).toBe(true); |
| ... | ... | @@ -300,7 +205,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 300 | 205 | yield* initRepoWithCommit(tmp); |
| 301 | 206 | yield* git(tmp, ["checkout", "--detach", "HEAD"]); |
| 302 | 207 | |
| 303 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 208 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | |
| 304 | 209 | expect(result.branches.some((branch) => branch.name.startsWith("("))).toBe(false); |
| 305 | 210 | expect(result.branches.some((branch) => branch.current)).toBe(false); |
| 306 | 211 | }), |
| ... | ... | @@ -310,12 +215,12 @@ it.layer(TestLayer)("git integration", (it) => { |
| 310 | 215 | Effect.gen(function* () { |
| 311 | 216 | const tmp = yield* makeTmpDir(); |
| 312 | 217 | yield* initRepoWithCommit(tmp); |
| 313 | const initialBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 218 | const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | |
| 314 | 219 | (branch) => branch.current, |
| 315 | 220 | )!.name; |
| 316 | 221 | |
| 317 | yield* createGitBranch({ cwd: tmp, branch: "older-branch" }); | |
| 318 | yield* checkoutGitBranch({ cwd: tmp, branch: "older-branch" }); | |
| 222 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "older-branch" }); | |
| 223 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "older-branch" }); | |
| 319 | 224 | yield* commitWithDate( |
| 320 | 225 | tmp, |
| 321 | 226 | "older.txt", |
| ... | ... | @@ -324,9 +229,9 @@ it.layer(TestLayer)("git integration", (it) => { |
| 324 | 229 | "older branch change", |
| 325 | 230 | ); |
| 326 | 231 | |
| 327 | yield* checkoutGitBranch({ cwd: tmp, branch: initialBranch }); | |
| 328 | yield* createGitBranch({ cwd: tmp, branch: "newer-branch" }); | |
| 329 | yield* checkoutGitBranch({ cwd: tmp, branch: "newer-branch" }); | |
| 232 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: initialBranch }); | |
| 233 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "newer-branch" }); | |
| 234 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "newer-branch" }); | |
| 330 | 235 | yield* commitWithDate( |
| 331 | 236 | tmp, |
| 332 | 237 | "newer.txt", |
| ... | ... | @@ -336,9 +241,9 @@ it.layer(TestLayer)("git integration", (it) => { |
| 336 | 241 | ); |
| 337 | 242 | |
| 338 | 243 | // Switch away to show current branch is pinned, then remaining branches are recency-sorted. |
| 339 | yield* checkoutGitBranch({ cwd: tmp, branch: "older-branch" }); | |
| 244 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "older-branch" }); | |
| 340 | 245 | |
| 341 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 246 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | |
| 342 | 247 | expect(result.branches[0]!.name).toBe("older-branch"); |
| 343 | 248 | expect(result.branches[1]!.name).toBe("newer-branch"); |
| 344 | 249 | }), |
| ... | ... | @@ -349,7 +254,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 349 | 254 | const tmp = yield* makeTmpDir(); |
| 350 | 255 | const remote = yield* makeTmpDir(); |
| 351 | 256 | yield* initRepoWithCommit(tmp); |
| 352 | const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 257 | const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | |
| 353 | 258 | (branch) => branch.current, |
| 354 | 259 | )!.name; |
| 355 | 260 | |
| ... | ... | @@ -358,8 +263,8 @@ it.layer(TestLayer)("git integration", (it) => { |
| 358 | 263 | yield* git(tmp, ["push", "-u", "origin", defaultBranch]); |
| 359 | 264 | yield* git(tmp, ["remote", "set-head", "origin", defaultBranch]); |
| 360 | 265 | |
| 361 | yield* createGitBranch({ cwd: tmp, branch: "current-branch" }); | |
| 362 | yield* checkoutGitBranch({ cwd: tmp, branch: "current-branch" }); | |
| 266 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "current-branch" }); | |
| 267 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "current-branch" }); | |
| 363 | 268 | yield* commitWithDate( |
| 364 | 269 | tmp, |
| 365 | 270 | "current.txt", |
| ... | ... | @@ -368,9 +273,9 @@ it.layer(TestLayer)("git integration", (it) => { |
| 368 | 273 | "current change", |
| 369 | 274 | ); |
| 370 | 275 | |
| 371 | yield* checkoutGitBranch({ cwd: tmp, branch: defaultBranch }); | |
| 372 | yield* createGitBranch({ cwd: tmp, branch: "newer-branch" }); | |
| 373 | yield* checkoutGitBranch({ cwd: tmp, branch: "newer-branch" }); | |
| 276 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch }); | |
| 277 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "newer-branch" }); | |
| 278 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "newer-branch" }); | |
| 374 | 279 | yield* commitWithDate( |
| 375 | 280 | tmp, |
| 376 | 281 | "newer.txt", |
| ... | ... | @@ -379,9 +284,9 @@ it.layer(TestLayer)("git integration", (it) => { |
| 379 | 284 | "newer change", |
| 380 | 285 | ); |
| 381 | 286 | |
| 382 | yield* checkoutGitBranch({ cwd: tmp, branch: "current-branch" }); | |
| 287 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "current-branch" }); | |
| 383 | 288 | |
| 384 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 289 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | |
| 385 | 290 | expect(result.branches[0]!.name).toBe("current-branch"); |
| 386 | 291 | expect(result.branches[1]!.name).toBe(defaultBranch); |
| 387 | 292 | expect(result.branches[2]!.name).toBe("newer-branch"); |
| ... | ... | @@ -392,10 +297,10 @@ it.layer(TestLayer)("git integration", (it) => { |
| 392 | 297 | Effect.gen(function* () { |
| 393 | 298 | const tmp = yield* makeTmpDir(); |
| 394 | 299 | yield* initRepoWithCommit(tmp); |
| 395 | yield* createGitBranch({ cwd: tmp, branch: "feature-a" }); | |
| 396 | yield* createGitBranch({ cwd: tmp, branch: "feature-b" }); | |
| 300 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-a" }); | |
| 301 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-b" }); | |
| 397 | 302 | |
| 398 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 303 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | |
| 399 | 304 | const names = result.branches.map((b) => b.name); |
| 400 | 305 | expect(names).toContain("feature-a"); |
| 401 | 306 | expect(names).toContain("feature-b"); |
| ... | ... | @@ -406,7 +311,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 406 | 311 | Effect.gen(function* () { |
| 407 | 312 | const tmp = yield* makeTmpDir(); |
| 408 | 313 | yield* initRepoWithCommit(tmp); |
| 409 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 314 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | |
| 410 | 315 | expect(result.branches.every((b) => b.isDefault === false)).toBe(true); |
| 411 | 316 | }), |
| 412 | 317 | ); |
| ... | ... | @@ -418,23 +323,23 @@ it.layer(TestLayer)("git integration", (it) => { |
| 418 | 323 | |
| 419 | 324 | yield* git(remote, ["init", "--bare"]); |
| 420 | 325 | yield* initRepoWithCommit(tmp); |
| 421 | const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 326 | const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | |
| 422 | 327 | (branch) => branch.current, |
| 423 | 328 | )!.name; |
| 424 | 329 | |
| 425 | 330 | yield* git(tmp, ["remote", "add", "origin", remote]); |
| 426 | 331 | yield* git(tmp, ["push", "-u", "origin", defaultBranch]); |
| 427 | 332 | |
| 428 | yield* createGitBranch({ cwd: tmp, branch: "feature/local-only" }); | |
| 333 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/local-only" }); | |
| 429 | 334 | |
| 430 | 335 | const remoteOnlyBranch = "feature/remote-only"; |
| 431 | yield* checkoutGitBranch({ cwd: tmp, branch: defaultBranch }); | |
| 336 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch }); | |
| 432 | 337 | yield* git(tmp, ["checkout", "-b", remoteOnlyBranch]); |
| 433 | 338 | yield* git(tmp, ["push", "-u", "origin", remoteOnlyBranch]); |
| 434 | 339 | yield* git(tmp, ["checkout", defaultBranch]); |
| 435 | 340 | yield* git(tmp, ["branch", "-D", remoteOnlyBranch]); |
| 436 | 341 | |
| 437 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 342 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | |
| 438 | 343 | const firstRemoteIndex = result.branches.findIndex((branch) => branch.isRemote); |
| 439 | 344 | |
| 440 | 345 | expect(result.hasOriginRemote).toBe(true); |
| ... | ... | @@ -466,7 +371,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 466 | 371 | |
| 467 | 372 | yield* git(remote, ["init", "--bare"]); |
| 468 | 373 | yield* initRepoWithCommit(tmp); |
| 469 | const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 374 | const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | |
| 470 | 375 | (branch) => branch.current, |
| 471 | 376 | )!.name; |
| 472 | 377 | |
| ... | ... | @@ -479,7 +384,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 479 | 384 | yield* git(tmp, ["checkout", defaultBranch]); |
| 480 | 385 | yield* git(tmp, ["branch", "-D", remoteOnlyBranch]); |
| 481 | 386 | |
| 482 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 387 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | |
| 483 | 388 | const remoteBranch = result.branches.find( |
| 484 | 389 | (branch) => branch.name === `${remoteName}/${remoteOnlyBranch}`, |
| 485 | 390 | ); |
| ... | ... | @@ -498,11 +403,11 @@ it.layer(TestLayer)("git integration", (it) => { |
| 498 | 403 | Effect.gen(function* () { |
| 499 | 404 | const tmp = yield* makeTmpDir(); |
| 500 | 405 | yield* initRepoWithCommit(tmp); |
| 501 | yield* createGitBranch({ cwd: tmp, branch: "feature" }); | |
| 406 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature" }); | |
| 502 | 407 | |
| 503 | yield* checkoutGitBranch({ cwd: tmp, branch: "feature" }); | |
| 408 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature" }); | |
| 504 | 409 | |
| 505 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 410 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | |
| 506 | 411 | const current = result.branches.find((b) => b.current); |
| 507 | 412 | expect(current!.name).toBe("feature"); |
| 508 | 413 | }), |
| ... | ... | @@ -516,20 +421,20 @@ it.layer(TestLayer)("git integration", (it) => { |
| 516 | 421 | yield* git(remote, ["init", "--bare"]); |
| 517 | 422 | |
| 518 | 423 | yield* initRepoWithCommit(source); |
| 519 | const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( | |
| 424 | const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( | |
| 520 | 425 | (branch) => branch.current, |
| 521 | 426 | )!.name; |
| 522 | 427 | yield* git(source, ["remote", "add", "origin", remote]); |
| 523 | 428 | yield* git(source, ["push", "-u", "origin", defaultBranch]); |
| 524 | 429 | |
| 525 | 430 | const featureBranch = "feature-behind"; |
| 526 | yield* createGitBranch({ cwd: source, branch: featureBranch }); | |
| 527 | yield* checkoutGitBranch({ cwd: source, branch: featureBranch }); | |
| 431 | yield* (yield* GitCore).createBranch({ cwd: source, branch: featureBranch }); | |
| 432 | yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch }); | |
| 528 | 433 | yield* writeTextFile(path.join(source, "feature.txt"), "feature base\n"); |
| 529 | 434 | yield* git(source, ["add", "feature.txt"]); |
| 530 | 435 | yield* git(source, ["commit", "-m", "feature base"]); |
| 531 | 436 | yield* git(source, ["push", "-u", "origin", featureBranch]); |
| 532 | yield* checkoutGitBranch({ cwd: source, branch: defaultBranch }); | |
| 437 | yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: defaultBranch }); | |
| 533 | 438 | |
| 534 | 439 | yield* git(clone, ["clone", remote, "."]); |
| 535 | 440 | yield* git(clone, ["config", "user.email", "test@test.com"]); |
| ... | ... | @@ -540,7 +445,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 540 | 445 | yield* git(clone, ["commit", "-m", "remote feature update"]); |
| 541 | 446 | yield* git(clone, ["push", "origin", featureBranch]); |
| 542 | 447 | |
| 543 | yield* checkoutGitBranch({ cwd: source, branch: featureBranch }); | |
| 448 | yield* (yield* GitCore).checkoutBranch({ cwd: source, branch: featureBranch }); | |
| 544 | 449 | const core = yield* GitCore; |
| 545 | 450 | yield* Effect.promise(() => |
| 546 | 451 | vi.waitFor(async () => { |
| ... | ... | @@ -560,7 +465,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 560 | 465 | yield* git(remote, ["init", "--bare"]); |
| 561 | 466 | |
| 562 | 467 | yield* initRepoWithCommit(source); |
| 563 | const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( | |
| 468 | const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( | |
| 564 | 469 | (branch) => branch.current, |
| 565 | 470 | )!.name; |
| 566 | 471 | yield* git(source, ["remote", "add", "origin", remote]); |
| ... | ... | @@ -575,23 +480,21 @@ it.layer(TestLayer)("git integration", (it) => { |
| 575 | 480 | yield* git(source, ["push", "-u", "origin", featureBranch]); |
| 576 | 481 | yield* git(source, ["checkout", defaultBranch]); |
| 577 | 482 | |
| 578 | const realGitService = yield* GitService; | |
| 483 | const realGitCore = yield* GitCore; | |
| 579 | 484 | let refreshFetchAttempts = 0; |
| 580 | const core = yield* makeIsolatedGitCore({ | |
| 581 | execute: (input) => { | |
| 582 | if (input.args[0] === "fetch") { | |
| 583 | refreshFetchAttempts += 1; | |
| 584 | return Effect.fail( | |
| 585 | new GitCommandError({ | |
| 586 | operation: "git.test.refreshFailure", | |
| 587 | command: `git ${input.args.join(" ")}`, | |
| 588 | cwd: input.cwd, | |
| 589 | detail: "simulated fetch timeout", | |
| 590 | }), | |
| 591 | ); | |
| 592 | } | |
| 593 | return realGitService.execute(input); | |
| 594 | }, | |
| 485 | const core = yield* makeIsolatedGitCore((input) => { | |
| 486 | if (input.args[0] === "fetch") { | |
| 487 | refreshFetchAttempts += 1; | |
| 488 | return Effect.fail( | |
| 489 | new GitCommandError({ | |
| 490 | operation: "git.test.refreshFailure", | |
| 491 | command: `git ${input.args.join(" ")}`, | |
| 492 | cwd: input.cwd, | |
| 493 | detail: "simulated fetch timeout", | |
| 494 | }), | |
| 495 | ); | |
| 496 | } | |
| 497 | return realGitCore.execute(input); | |
| 595 | 498 | }); |
| 596 | 499 | yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); |
| 597 | 500 | yield* Effect.promise(() => |
| ... | ... | @@ -610,7 +513,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 610 | 513 | yield* git(remote, ["init", "--bare"]); |
| 611 | 514 | |
| 612 | 515 | yield* initRepoWithCommit(source); |
| 613 | const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( | |
| 516 | const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( | |
| 614 | 517 | (branch) => branch.current, |
| 615 | 518 | )!.name; |
| 616 | 519 | yield* git(source, ["remote", "add", "origin", remote]); |
| ... | ... | @@ -624,16 +527,14 @@ it.layer(TestLayer)("git integration", (it) => { |
| 624 | 527 | yield* git(source, ["push", "-u", "origin", featureBranch]); |
| 625 | 528 | yield* git(source, ["checkout", defaultBranch]); |
| 626 | 529 | |
| 627 | const realGitService = yield* GitService; | |
| 530 | const realGitCore = yield* GitCore; | |
| 628 | 531 | let fetchArgs: readonly string[] | null = null; |
| 629 | const core = yield* makeIsolatedGitCore({ | |
| 630 | execute: (input) => { | |
| 631 | if (input.args[0] === "fetch") { | |
| 632 | fetchArgs = [...input.args]; | |
| 633 | return Effect.succeed({ code: 0, stdout: "", stderr: "" }); | |
| 634 | } | |
| 635 | return realGitService.execute(input); | |
| 636 | }, | |
| 532 | const core = yield* makeIsolatedGitCore((input) => { | |
| 533 | if (input.args[0] === "fetch") { | |
| 534 | fetchArgs = [...input.args]; | |
| 535 | return Effect.succeed({ code: 0, stdout: "", stderr: "" }); | |
| 536 | } | |
| 537 | return realGitCore.execute(input); | |
| 637 | 538 | }); |
| 638 | 539 | yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); |
| 639 | 540 | yield* Effect.promise(() => |
| ... | ... | @@ -660,7 +561,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 660 | 561 | yield* git(remote, ["init", "--bare"]); |
| 661 | 562 | |
| 662 | 563 | yield* initRepoWithCommit(source); |
| 663 | const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( | |
| 564 | const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( | |
| 664 | 565 | (branch) => branch.current, |
| 665 | 566 | )!.name; |
| 666 | 567 | yield* git(source, ["remote", "add", "origin", remote]); |
| ... | ... | @@ -674,22 +575,20 @@ it.layer(TestLayer)("git integration", (it) => { |
| 674 | 575 | yield* git(source, ["push", "-u", "origin", featureBranch]); |
| 675 | 576 | yield* git(source, ["checkout", defaultBranch]); |
| 676 | 577 | |
| 677 | const realGitService = yield* GitService; | |
| 578 | const realGitCore = yield* GitCore; | |
| 678 | 579 | let fetchStarted = false; |
| 679 | 580 | let releaseFetch!: () => void; |
| 680 | 581 | const waitForReleasePromise = new Promise<void>((resolve) => { |
| 681 | 582 | releaseFetch = resolve; |
| 682 | 583 | }); |
| 683 | const core = yield* makeIsolatedGitCore({ | |
| 684 | execute: (input) => { | |
| 685 | if (input.args[0] === "fetch") { | |
| 686 | fetchStarted = true; | |
| 687 | return Effect.promise(() => | |
| 688 | waitForReleasePromise.then(() => ({ code: 0, stdout: "", stderr: "" })), | |
| 689 | ); | |
| 690 | } | |
| 691 | return realGitService.execute(input); | |
| 692 | }, | |
| 584 | const core = yield* makeIsolatedGitCore((input) => { | |
| 585 | if (input.args[0] === "fetch") { | |
| 586 | fetchStarted = true; | |
| 587 | return Effect.promise(() => | |
| 588 | waitForReleasePromise.then(() => ({ code: 0, stdout: "", stderr: "" })), | |
| 589 | ); | |
| 590 | } | |
| 591 | return realGitCore.execute(input); | |
| 693 | 592 | }); |
| 694 | 593 | yield* core.checkoutBranch({ cwd: source, branch: featureBranch }); |
| 695 | 594 | yield* Effect.promise(() => |
| ... | ... | @@ -706,7 +605,9 @@ it.layer(TestLayer)("git integration", (it) => { |
| 706 | 605 | Effect.gen(function* () { |
| 707 | 606 | const tmp = yield* makeTmpDir(); |
| 708 | 607 | yield* initRepoWithCommit(tmp); |
| 709 | const result = yield* Effect.result(checkoutGitBranch({ cwd: tmp, branch: "nonexistent" })); | |
| 608 | const result = yield* Effect.result( | |
| 609 | (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "nonexistent" }), | |
| 610 | ); | |
| 710 | 611 | expect(result._tag).toBe("Failure"); |
| 711 | 612 | }), |
| 712 | 613 | ); |
| ... | ... | @@ -718,16 +619,16 @@ it.layer(TestLayer)("git integration", (it) => { |
| 718 | 619 | yield* git(remote, ["init", "--bare"]); |
| 719 | 620 | |
| 720 | 621 | yield* initRepoWithCommit(source); |
| 721 | const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( | |
| 622 | const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( | |
| 722 | 623 | (branch) => branch.current, |
| 723 | 624 | )!.name; |
| 724 | 625 | yield* git(source, ["remote", "add", "origin", remote]); |
| 725 | 626 | yield* git(source, ["push", "-u", "origin", defaultBranch]); |
| 726 | 627 | |
| 727 | yield* createGitBranch({ cwd: source, branch: "feature" }); | |
| 628 | yield* (yield* GitCore).createBranch({ cwd: source, branch: "feature" }); | |
| 728 | 629 | |
| 729 | 630 | const checkoutResult = yield* Effect.result( |
| 730 | checkoutGitBranch({ cwd: source, branch: "origin/feature" }), | |
| 631 | (yield* GitCore).checkoutBranch({ cwd: source, branch: "origin/feature" }), | |
| 731 | 632 | ); |
| 732 | 633 | expect(checkoutResult._tag).toBe("Failure"); |
| 733 | 634 | expect(yield* git(source, ["branch", "--show-current"])).toBe(defaultBranch); |
| ... | ... | @@ -743,7 +644,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 743 | 644 | yield* git(remote, ["init", "--bare"]); |
| 744 | 645 | |
| 745 | 646 | yield* initRepoWithCommit(source); |
| 746 | const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( | |
| 647 | const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( | |
| 747 | 648 | (branch) => branch.current, |
| 748 | 649 | )!.name; |
| 749 | 650 | yield* git(source, ["remote", "add", remoteName, remote]); |
| ... | ... | @@ -757,7 +658,10 @@ it.layer(TestLayer)("git integration", (it) => { |
| 757 | 658 | yield* git(source, ["checkout", defaultBranch]); |
| 758 | 659 | yield* git(source, ["branch", "-D", featureBranch]); |
| 759 | 660 | |
| 760 | yield* checkoutGitBranch({ cwd: source, branch: `${remoteName}/${featureBranch}` }); | |
| 661 | yield* (yield* GitCore).checkoutBranch({ | |
| 662 | cwd: source, | |
| 663 | branch: `${remoteName}/${featureBranch}`, | |
| 664 | }); | |
| 761 | 665 | |
| 762 | 666 | expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature"); |
| 763 | 667 | }), |
| ... | ... | @@ -772,9 +676,9 @@ it.layer(TestLayer)("git integration", (it) => { |
| 772 | 676 | yield* git(remote, ["init", "--bare"]); |
| 773 | 677 | |
| 774 | 678 | yield* initRepoWithCommit(source); |
| 775 | const defaultBranch = (yield* listGitBranches({ cwd: source })).branches.find( | |
| 776 | (branch) => branch.current, | |
| 777 | )!.name; | |
| 679 | const defaultBranch = (yield* (yield* GitCore).listBranches({ | |
| 680 | cwd: source, | |
| 681 | })).branches.find((branch) => branch.current)!.name; | |
| 778 | 682 | yield* git(source, ["remote", "add", "origin", remote]); |
| 779 | 683 | yield* git(source, ["push", "-u", "origin", defaultBranch]); |
| 780 | 684 | |
| ... | ... | @@ -782,7 +686,10 @@ it.layer(TestLayer)("git integration", (it) => { |
| 782 | 686 | // would attempt to create an already-existing local branch. |
| 783 | 687 | yield* git(source, ["branch", "--unset-upstream"]); |
| 784 | 688 | |
| 785 | yield* checkoutGitBranch({ cwd: source, branch: `origin/${defaultBranch}` }); | |
| 689 | yield* (yield* GitCore).checkoutBranch({ | |
| 690 | cwd: source, | |
| 691 | branch: `origin/${defaultBranch}`, | |
| 692 | }); | |
| 786 | 693 | |
| 787 | 694 | const core = yield* GitCore; |
| 788 | 695 | const status = yield* core.statusDetails(source); |
| ... | ... | @@ -794,7 +701,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 794 | 701 | Effect.gen(function* () { |
| 795 | 702 | const tmp = yield* makeTmpDir(); |
| 796 | 703 | yield* initRepoWithCommit(tmp); |
| 797 | yield* createGitBranch({ cwd: tmp, branch: "other" }); | |
| 704 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "other" }); | |
| 798 | 705 | |
| 799 | 706 | // Create a conflicting change: modify README on current branch |
| 800 | 707 | yield* writeTextFile(path.join(tmp, "README.md"), "modified\n"); |
| ... | ... | @@ -802,22 +709,24 @@ it.layer(TestLayer)("git integration", (it) => { |
| 802 | 709 | |
| 803 | 710 | // First, checkout other branch cleanly |
| 804 | 711 | yield* git(tmp, ["stash"]); |
| 805 | yield* checkoutGitBranch({ cwd: tmp, branch: "other" }); | |
| 712 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "other" }); | |
| 806 | 713 | yield* writeTextFile(path.join(tmp, "README.md"), "other content\n"); |
| 807 | 714 | yield* git(tmp, ["add", "."]); |
| 808 | 715 | yield* git(tmp, ["commit", "-m", "other change"]); |
| 809 | 716 | |
| 810 | 717 | // Go back to default branch |
| 811 | const defaultBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 718 | const defaultBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | |
| 812 | 719 | (b) => !b.current, |
| 813 | 720 | )!.name; |
| 814 | yield* checkoutGitBranch({ cwd: tmp, branch: defaultBranch }); | |
| 721 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: defaultBranch }); | |
| 815 | 722 | |
| 816 | 723 | // Make uncommitted changes to the same file |
| 817 | 724 | yield* writeTextFile(path.join(tmp, "README.md"), "conflicting local\n"); |
| 818 | 725 | |
| 819 | 726 | // Checkout should fail due to uncommitted changes |
| 820 | const result = yield* Effect.result(checkoutGitBranch({ cwd: tmp, branch: "other" })); | |
| 727 | const result = yield* Effect.result( | |
| 728 | (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "other" }), | |
| 729 | ); | |
| 821 | 730 | expect(result._tag).toBe("Failure"); |
| 822 | 731 | }), |
| 823 | 732 | ); |
| ... | ... | @@ -830,9 +739,9 @@ it.layer(TestLayer)("git integration", (it) => { |
| 830 | 739 | Effect.gen(function* () { |
| 831 | 740 | const tmp = yield* makeTmpDir(); |
| 832 | 741 | yield* initRepoWithCommit(tmp); |
| 833 | yield* createGitBranch({ cwd: tmp, branch: "new-feature" }); | |
| 742 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "new-feature" }); | |
| 834 | 743 | |
| 835 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 744 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | |
| 836 | 745 | expect(result.branches.some((b) => b.name === "new-feature")).toBe(true); |
| 837 | 746 | }), |
| 838 | 747 | ); |
| ... | ... | @@ -841,8 +750,10 @@ it.layer(TestLayer)("git integration", (it) => { |
| 841 | 750 | Effect.gen(function* () { |
| 842 | 751 | const tmp = yield* makeTmpDir(); |
| 843 | 752 | yield* initRepoWithCommit(tmp); |
| 844 | yield* createGitBranch({ cwd: tmp, branch: "dupe" }); | |
| 845 | const result = yield* Effect.result(createGitBranch({ cwd: tmp, branch: "dupe" })); | |
| 753 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "dupe" }); | |
| 754 | const result = yield* Effect.result( | |
| 755 | (yield* GitCore).createBranch({ cwd: tmp, branch: "dupe" }), | |
| 756 | ); | |
| 846 | 757 | expect(result._tag).toBe("Failure"); |
| 847 | 758 | }), |
| 848 | 759 | ); |
| ... | ... | @@ -855,10 +766,10 @@ it.layer(TestLayer)("git integration", (it) => { |
| 855 | 766 | Effect.gen(function* () { |
| 856 | 767 | const tmp = yield* makeTmpDir(); |
| 857 | 768 | yield* initRepoWithCommit(tmp); |
| 858 | yield* createGitBranch({ cwd: tmp, branch: "feature/old-name" }); | |
| 859 | yield* checkoutGitBranch({ cwd: tmp, branch: "feature/old-name" }); | |
| 769 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/old-name" }); | |
| 770 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/old-name" }); | |
| 860 | 771 | |
| 861 | const renamed = yield* renameGitBranch({ | |
| 772 | const renamed = yield* (yield* GitCore).renameBranch({ | |
| 862 | 773 | cwd: tmp, |
| 863 | 774 | oldBranch: "feature/old-name", |
| 864 | 775 | newBranch: "feature/new-name", |
| ... | ... | @@ -866,7 +777,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 866 | 777 | |
| 867 | 778 | expect(renamed.branch).toBe("feature/new-name"); |
| 868 | 779 | |
| 869 | const branches = yield* listGitBranches({ cwd: tmp }); | |
| 780 | const branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); | |
| 870 | 781 | expect(branches.branches.some((branch) => branch.name === "feature/old-name")).toBe(false); |
| 871 | 782 | const current = branches.branches.find((branch) => branch.current); |
| 872 | 783 | expect(current?.name).toBe("feature/new-name"); |
| ... | ... | @@ -877,9 +788,11 @@ it.layer(TestLayer)("git integration", (it) => { |
| 877 | 788 | Effect.gen(function* () { |
| 878 | 789 | const tmp = yield* makeTmpDir(); |
| 879 | 790 | yield* initRepoWithCommit(tmp); |
| 880 | const current = (yield* listGitBranches({ cwd: tmp })).branches.find((b) => b.current)!; | |
| 791 | const current = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | |
| 792 | (b) => b.current, | |
| 793 | )!; | |
| 881 | 794 | |
| 882 | const renamed = yield* renameGitBranch({ | |
| 795 | const renamed = yield* (yield* GitCore).renameBranch({ | |
| 883 | 796 | cwd: tmp, |
| 884 | 797 | oldBranch: current.name, |
| 885 | 798 | newBranch: current.name, |
| ... | ... | @@ -893,18 +806,18 @@ it.layer(TestLayer)("git integration", (it) => { |
| 893 | 806 | Effect.gen(function* () { |
| 894 | 807 | const tmp = yield* makeTmpDir(); |
| 895 | 808 | yield* initRepoWithCommit(tmp); |
| 896 | yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session" }); | |
| 897 | yield* createGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); | |
| 898 | yield* checkoutGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); | |
| 809 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session" }); | |
| 810 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/tmp-working" }); | |
| 811 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "t3code/tmp-working" }); | |
| 899 | 812 | |
| 900 | const renamed = yield* renameGitBranch({ | |
| 813 | const renamed = yield* (yield* GitCore).renameBranch({ | |
| 901 | 814 | cwd: tmp, |
| 902 | 815 | oldBranch: "t3code/tmp-working", |
| 903 | 816 | newBranch: "t3code/feat/session", |
| 904 | 817 | }); |
| 905 | 818 | |
| 906 | 819 | expect(renamed.branch).toBe("t3code/feat/session-1"); |
| 907 | const branches = yield* listGitBranches({ cwd: tmp }); | |
| 820 | const branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); | |
| 908 | 821 | expect(branches.branches.some((branch) => branch.name === "t3code/feat/session")).toBe( |
| 909 | 822 | true, |
| 910 | 823 | ); |
| ... | ... | @@ -920,12 +833,12 @@ it.layer(TestLayer)("git integration", (it) => { |
| 920 | 833 | Effect.gen(function* () { |
| 921 | 834 | const tmp = yield* makeTmpDir(); |
| 922 | 835 | yield* initRepoWithCommit(tmp); |
| 923 | yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session" }); | |
| 924 | yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session-1" }); | |
| 925 | yield* createGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); | |
| 926 | yield* checkoutGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); | |
| 836 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session" }); | |
| 837 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/feat/session-1" }); | |
| 838 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "t3code/tmp-working" }); | |
| 839 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "t3code/tmp-working" }); | |
| 927 | 840 | |
| 928 | const renamed = yield* renameGitBranch({ | |
| 841 | const renamed = yield* (yield* GitCore).renameBranch({ | |
| 929 | 842 | cwd: tmp, |
| 930 | 843 | oldBranch: "t3code/tmp-working", |
| 931 | 844 | newBranch: "t3code/feat/session", |
| ... | ... | @@ -939,18 +852,16 @@ it.layer(TestLayer)("git integration", (it) => { |
| 939 | 852 | Effect.gen(function* () { |
| 940 | 853 | const tmp = yield* makeTmpDir(); |
| 941 | 854 | yield* initRepoWithCommit(tmp); |
| 942 | yield* createGitBranch({ cwd: tmp, branch: "feature/old-name" }); | |
| 943 | yield* checkoutGitBranch({ cwd: tmp, branch: "feature/old-name" }); | |
| 855 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/old-name" }); | |
| 856 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/old-name" }); | |
| 944 | 857 | |
| 945 | const realGitService = yield* GitService; | |
| 858 | const realGitCore = yield* GitCore; | |
| 946 | 859 | let renameArgs: ReadonlyArray<string> | null = null; |
| 947 | const core = yield* makeIsolatedGitCore({ | |
| 948 | execute: (input) => { | |
| 949 | if (input.args[0] === "branch" && input.args[1] === "-m") { | |
| 950 | renameArgs = [...input.args]; | |
| 951 | } | |
| 952 | return realGitService.execute(input); | |
| 953 | }, | |
| 860 | const core = yield* makeIsolatedGitCore((input) => { | |
| 861 | if (input.args[0] === "branch" && input.args[1] === "-m") { | |
| 862 | renameArgs = [...input.args]; | |
| 863 | } | |
| 864 | return realGitCore.execute(input); | |
| 954 | 865 | }); |
| 955 | 866 | |
| 956 | 867 | const renamed = yield* core.renameBranch({ |
| ... | ... | @@ -974,11 +885,11 @@ it.layer(TestLayer)("git integration", (it) => { |
| 974 | 885 | yield* initRepoWithCommit(tmp); |
| 975 | 886 | |
| 976 | 887 | const wtPath = path.join(tmp, "worktree-out"); |
| 977 | const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 888 | const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | |
| 978 | 889 | (b) => b.current, |
| 979 | 890 | )!.name; |
| 980 | 891 | |
| 981 | const result = yield* createGitWorktree({ | |
| 892 | const result = yield* (yield* GitCore).createWorktree({ | |
| 982 | 893 | cwd: tmp, |
| 983 | 894 | branch: currentBranch, |
| 984 | 895 | newBranch: "wt-branch", |
| ... | ... | @@ -991,7 +902,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 991 | 902 | expect(existsSync(path.join(wtPath, "README.md"))).toBe(true); |
| 992 | 903 | |
| 993 | 904 | // Clean up worktree before tmp dir disposal |
| 994 | yield* removeGitWorktree({ cwd: tmp, path: wtPath }); | |
| 905 | yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); | |
| 995 | 906 | }), |
| 996 | 907 | ); |
| 997 | 908 | |
| ... | ... | @@ -1001,11 +912,11 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1001 | 912 | yield* initRepoWithCommit(tmp); |
| 1002 | 913 | |
| 1003 | 914 | const wtPath = path.join(tmp, "wt-check-dir"); |
| 1004 | const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 915 | const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | |
| 1005 | 916 | (b) => b.current, |
| 1006 | 917 | )!.name; |
| 1007 | 918 | |
| 1008 | yield* createGitWorktree({ | |
| 919 | yield* (yield* GitCore).createWorktree({ | |
| 1009 | 920 | cwd: tmp, |
| 1010 | 921 | branch: currentBranch, |
| 1011 | 922 | newBranch: "wt-check", |
| ... | ... | @@ -1016,7 +927,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1016 | 927 | const branchOutput = yield* git(wtPath, ["branch", "--show-current"]); |
| 1017 | 928 | expect(branchOutput).toBe("wt-check"); |
| 1018 | 929 | |
| 1019 | yield* removeGitWorktree({ cwd: tmp, path: wtPath }); | |
| 930 | yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); | |
| 1020 | 931 | }), |
| 1021 | 932 | ); |
| 1022 | 933 | |
| ... | ... | @@ -1024,10 +935,10 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1024 | 935 | Effect.gen(function* () { |
| 1025 | 936 | const tmp = yield* makeTmpDir(); |
| 1026 | 937 | yield* initRepoWithCommit(tmp); |
| 1027 | yield* createGitBranch({ cwd: tmp, branch: "feature/existing-worktree" }); | |
| 938 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/existing-worktree" }); | |
| 1028 | 939 | |
| 1029 | 940 | const wtPath = path.join(tmp, "wt-existing"); |
| 1030 | const result = yield* createGitWorktree({ | |
| 941 | const result = yield* (yield* GitCore).createWorktree({ | |
| 1031 | 942 | cwd: tmp, |
| 1032 | 943 | branch: "feature/existing-worktree", |
| 1033 | 944 | path: wtPath, |
| ... | ... | @@ -1038,7 +949,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1038 | 949 | const branchOutput = yield* git(wtPath, ["branch", "--show-current"]); |
| 1039 | 950 | expect(branchOutput).toBe("feature/existing-worktree"); |
| 1040 | 951 | |
| 1041 | yield* removeGitWorktree({ cwd: tmp, path: wtPath }); | |
| 952 | yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); | |
| 1042 | 953 | }), |
| 1043 | 954 | ); |
| 1044 | 955 | |
| ... | ... | @@ -1046,15 +957,15 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1046 | 957 | Effect.gen(function* () { |
| 1047 | 958 | const tmp = yield* makeTmpDir(); |
| 1048 | 959 | yield* initRepoWithCommit(tmp); |
| 1049 | yield* createGitBranch({ cwd: tmp, branch: "existing" }); | |
| 960 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "existing" }); | |
| 1050 | 961 | |
| 1051 | 962 | const wtPath = path.join(tmp, "wt-conflict"); |
| 1052 | const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 963 | const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | |
| 1053 | 964 | (b) => b.current, |
| 1054 | 965 | )!.name; |
| 1055 | 966 | |
| 1056 | 967 | const result = yield* Effect.result( |
| 1057 | createGitWorktree({ | |
| 968 | (yield* GitCore).createWorktree({ | |
| 1058 | 969 | cwd: tmp, |
| 1059 | 970 | branch: currentBranch, |
| 1060 | 971 | newBranch: "existing", |
| ... | ... | @@ -1071,11 +982,11 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1071 | 982 | yield* initRepoWithCommit(tmp); |
| 1072 | 983 | |
| 1073 | 984 | const wtPath = path.join(tmp, "wt-list-dir"); |
| 1074 | const mainBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 985 | const mainBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | |
| 1075 | 986 | (b) => b.current, |
| 1076 | 987 | )!.name; |
| 1077 | 988 | |
| 1078 | yield* createGitWorktree({ | |
| 989 | yield* (yield* GitCore).createWorktree({ | |
| 1079 | 990 | cwd: tmp, |
| 1080 | 991 | branch: mainBranch, |
| 1081 | 992 | newBranch: "wt-list", |
| ... | ... | @@ -1083,17 +994,17 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1083 | 994 | }); |
| 1084 | 995 | |
| 1085 | 996 | // listGitBranches from the worktree should show wt-list as current |
| 1086 | const wtBranches = yield* listGitBranches({ cwd: wtPath }); | |
| 997 | const wtBranches = yield* (yield* GitCore).listBranches({ cwd: wtPath }); | |
| 1087 | 998 | expect(wtBranches.isRepo).toBe(true); |
| 1088 | 999 | const wtCurrent = wtBranches.branches.find((b) => b.current); |
| 1089 | 1000 | expect(wtCurrent!.name).toBe("wt-list"); |
| 1090 | 1001 | |
| 1091 | 1002 | // Main repo should still show the original branch as current |
| 1092 | const mainBranches = yield* listGitBranches({ cwd: tmp }); | |
| 1003 | const mainBranches = yield* (yield* GitCore).listBranches({ cwd: tmp }); | |
| 1093 | 1004 | const mainCurrent = mainBranches.branches.find((b) => b.current); |
| 1094 | 1005 | expect(mainCurrent!.name).toBe(mainBranch); |
| 1095 | 1006 | |
| 1096 | yield* removeGitWorktree({ cwd: tmp, path: wtPath }); | |
| 1007 | yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); | |
| 1097 | 1008 | }), |
| 1098 | 1009 | ); |
| 1099 | 1010 | |
| ... | ... | @@ -1103,11 +1014,11 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1103 | 1014 | yield* initRepoWithCommit(tmp); |
| 1104 | 1015 | |
| 1105 | 1016 | const wtPath = path.join(tmp, "wt-remove-dir"); |
| 1106 | const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 1017 | const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | |
| 1107 | 1018 | (b) => b.current, |
| 1108 | 1019 | )!.name; |
| 1109 | 1020 | |
| 1110 | yield* createGitWorktree({ | |
| 1021 | yield* (yield* GitCore).createWorktree({ | |
| 1111 | 1022 | cwd: tmp, |
| 1112 | 1023 | branch: currentBranch, |
| 1113 | 1024 | newBranch: "wt-remove", |
| ... | ... | @@ -1115,7 +1026,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1115 | 1026 | }); |
| 1116 | 1027 | expect(existsSync(wtPath)).toBe(true); |
| 1117 | 1028 | |
| 1118 | yield* removeGitWorktree({ cwd: tmp, path: wtPath }); | |
| 1029 | yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); | |
| 1119 | 1030 | expect(existsSync(wtPath)).toBe(false); |
| 1120 | 1031 | }), |
| 1121 | 1032 | ); |
| ... | ... | @@ -1126,11 +1037,11 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1126 | 1037 | yield* initRepoWithCommit(tmp); |
| 1127 | 1038 | |
| 1128 | 1039 | const wtPath = path.join(tmp, "wt-dirty-dir"); |
| 1129 | const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 1040 | const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | |
| 1130 | 1041 | (b) => b.current, |
| 1131 | 1042 | )!.name; |
| 1132 | 1043 | |
| 1133 | yield* createGitWorktree({ | |
| 1044 | yield* (yield* GitCore).createWorktree({ | |
| 1134 | 1045 | cwd: tmp, |
| 1135 | 1046 | branch: currentBranch, |
| 1136 | 1047 | newBranch: "wt-dirty", |
| ... | ... | @@ -1140,11 +1051,13 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1140 | 1051 | |
| 1141 | 1052 | yield* writeTextFile(path.join(wtPath, "README.md"), "dirty change\n"); |
| 1142 | 1053 | |
| 1143 | const failedRemove = yield* Effect.result(removeGitWorktree({ cwd: tmp, path: wtPath })); | |
| 1054 | const failedRemove = yield* Effect.result( | |
| 1055 | (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }), | |
| 1056 | ); | |
| 1144 | 1057 | expect(failedRemove._tag).toBe("Failure"); |
| 1145 | 1058 | expect(existsSync(wtPath)).toBe(true); |
| 1146 | 1059 | |
| 1147 | yield* removeGitWorktree({ cwd: tmp, path: wtPath, force: true }); | |
| 1060 | yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath, force: true }); | |
| 1148 | 1061 | expect(existsSync(wtPath)).toBe(false); |
| 1149 | 1062 | }), |
| 1150 | 1063 | ); |
| ... | ... | @@ -1157,10 +1070,10 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1157 | 1070 | Effect.gen(function* () { |
| 1158 | 1071 | const tmp = yield* makeTmpDir(); |
| 1159 | 1072 | yield* initRepoWithCommit(tmp); |
| 1160 | yield* createGitBranch({ cwd: tmp, branch: "feature-login" }); | |
| 1161 | yield* checkoutGitBranch({ cwd: tmp, branch: "feature-login" }); | |
| 1073 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-login" }); | |
| 1074 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature-login" }); | |
| 1162 | 1075 | |
| 1163 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 1076 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | |
| 1164 | 1077 | const current = result.branches.find((b) => b.current); |
| 1165 | 1078 | expect(current!.name).toBe("feature-login"); |
| 1166 | 1079 | }), |
| ... | ... | @@ -1175,12 +1088,12 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1175 | 1088 | const tmp = yield* makeTmpDir(); |
| 1176 | 1089 | yield* initRepoWithCommit(tmp); |
| 1177 | 1090 | |
| 1178 | const currentBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 1091 | const currentBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | |
| 1179 | 1092 | (b) => b.current, |
| 1180 | 1093 | )!.name; |
| 1181 | 1094 | |
| 1182 | 1095 | const wtPath = path.join(tmp, "my-worktree"); |
| 1183 | const result = yield* createGitWorktree({ | |
| 1096 | const result = yield* (yield* GitCore).createWorktree({ | |
| 1184 | 1097 | cwd: tmp, |
| 1185 | 1098 | branch: currentBranch, |
| 1186 | 1099 | newBranch: "feature-wt", |
| ... | ... | @@ -1191,7 +1104,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1191 | 1104 | expect(existsSync(result.worktree.path)).toBe(true); |
| 1192 | 1105 | |
| 1193 | 1106 | // Main repo still on original branch |
| 1194 | const mainBranches = yield* listGitBranches({ cwd: tmp }); | |
| 1107 | const mainBranches = yield* (yield* GitCore).listBranches({ cwd: tmp }); | |
| 1195 | 1108 | const mainCurrent = mainBranches.branches.find((b) => b.current); |
| 1196 | 1109 | expect(mainCurrent!.name).toBe(currentBranch); |
| 1197 | 1110 | |
| ... | ... | @@ -1199,7 +1112,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1199 | 1112 | const wtBranch = yield* git(wtPath, ["branch", "--show-current"]); |
| 1200 | 1113 | expect(wtBranch).toBe("feature-wt"); |
| 1201 | 1114 | |
| 1202 | yield* removeGitWorktree({ cwd: tmp, path: wtPath }); | |
| 1115 | yield* (yield* GitCore).removeWorktree({ cwd: tmp, path: wtPath }); | |
| 1203 | 1116 | }), |
| 1204 | 1117 | ); |
| 1205 | 1118 | }); |
| ... | ... | @@ -1221,7 +1134,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1221 | 1134 | yield* git(tmp, ["push", "origin", "HEAD:refs/pull/55/head"]); |
| 1222 | 1135 | yield* git(tmp, ["checkout", initialBranch]); |
| 1223 | 1136 | |
| 1224 | yield* fetchGitPullRequestBranch({ | |
| 1137 | yield* (yield* GitCore).fetchPullRequestBranch({ | |
| 1225 | 1138 | cwd: tmp, |
| 1226 | 1139 | prNumber: 55, |
| 1227 | 1140 | branch: "feature/pr-fetch", |
| ... | ... | @@ -1242,22 +1155,22 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1242 | 1155 | Effect.gen(function* () { |
| 1243 | 1156 | const tmp = yield* makeTmpDir(); |
| 1244 | 1157 | yield* initRepoWithCommit(tmp); |
| 1245 | yield* createGitBranch({ cwd: tmp, branch: "branch-a" }); | |
| 1246 | yield* createGitBranch({ cwd: tmp, branch: "branch-b" }); | |
| 1158 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "branch-a" }); | |
| 1159 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "branch-b" }); | |
| 1247 | 1160 | |
| 1248 | 1161 | // Simulate switching to thread A's branch |
| 1249 | yield* checkoutGitBranch({ cwd: tmp, branch: "branch-a" }); | |
| 1250 | let branches = yield* listGitBranches({ cwd: tmp }); | |
| 1162 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-a" }); | |
| 1163 | let branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); | |
| 1251 | 1164 | expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); |
| 1252 | 1165 | |
| 1253 | 1166 | // Simulate switching to thread B's branch |
| 1254 | yield* checkoutGitBranch({ cwd: tmp, branch: "branch-b" }); | |
| 1255 | branches = yield* listGitBranches({ cwd: tmp }); | |
| 1167 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-b" }); | |
| 1168 | branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); | |
| 1256 | 1169 | expect(branches.branches.find((b) => b.current)!.name).toBe("branch-b"); |
| 1257 | 1170 | |
| 1258 | 1171 | // Switch back to thread A |
| 1259 | yield* checkoutGitBranch({ cwd: tmp, branch: "branch-a" }); | |
| 1260 | branches = yield* listGitBranches({ cwd: tmp }); | |
| 1172 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "branch-a" }); | |
| 1173 | branches = yield* (yield* GitCore).listBranches({ cwd: tmp }); | |
| 1261 | 1174 | expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); |
| 1262 | 1175 | }), |
| 1263 | 1176 | ); |
| ... | ... | @@ -1270,30 +1183,30 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1270 | 1183 | Effect.gen(function* () { |
| 1271 | 1184 | const tmp = yield* makeTmpDir(); |
| 1272 | 1185 | yield* initRepoWithCommit(tmp); |
| 1273 | yield* createGitBranch({ cwd: tmp, branch: "diverged" }); | |
| 1186 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "diverged" }); | |
| 1274 | 1187 | |
| 1275 | 1188 | // Make diverged branch have different file content |
| 1276 | yield* checkoutGitBranch({ cwd: tmp, branch: "diverged" }); | |
| 1189 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "diverged" }); | |
| 1277 | 1190 | yield* writeTextFile(path.join(tmp, "README.md"), "diverged content\n"); |
| 1278 | 1191 | yield* git(tmp, ["add", "."]); |
| 1279 | 1192 | yield* git(tmp, ["commit", "-m", "diverge"]); |
| 1280 | 1193 | |
| 1281 | 1194 | // Actually, let's just get back to the initial branch explicitly |
| 1282 | const allBranches = yield* listGitBranches({ cwd: tmp }); | |
| 1195 | const allBranches = yield* (yield* GitCore).listBranches({ cwd: tmp }); | |
| 1283 | 1196 | const initialBranch = allBranches.branches.find((b) => b.name !== "diverged")!.name; |
| 1284 | yield* checkoutGitBranch({ cwd: tmp, branch: initialBranch }); | |
| 1197 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: initialBranch }); | |
| 1285 | 1198 | |
| 1286 | 1199 | // Make local uncommitted changes to the same file |
| 1287 | 1200 | yield* writeTextFile(path.join(tmp, "README.md"), "local uncommitted\n"); |
| 1288 | 1201 | |
| 1289 | 1202 | // Attempt checkout should fail |
| 1290 | 1203 | const failedCheckout = yield* Effect.result( |
| 1291 | checkoutGitBranch({ cwd: tmp, branch: "diverged" }), | |
| 1204 | (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "diverged" }), | |
| 1292 | 1205 | ); |
| 1293 | 1206 | expect(failedCheckout._tag).toBe("Failure"); |
| 1294 | 1207 | |
| 1295 | 1208 | // Current branch should still be the initial one |
| 1296 | const result = yield* listGitBranches({ cwd: tmp }); | |
| 1209 | const result = yield* (yield* GitCore).listBranches({ cwd: tmp }); | |
| 1297 | 1210 | expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch); |
| 1298 | 1211 | }), |
| 1299 | 1212 | ); |
| ... | ... | @@ -1390,9 +1303,9 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1390 | 1303 | yield* git(remote, ["init", "--bare"]); |
| 1391 | 1304 | |
| 1392 | 1305 | yield* initRepoWithCommit(source); |
| 1393 | const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find( | |
| 1394 | (branch) => branch.current, | |
| 1395 | )!.name; | |
| 1306 | const initialBranch = (yield* (yield* GitCore).listBranches({ | |
| 1307 | cwd: source, | |
| 1308 | })).branches.find((branch) => branch.current)!.name; | |
| 1396 | 1309 | yield* git(source, ["remote", "add", "origin", remote]); |
| 1397 | 1310 | yield* git(source, ["push", "-u", "origin", initialBranch]); |
| 1398 | 1311 | yield* git(source, ["checkout", "-b", "feature/remote-base-only"]); |
| ... | ... | @@ -1423,9 +1336,9 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1423 | 1336 | yield* git(remote, ["init", "--bare"]); |
| 1424 | 1337 | |
| 1425 | 1338 | yield* initRepoWithCommit(source); |
| 1426 | const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find( | |
| 1427 | (branch) => branch.current, | |
| 1428 | )!.name; | |
| 1339 | const initialBranch = (yield* (yield* GitCore).listBranches({ | |
| 1340 | cwd: source, | |
| 1341 | })).branches.find((branch) => branch.current)!.name; | |
| 1429 | 1342 | yield* git(source, ["remote", "add", remoteName, remote]); |
| 1430 | 1343 | yield* git(source, ["push", "-u", remoteName, initialBranch]); |
| 1431 | 1344 | yield* git(source, ["checkout", "-b", "feature/non-origin-merge-base"]); |
| ... | ... | @@ -1526,7 +1439,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1526 | 1439 | yield* git(remote, ["init", "--bare"]); |
| 1527 | 1440 | |
| 1528 | 1441 | yield* initRepoWithCommit(tmp); |
| 1529 | const initialBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 1442 | const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | |
| 1530 | 1443 | (branch) => branch.current, |
| 1531 | 1444 | )!.name; |
| 1532 | 1445 | yield* git(tmp, ["remote", "add", "origin", remote]); |
| ... | ... | @@ -1562,7 +1475,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1562 | 1475 | yield* git(fork, ["init", "--bare"]); |
| 1563 | 1476 | |
| 1564 | 1477 | yield* initRepoWithCommit(tmp); |
| 1565 | const initialBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( | |
| 1478 | const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: tmp })).branches.find( | |
| 1566 | 1479 | (branch) => branch.current, |
| 1567 | 1480 | )!.name; |
| 1568 | 1481 | yield* git(tmp, ["remote", "add", "origin", origin]); |
| ... | ... | @@ -1668,9 +1581,9 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1668 | 1581 | yield* git(remote, ["init", "--bare"]); |
| 1669 | 1582 | |
| 1670 | 1583 | yield* initRepoWithCommit(source); |
| 1671 | const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find( | |
| 1672 | (branch) => branch.current, | |
| 1673 | )!.name; | |
| 1584 | const initialBranch = (yield* (yield* GitCore).listBranches({ | |
| 1585 | cwd: source, | |
| 1586 | })).branches.find((branch) => branch.current)!.name; | |
| 1674 | 1587 | yield* git(source, ["remote", "add", "origin", remote]); |
| 1675 | 1588 | yield* git(source, ["push", "-u", "origin", initialBranch]); |
| 1676 | 1589 | |
| ... | ... | @@ -1761,8 +1674,8 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1761 | 1674 | yield* initRepoWithCommit(tmp); |
| 1762 | 1675 | yield* git(remote, ["init", "--bare"]); |
| 1763 | 1676 | yield* git(tmp, ["remote", "add", "origin", remote]); |
| 1764 | yield* createGitBranch({ cwd: tmp, branch: "feature/core-push" }); | |
| 1765 | yield* checkoutGitBranch({ cwd: tmp, branch: "feature/core-push" }); | |
| 1677 | yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/core-push" }); | |
| 1678 | yield* (yield* GitCore).checkoutBranch({ cwd: tmp, branch: "feature/core-push" }); | |
| 1766 | 1679 | |
| 1767 | 1680 | yield* writeTextFile(path.join(tmp, "feature.txt"), "push me\n"); |
| 1768 | 1681 | const core = yield* GitCore; |
| ... | ... | @@ -1790,7 +1703,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1790 | 1703 | yield* git(remote, ["init", "--bare"]); |
| 1791 | 1704 | |
| 1792 | 1705 | yield* initRepoWithCommit(source); |
| 1793 | const initialBranch = (yield* listGitBranches({ cwd: source })).branches.find( | |
| 1706 | const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( | |
| 1794 | 1707 | (branch) => branch.current, |
| 1795 | 1708 | )!.name; |
| 1796 | 1709 | yield* git(source, ["remote", "add", "origin", remote]); |
| ... | ... | @@ -1818,7 +1731,7 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1818 | 1731 | Effect.gen(function* () { |
| 1819 | 1732 | const tmp = yield* makeTmpDir(); |
| 1820 | 1733 | yield* initRepoWithCommit(tmp); |
| 1821 | const result = yield* Effect.result(pullGitBranch({ cwd: tmp })); | |
| 1734 | const result = yield* Effect.result((yield* GitCore).pullCurrentBranch(tmp)); | |
| 1822 | 1735 | expect(result._tag).toBe("Failure"); |
| 1823 | 1736 | if (result._tag === "Failure") { |
| 1824 | 1737 | expect(result.failure.message.toLowerCase()).toContain("no upstream"); |
| ... | ... | @@ -1830,23 +1743,21 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1830 | 1743 | Effect.gen(function* () { |
| 1831 | 1744 | const tmp = yield* makeTmpDir(); |
| 1832 | 1745 | yield* initRepoWithCommit(tmp); |
| 1833 | const realGitService = yield* GitService; | |
| 1746 | const realGitCore = yield* GitCore; | |
| 1834 | 1747 | let didFailRecency = false; |
| 1835 | const core = yield* makeIsolatedGitCore({ | |
| 1836 | execute: (input) => { | |
| 1837 | if (!didFailRecency && input.args[0] === "for-each-ref") { | |
| 1838 | didFailRecency = true; | |
| 1839 | return Effect.fail( | |
| 1840 | new GitCommandError({ | |
| 1841 | operation: "git.test.listBranchesRecency", | |
| 1842 | command: `git ${input.args.join(" ")}`, | |
| 1843 | cwd: input.cwd, | |
| 1844 | detail: "timeout", | |
| 1845 | }), | |
| 1846 | ); | |
| 1847 | } | |
| 1848 | return realGitService.execute(input); | |
| 1849 | }, | |
| 1748 | const core = yield* makeIsolatedGitCore((input) => { | |
| 1749 | if (!didFailRecency && input.args[0] === "for-each-ref") { | |
| 1750 | didFailRecency = true; | |
| 1751 | return Effect.fail( | |
| 1752 | new GitCommandError({ | |
| 1753 | operation: "git.test.listBranchesRecency", | |
| 1754 | command: `git ${input.args.join(" ")}`, | |
| 1755 | cwd: input.cwd, | |
| 1756 | detail: "timeout", | |
| 1757 | }), | |
| 1758 | ); | |
| 1759 | } | |
| 1760 | return realGitCore.execute(input); | |
| 1850 | 1761 | }); |
| 1851 | 1762 | |
| 1852 | 1763 | const result = yield* core.listBranches({ cwd: tmp }); |
| ... | ... | @@ -1866,35 +1777,33 @@ it.layer(TestLayer)("git integration", (it) => { |
| 1866 | 1777 | yield* git(remote, ["init", "--bare"]); |
| 1867 | 1778 | yield* git(tmp, ["remote", "add", "origin", remote]); |
| 1868 | 1779 | |
| 1869 | const realGitService = yield* GitService; | |
| 1780 | const realGitCore = yield* GitCore; | |
| 1870 | 1781 | let didFailRemoteBranches = false; |
| 1871 | 1782 | let didFailRemoteNames = false; |
| 1872 | const core = yield* makeIsolatedGitCore({ | |
| 1873 | execute: (input) => { | |
| 1874 | if (input.args.join(" ") === "branch --no-color --remotes") { | |
| 1875 | didFailRemoteBranches = true; | |
| 1876 | return Effect.fail( | |
| 1877 | new GitCommandError({ | |
| 1878 | operation: "git.test.listBranchesRemoteBranches", | |
| 1879 | command: `git ${input.args.join(" ")}`, | |
| 1880 | cwd: input.cwd, | |
| 1881 | detail: "remote unavailable", | |
| 1882 | }), | |
| 1883 | ); | |
| 1884 | } | |
| 1885 | if (input.args.join(" ") === "remote") { | |
| 1886 | didFailRemoteNames = true; | |
| 1887 | return Effect.fail( | |
| 1888 | new GitCommandError({ | |
| 1889 | operation: "git.test.listBranchesRemoteNames", | |
| 1890 | command: `git ${input.args.join(" ")}`, | |
| 1891 | cwd: input.cwd, | |
| 1892 | detail: "remote unavailable", | |
| 1893 | }), | |
| 1894 | ); | |
| 1895 | } | |
| 1896 | return realGitService.execute(input); | |
| 1897 | }, | |
| 1783 | const core = yield* makeIsolatedGitCore((input) => { | |
| 1784 | if (input.args.join(" ") === "branch --no-color --remotes") { | |
| 1785 | didFailRemoteBranches = true; | |
| 1786 | return Effect.fail( | |
| 1787 | new GitCommandError({ | |
| 1788 | operation: "git.test.listBranchesRemoteBranches", | |
| 1789 | command: `git ${input.args.join(" ")}`, | |
| 1790 | cwd: input.cwd, | |
| 1791 | detail: "remote unavailable", | |
| 1792 | }), | |
| 1793 | ); | |
| 1794 | } | |
| 1795 | if (input.args.join(" ") === "remote") { | |
| 1796 | didFailRemoteNames = true; | |
| 1797 | return Effect.fail( | |
| 1798 | new GitCommandError({ | |
| 1799 | operation: "git.test.listBranchesRemoteNames", | |
| 1800 | command: `git ${input.args.join(" ")}`, | |
| 1801 | cwd: input.cwd, | |
| 1802 | detail: "remote unavailable", | |
| 1803 | }), | |
| 1804 | ); | |
| 1805 | } | |
| 1806 | return realGitCore.execute(input); | |
| 1898 | 1807 | }); |
| 1899 | 1808 | |
| 1900 | 1809 | const result = yield* core.listBranches({ cwd: tmp }); |
apps/server/src/git/Layers/GitCore.ts+1189-1047| ... | ... | @@ -1,10 +1,29 @@ |
| 1 | import { Cache, Data, Duration, Effect, Exit, FileSystem, Layer, Path } from "effect"; | |
| 1 | import { | |
| 2 | Cache, | |
| 3 | Data, | |
| 4 | Duration, | |
| 5 | Effect, | |
| 6 | Exit, | |
| 7 | FileSystem, | |
| 8 | Layer, | |
| 9 | Option, | |
| 10 | Path, | |
| 11 | Schema, | |
| 12 | Stream, | |
| 13 | } from "effect"; | |
| 14 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 2 | 15 | |
| 3 | 16 | import { GitCommandError } from "../Errors.ts"; |
| 4 | import { GitService } from "../Services/GitService.ts"; | |
| 5 | import { GitCore, type GitCoreShape } from "../Services/GitCore.ts"; | |
| 17 | import { | |
| 18 | GitCore, | |
| 19 | type GitCoreShape, | |
| 20 | type ExecuteGitInput, | |
| 21 | type ExecuteGitResult, | |
| 22 | } from "../Services/GitCore.ts"; | |
| 6 | 23 | import { ServerConfig } from "../../config.ts"; |
| 7 | 24 | |
| 25 | const DEFAULT_TIMEOUT_MS = 30_000; | |
| 26 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; | |
| 8 | 27 | const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); |
| 9 | 28 | const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); |
| 10 | 29 | const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; |
| ... | ... | @@ -218,27 +237,146 @@ function createGitCommandError( |
| 218 | 237 | }); |
| 219 | 238 | } |
| 220 | 239 | |
| 221 | const makeGitCore = Effect.gen(function* () { | |
| 222 | const git = yield* GitService; | |
| 223 | const fileSystem = yield* FileSystem.FileSystem; | |
| 224 | const path = yield* Path.Path; | |
| 225 | const { worktreesDir } = yield* ServerConfig; | |
| 226 | ||
| 227 | const executeGit = ( | |
| 228 | operation: string, | |
| 229 | cwd: string, | |
| 230 | args: readonly string[], | |
| 231 | options: ExecuteGitOptions = {}, | |
| 232 | ): Effect.Effect<{ code: number; stdout: string; stderr: string }, GitCommandError> => | |
| 233 | git | |
| 234 | .execute({ | |
| 240 | function quoteGitCommand(args: ReadonlyArray<string>): string { | |
| 241 | return `git ${args.join(" ")}`; | |
| 242 | } | |
| 243 | ||
| 244 | function toGitCommandError( | |
| 245 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, | |
| 246 | detail: string, | |
| 247 | ) { | |
| 248 | return (cause: unknown) => | |
| 249 | Schema.is(GitCommandError)(cause) | |
| 250 | ? cause | |
| 251 | : new GitCommandError({ | |
| 252 | operation: input.operation, | |
| 253 | command: quoteGitCommand(input.args), | |
| 254 | cwd: input.cwd, | |
| 255 | detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`, | |
| 256 | ...(cause !== undefined ? { cause } : {}), | |
| 257 | }); | |
| 258 | } | |
| 259 | ||
| 260 | const collectOutput = Effect.fn(function* <E>( | |
| 261 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, | |
| 262 | stream: Stream.Stream<Uint8Array, E>, | |
| 263 | maxOutputBytes: number, | |
| 264 | ): Effect.fn.Return<string, GitCommandError> { | |
| 265 | const decoder = new TextDecoder(); | |
| 266 | let bytes = 0; | |
| 267 | let text = ""; | |
| 268 | ||
| 269 | yield* Stream.runForEach(stream, (chunk) => | |
| 270 | Effect.gen(function* () { | |
| 271 | bytes += chunk.byteLength; | |
| 272 | if (bytes > maxOutputBytes) { | |
| 273 | return yield* new GitCommandError({ | |
| 274 | operation: input.operation, | |
| 275 | command: quoteGitCommand(input.args), | |
| 276 | cwd: input.cwd, | |
| 277 | detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, | |
| 278 | }); | |
| 279 | } | |
| 280 | text += decoder.decode(chunk, { stream: true }); | |
| 281 | }), | |
| 282 | ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed."))); | |
| 283 | ||
| 284 | text += decoder.decode(); | |
| 285 | return text; | |
| 286 | }); | |
| 287 | ||
| 288 | export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"] }) => | |
| 289 | Effect.gen(function* () { | |
| 290 | const fileSystem = yield* FileSystem.FileSystem; | |
| 291 | const path = yield* Path.Path; | |
| 292 | const { worktreesDir } = yield* ServerConfig; | |
| 293 | ||
| 294 | let execute: GitCoreShape["execute"]; | |
| 295 | ||
| 296 | if (options?.executeOverride) { | |
| 297 | execute = options.executeOverride; | |
| 298 | } else { | |
| 299 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 300 | execute = Effect.fnUntraced(function* (input) { | |
| 301 | const commandInput = { | |
| 302 | ...input, | |
| 303 | args: [...input.args], | |
| 304 | } as const; | |
| 305 | const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; | |
| 306 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; | |
| 307 | ||
| 308 | const commandEffect = Effect.gen(function* () { | |
| 309 | const child = yield* commandSpawner | |
| 310 | .spawn( | |
| 311 | ChildProcess.make("git", commandInput.args, { | |
| 312 | cwd: commandInput.cwd, | |
| 313 | ...(input.env ? { env: input.env } : {}), | |
| 314 | }), | |
| 315 | ) | |
| 316 | .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn."))); | |
| 317 | ||
| 318 | const [stdout, stderr, exitCode] = yield* Effect.all( | |
| 319 | [ | |
| 320 | collectOutput(commandInput, child.stdout, maxOutputBytes), | |
| 321 | collectOutput(commandInput, child.stderr, maxOutputBytes), | |
| 322 | child.exitCode.pipe( | |
| 323 | Effect.map((value) => Number(value)), | |
| 324 | Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), | |
| 325 | ), | |
| 326 | ], | |
| 327 | { concurrency: "unbounded" }, | |
| 328 | ); | |
| 329 | ||
| 330 | if (!input.allowNonZeroExit && exitCode !== 0) { | |
| 331 | const trimmedStderr = stderr.trim(); | |
| 332 | return yield* new GitCommandError({ | |
| 333 | operation: commandInput.operation, | |
| 334 | command: quoteGitCommand(commandInput.args), | |
| 335 | cwd: commandInput.cwd, | |
| 336 | detail: | |
| 337 | trimmedStderr.length > 0 | |
| 338 | ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}` | |
| 339 | : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`, | |
| 340 | }); | |
| 341 | } | |
| 342 | ||
| 343 | return { code: exitCode, stdout, stderr } satisfies ExecuteGitResult; | |
| 344 | }); | |
| 345 | ||
| 346 | return yield* commandEffect.pipe( | |
| 347 | Effect.scoped, | |
| 348 | Effect.timeoutOption(timeoutMs), | |
| 349 | Effect.flatMap((result) => | |
| 350 | Option.match(result, { | |
| 351 | onNone: () => | |
| 352 | Effect.fail( | |
| 353 | new GitCommandError({ | |
| 354 | operation: commandInput.operation, | |
| 355 | command: quoteGitCommand(commandInput.args), | |
| 356 | cwd: commandInput.cwd, | |
| 357 | detail: `${quoteGitCommand(commandInput.args)} timed out.`, | |
| 358 | }), | |
| 359 | ), | |
| 360 | onSome: Effect.succeed, | |
| 361 | }), | |
| 362 | ), | |
| 363 | ); | |
| 364 | }); | |
| 365 | } | |
| 366 | ||
| 367 | const executeGit = ( | |
| 368 | operation: string, | |
| 369 | cwd: string, | |
| 370 | args: readonly string[], | |
| 371 | options: ExecuteGitOptions = {}, | |
| 372 | ): Effect.Effect<{ code: number; stdout: string; stderr: string }, GitCommandError> => | |
| 373 | execute({ | |
| 235 | 374 | operation, |
| 236 | 375 | cwd, |
| 237 | 376 | args, |
| 238 | 377 | allowNonZeroExit: true, |
| 239 | 378 | ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), |
| 240 | }) | |
| 241 | .pipe( | |
| 379 | }).pipe( | |
| 242 | 380 | Effect.flatMap((result) => { |
| 243 | 381 | if (options.allowNonZeroExit || result.code === 0) { |
| 244 | 382 | return Effect.succeed(result); |
| ... | ... | @@ -263,1166 +401,1170 @@ const makeGitCore = Effect.gen(function* () { |
| 263 | 401 | }), |
| 264 | 402 | ); |
| 265 | 403 | |
| 266 | const runGit = ( | |
| 267 | operation: string, | |
| 268 | cwd: string, | |
| 269 | args: readonly string[], | |
| 270 | allowNonZeroExit = false, | |
| 271 | ): Effect.Effect<void, GitCommandError> => | |
| 272 | executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid); | |
| 273 | ||
| 274 | const runGitStdout = ( | |
| 275 | operation: string, | |
| 276 | cwd: string, | |
| 277 | args: readonly string[], | |
| 278 | allowNonZeroExit = false, | |
| 279 | ): Effect.Effect<string, GitCommandError> => | |
| 280 | executeGit(operation, cwd, args, { allowNonZeroExit }).pipe( | |
| 281 | Effect.map((result) => result.stdout), | |
| 282 | ); | |
| 283 | ||
| 284 | const branchExists = (cwd: string, branch: string): Effect.Effect<boolean, GitCommandError> => | |
| 285 | executeGit( | |
| 286 | "GitCore.branchExists", | |
| 287 | cwd, | |
| 288 | ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], | |
| 289 | { | |
| 290 | allowNonZeroExit: true, | |
| 291 | timeoutMs: 5_000, | |
| 292 | }, | |
| 293 | ).pipe(Effect.map((result) => result.code === 0)); | |
| 294 | ||
| 295 | const resolveAvailableBranchName = ( | |
| 296 | cwd: string, | |
| 297 | desiredBranch: string, | |
| 298 | ): Effect.Effect<string, GitCommandError> => | |
| 299 | Effect.gen(function* () { | |
| 300 | const isDesiredTaken = yield* branchExists(cwd, desiredBranch); | |
| 301 | if (!isDesiredTaken) { | |
| 302 | return desiredBranch; | |
| 303 | } | |
| 304 | ||
| 305 | for (let suffix = 1; suffix <= 100; suffix += 1) { | |
| 306 | const candidate = `${desiredBranch}-${suffix}`; | |
| 307 | const isCandidateTaken = yield* branchExists(cwd, candidate); | |
| 308 | if (!isCandidateTaken) { | |
| 309 | return candidate; | |
| 310 | } | |
| 311 | } | |
| 312 | ||
| 313 | return yield* createGitCommandError( | |
| 314 | "GitCore.renameBranch", | |
| 315 | cwd, | |
| 316 | ["branch", "-m", "--", desiredBranch], | |
| 317 | `Could not find an available branch name for '${desiredBranch}'.`, | |
| 404 | const runGit = ( | |
| 405 | operation: string, | |
| 406 | cwd: string, | |
| 407 | args: readonly string[], | |
| 408 | allowNonZeroExit = false, | |
| 409 | ): Effect.Effect<void, GitCommandError> => | |
| 410 | executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid); | |
| 411 | ||
| 412 | const runGitStdout = ( | |
| 413 | operation: string, | |
| 414 | cwd: string, | |
| 415 | args: readonly string[], | |
| 416 | allowNonZeroExit = false, | |
| 417 | ): Effect.Effect<string, GitCommandError> => | |
| 418 | executeGit(operation, cwd, args, { allowNonZeroExit }).pipe( | |
| 419 | Effect.map((result) => result.stdout), | |
| 318 | 420 | ); |
| 319 | }); | |
| 320 | 421 | |
| 321 | const resolveCurrentUpstream = ( | |
| 322 | cwd: string, | |
| 323 | ): Effect.Effect< | |
| 324 | { upstreamRef: string; remoteName: string; upstreamBranch: string } | null, | |
| 325 | GitCommandError | |
| 326 | > => | |
| 327 | Effect.gen(function* () { | |
| 328 | const upstreamRef = yield* runGitStdout( | |
| 329 | "GitCore.resolveCurrentUpstream", | |
| 422 | const branchExists = (cwd: string, branch: string): Effect.Effect<boolean, GitCommandError> => | |
| 423 | executeGit( | |
| 424 | "GitCore.branchExists", | |
| 330 | 425 | cwd, |
| 331 | ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], | |
| 332 | true, | |
| 333 | ).pipe(Effect.map((stdout) => stdout.trim())); | |
| 334 | ||
| 335 | if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { | |
| 336 | return null; | |
| 337 | } | |
| 338 | ||
| 339 | const separatorIndex = upstreamRef.indexOf("/"); | |
| 340 | if (separatorIndex <= 0) { | |
| 341 | return null; | |
| 342 | } | |
| 343 | const remoteName = upstreamRef.slice(0, separatorIndex); | |
| 344 | const upstreamBranch = upstreamRef.slice(separatorIndex + 1); | |
| 345 | if (remoteName.length === 0 || upstreamBranch.length === 0) { | |
| 346 | return null; | |
| 347 | } | |
| 426 | ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], | |
| 427 | { | |
| 428 | allowNonZeroExit: true, | |
| 429 | timeoutMs: 5_000, | |
| 430 | }, | |
| 431 | ).pipe(Effect.map((result) => result.code === 0)); | |
| 348 | 432 | |
| 349 | return { | |
| 350 | upstreamRef, | |
| 351 | remoteName, | |
| 352 | upstreamBranch, | |
| 353 | }; | |
| 354 | }); | |
| 433 | const resolveAvailableBranchName = ( | |
| 434 | cwd: string, | |
| 435 | desiredBranch: string, | |
| 436 | ): Effect.Effect<string, GitCommandError> => | |
| 437 | Effect.gen(function* () { | |
| 438 | const isDesiredTaken = yield* branchExists(cwd, desiredBranch); | |
| 439 | if (!isDesiredTaken) { | |
| 440 | return desiredBranch; | |
| 441 | } | |
| 355 | 442 | |
| 356 | const fetchUpstreamRef = ( | |
| 357 | cwd: string, | |
| 358 | upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, | |
| 359 | ): Effect.Effect<void, GitCommandError> => { | |
| 360 | const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; | |
| 361 | return runGit( | |
| 362 | "GitCore.fetchUpstreamRef", | |
| 363 | cwd, | |
| 364 | ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], | |
| 365 | true, | |
| 366 | ); | |
| 367 | }; | |
| 443 | for (let suffix = 1; suffix <= 100; suffix += 1) { | |
| 444 | const candidate = `${desiredBranch}-${suffix}`; | |
| 445 | const isCandidateTaken = yield* branchExists(cwd, candidate); | |
| 446 | if (!isCandidateTaken) { | |
| 447 | return candidate; | |
| 448 | } | |
| 449 | } | |
| 368 | 450 | |
| 369 | const fetchUpstreamRefForStatus = ( | |
| 370 | cwd: string, | |
| 371 | upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, | |
| 372 | ): Effect.Effect<void, GitCommandError> => { | |
| 373 | const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; | |
| 374 | return executeGit( | |
| 375 | "GitCore.fetchUpstreamRefForStatus", | |
| 376 | cwd, | |
| 377 | ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], | |
| 378 | { | |
| 379 | allowNonZeroExit: true, | |
| 380 | timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), | |
| 381 | }, | |
| 382 | ).pipe(Effect.asVoid); | |
| 383 | }; | |
| 451 | return yield* createGitCommandError( | |
| 452 | "GitCore.renameBranch", | |
| 453 | cwd, | |
| 454 | ["branch", "-m", "--", desiredBranch], | |
| 455 | `Could not find an available branch name for '${desiredBranch}'.`, | |
| 456 | ); | |
| 457 | }); | |
| 384 | 458 | |
| 385 | const statusUpstreamRefreshCache = yield* Cache.makeWith({ | |
| 386 | capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, | |
| 387 | lookup: (cacheKey: StatusUpstreamRefreshCacheKey) => | |
| 459 | const resolveCurrentUpstream = ( | |
| 460 | cwd: string, | |
| 461 | ): Effect.Effect< | |
| 462 | { upstreamRef: string; remoteName: string; upstreamBranch: string } | null, | |
| 463 | GitCommandError | |
| 464 | > => | |
| 388 | 465 | Effect.gen(function* () { |
| 389 | yield* fetchUpstreamRefForStatus(cacheKey.cwd, { | |
| 390 | upstreamRef: cacheKey.upstreamRef, | |
| 391 | remoteName: cacheKey.remoteName, | |
| 392 | upstreamBranch: cacheKey.upstreamBranch, | |
| 393 | }); | |
| 394 | return true as const; | |
| 395 | }), | |
| 396 | // Keep successful refreshes warm; drop failures immediately so next request can retry. | |
| 397 | timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_UPSTREAM_REFRESH_INTERVAL : Duration.zero), | |
| 398 | }); | |
| 399 | ||
| 400 | const refreshStatusUpstreamIfStale = (cwd: string): Effect.Effect<void, GitCommandError> => | |
| 401 | Effect.gen(function* () { | |
| 402 | const upstream = yield* resolveCurrentUpstream(cwd); | |
| 403 | if (!upstream) return; | |
| 404 | yield* Cache.get( | |
| 405 | statusUpstreamRefreshCache, | |
| 406 | new StatusUpstreamRefreshCacheKey({ | |
| 466 | const upstreamRef = yield* runGitStdout( | |
| 467 | "GitCore.resolveCurrentUpstream", | |
| 407 | 468 | cwd, |
| 408 | upstreamRef: upstream.upstreamRef, | |
| 409 | remoteName: upstream.remoteName, | |
| 410 | upstreamBranch: upstream.upstreamBranch, | |
| 411 | }), | |
| 412 | ); | |
| 413 | }); | |
| 469 | ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], | |
| 470 | true, | |
| 471 | ).pipe(Effect.map((stdout) => stdout.trim())); | |
| 414 | 472 | |
| 415 | const refreshCheckedOutBranchUpstream = (cwd: string): Effect.Effect<void, GitCommandError> => | |
| 416 | Effect.gen(function* () { | |
| 417 | const upstream = yield* resolveCurrentUpstream(cwd); | |
| 418 | if (!upstream) return; | |
| 419 | yield* fetchUpstreamRef(cwd, upstream); | |
| 420 | }); | |
| 421 | ||
| 422 | const resolveDefaultBranchName = ( | |
| 423 | cwd: string, | |
| 424 | remoteName: string, | |
| 425 | ): Effect.Effect<string | null, GitCommandError> => | |
| 426 | executeGit( | |
| 427 | "GitCore.resolveDefaultBranchName", | |
| 428 | cwd, | |
| 429 | ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`], | |
| 430 | { allowNonZeroExit: true }, | |
| 431 | ).pipe( | |
| 432 | Effect.map((result) => { | |
| 433 | if (result.code !== 0) { | |
| 473 | if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { | |
| 434 | 474 | return null; |
| 435 | 475 | } |
| 436 | return parseDefaultBranchFromRemoteHeadRef(result.stdout, remoteName); | |
| 437 | }), | |
| 438 | ); | |
| 439 | ||
| 440 | const remoteBranchExists = ( | |
| 441 | cwd: string, | |
| 442 | remoteName: string, | |
| 443 | branch: string, | |
| 444 | ): Effect.Effect<boolean, GitCommandError> => | |
| 445 | executeGit( | |
| 446 | "GitCore.remoteBranchExists", | |
| 447 | cwd, | |
| 448 | ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${branch}`], | |
| 449 | { | |
| 450 | allowNonZeroExit: true, | |
| 451 | }, | |
| 452 | ).pipe(Effect.map((result) => result.code === 0)); | |
| 453 | ||
| 454 | const originRemoteExists = (cwd: string): Effect.Effect<boolean, GitCommandError> => | |
| 455 | executeGit("GitCore.originRemoteExists", cwd, ["remote", "get-url", "origin"], { | |
| 456 | allowNonZeroExit: true, | |
| 457 | }).pipe(Effect.map((result) => result.code === 0)); | |
| 458 | 476 | |
| 459 | const listRemoteNames = (cwd: string): Effect.Effect<ReadonlyArray<string>, GitCommandError> => | |
| 460 | runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe( | |
| 461 | Effect.map((stdout) => parseRemoteNames(stdout).toReversed()), | |
| 462 | ); | |
| 477 | const separatorIndex = upstreamRef.indexOf("/"); | |
| 478 | if (separatorIndex <= 0) { | |
| 479 | return null; | |
| 480 | } | |
| 481 | const remoteName = upstreamRef.slice(0, separatorIndex); | |
| 482 | const upstreamBranch = upstreamRef.slice(separatorIndex + 1); | |
| 483 | if (remoteName.length === 0 || upstreamBranch.length === 0) { | |
| 484 | return null; | |
| 485 | } | |
| 463 | 486 | |
| 464 | const resolvePrimaryRemoteName = (cwd: string): Effect.Effect<string, GitCommandError> => | |
| 465 | Effect.gen(function* () { | |
| 466 | if (yield* originRemoteExists(cwd)) { | |
| 467 | return "origin"; | |
| 468 | } | |
| 469 | const remotes = yield* listRemoteNames(cwd); | |
| 470 | const [firstRemote] = remotes; | |
| 471 | if (firstRemote) { | |
| 472 | return firstRemote; | |
| 473 | } | |
| 474 | return yield* createGitCommandError( | |
| 475 | "GitCore.resolvePrimaryRemoteName", | |
| 476 | cwd, | |
| 477 | ["remote"], | |
| 478 | "No git remote is configured for this repository.", | |
| 479 | ); | |
| 480 | }); | |
| 487 | return { | |
| 488 | upstreamRef, | |
| 489 | remoteName, | |
| 490 | upstreamBranch, | |
| 491 | }; | |
| 492 | }); | |
| 481 | 493 | |
| 482 | const resolvePushRemoteName = ( | |
| 483 | cwd: string, | |
| 484 | branch: string, | |
| 485 | ): Effect.Effect<string | null, GitCommandError> => | |
| 486 | Effect.gen(function* () { | |
| 487 | const branchPushRemote = yield* runGitStdout( | |
| 488 | "GitCore.resolvePushRemoteName.branchPushRemote", | |
| 494 | const fetchUpstreamRef = ( | |
| 495 | cwd: string, | |
| 496 | upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, | |
| 497 | ): Effect.Effect<void, GitCommandError> => { | |
| 498 | const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; | |
| 499 | return runGit( | |
| 500 | "GitCore.fetchUpstreamRef", | |
| 489 | 501 | cwd, |
| 490 | ["config", "--get", `branch.${branch}.pushRemote`], | |
| 502 | ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], | |
| 491 | 503 | true, |
| 492 | ).pipe(Effect.map((stdout) => stdout.trim())); | |
| 493 | if (branchPushRemote.length > 0) { | |
| 494 | return branchPushRemote; | |
| 495 | } | |
| 504 | ); | |
| 505 | }; | |
| 496 | 506 | |
| 497 | const pushDefaultRemote = yield* runGitStdout( | |
| 498 | "GitCore.resolvePushRemoteName.remotePushDefault", | |
| 507 | const fetchUpstreamRefForStatus = ( | |
| 508 | cwd: string, | |
| 509 | upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, | |
| 510 | ): Effect.Effect<void, GitCommandError> => { | |
| 511 | const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; | |
| 512 | return executeGit( | |
| 513 | "GitCore.fetchUpstreamRefForStatus", | |
| 499 | 514 | cwd, |
| 500 | ["config", "--get", "remote.pushDefault"], | |
| 501 | true, | |
| 502 | ).pipe(Effect.map((stdout) => stdout.trim())); | |
| 503 | if (pushDefaultRemote.length > 0) { | |
| 504 | return pushDefaultRemote; | |
| 505 | } | |
| 515 | ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], | |
| 516 | { | |
| 517 | allowNonZeroExit: true, | |
| 518 | timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), | |
| 519 | }, | |
| 520 | ).pipe(Effect.asVoid); | |
| 521 | }; | |
| 506 | 522 | |
| 507 | return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null))); | |
| 523 | const statusUpstreamRefreshCache = yield* Cache.makeWith({ | |
| 524 | capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY, | |
| 525 | lookup: (cacheKey: StatusUpstreamRefreshCacheKey) => | |
| 526 | Effect.gen(function* () { | |
| 527 | yield* fetchUpstreamRefForStatus(cacheKey.cwd, { | |
| 528 | upstreamRef: cacheKey.upstreamRef, | |
| 529 | remoteName: cacheKey.remoteName, | |
| 530 | upstreamBranch: cacheKey.upstreamBranch, | |
| 531 | }); | |
| 532 | return true as const; | |
| 533 | }), | |
| 534 | // Keep successful refreshes warm; drop failures immediately so next request can retry. | |
| 535 | timeToLive: (exit) => | |
| 536 | Exit.isSuccess(exit) ? STATUS_UPSTREAM_REFRESH_INTERVAL : Duration.zero, | |
| 508 | 537 | }); |
| 509 | 538 | |
| 510 | const ensureRemote: GitCoreShape["ensureRemote"] = (input) => | |
| 511 | Effect.gen(function* () { | |
| 512 | const preferredName = sanitizeRemoteName(input.preferredName); | |
| 513 | const normalizedTargetUrl = normalizeRemoteUrl(input.url); | |
| 514 | const remoteFetchUrls = yield* runGitStdout( | |
| 515 | "GitCore.ensureRemote.listRemoteUrls", | |
| 516 | input.cwd, | |
| 517 | ["remote", "-v"], | |
| 518 | ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); | |
| 519 | ||
| 520 | for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { | |
| 521 | if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) { | |
| 522 | return remoteName; | |
| 523 | } | |
| 524 | } | |
| 539 | const refreshStatusUpstreamIfStale = (cwd: string): Effect.Effect<void, GitCommandError> => | |
| 540 | Effect.gen(function* () { | |
| 541 | const upstream = yield* resolveCurrentUpstream(cwd); | |
| 542 | if (!upstream) return; | |
| 543 | yield* Cache.get( | |
| 544 | statusUpstreamRefreshCache, | |
| 545 | new StatusUpstreamRefreshCacheKey({ | |
| 546 | cwd, | |
| 547 | upstreamRef: upstream.upstreamRef, | |
| 548 | remoteName: upstream.remoteName, | |
| 549 | upstreamBranch: upstream.upstreamBranch, | |
| 550 | }), | |
| 551 | ); | |
| 552 | }); | |
| 525 | 553 | |
| 526 | let remoteName = preferredName; | |
| 527 | let suffix = 1; | |
| 528 | while (remoteFetchUrls.has(remoteName)) { | |
| 529 | remoteName = `${preferredName}-${suffix}`; | |
| 530 | suffix += 1; | |
| 531 | } | |
| 554 | const refreshCheckedOutBranchUpstream = (cwd: string): Effect.Effect<void, GitCommandError> => | |
| 555 | Effect.gen(function* () { | |
| 556 | const upstream = yield* resolveCurrentUpstream(cwd); | |
| 557 | if (!upstream) return; | |
| 558 | yield* fetchUpstreamRef(cwd, upstream); | |
| 559 | }); | |
| 532 | 560 | |
| 533 | yield* runGit("GitCore.ensureRemote.add", input.cwd, [ | |
| 534 | "remote", | |
| 535 | "add", | |
| 536 | remoteName, | |
| 537 | input.url, | |
| 538 | ]); | |
| 539 | return remoteName; | |
| 540 | }); | |
| 561 | const resolveDefaultBranchName = ( | |
| 562 | cwd: string, | |
| 563 | remoteName: string, | |
| 564 | ): Effect.Effect<string | null, GitCommandError> => | |
| 565 | executeGit( | |
| 566 | "GitCore.resolveDefaultBranchName", | |
| 567 | cwd, | |
| 568 | ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`], | |
| 569 | { allowNonZeroExit: true }, | |
| 570 | ).pipe( | |
| 571 | Effect.map((result) => { | |
| 572 | if (result.code !== 0) { | |
| 573 | return null; | |
| 574 | } | |
| 575 | return parseDefaultBranchFromRemoteHeadRef(result.stdout, remoteName); | |
| 576 | }), | |
| 577 | ); | |
| 541 | 578 | |
| 542 | const resolveBaseBranchForNoUpstream = ( | |
| 543 | cwd: string, | |
| 544 | branch: string, | |
| 545 | ): Effect.Effect<string | null, GitCommandError> => | |
| 546 | Effect.gen(function* () { | |
| 547 | const configuredBaseBranch = yield* runGitStdout( | |
| 548 | "GitCore.resolveBaseBranchForNoUpstream.config", | |
| 579 | const remoteBranchExists = ( | |
| 580 | cwd: string, | |
| 581 | remoteName: string, | |
| 582 | branch: string, | |
| 583 | ): Effect.Effect<boolean, GitCommandError> => | |
| 584 | executeGit( | |
| 585 | "GitCore.remoteBranchExists", | |
| 549 | 586 | cwd, |
| 550 | ["config", "--get", `branch.${branch}.gh-merge-base`], | |
| 551 | true, | |
| 552 | ).pipe(Effect.map((stdout) => stdout.trim())); | |
| 587 | ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${branch}`], | |
| 588 | { | |
| 589 | allowNonZeroExit: true, | |
| 590 | }, | |
| 591 | ).pipe(Effect.map((result) => result.code === 0)); | |
| 592 | ||
| 593 | const originRemoteExists = (cwd: string): Effect.Effect<boolean, GitCommandError> => | |
| 594 | executeGit("GitCore.originRemoteExists", cwd, ["remote", "get-url", "origin"], { | |
| 595 | allowNonZeroExit: true, | |
| 596 | }).pipe(Effect.map((result) => result.code === 0)); | |
| 553 | 597 | |
| 554 | const primaryRemoteName = yield* resolvePrimaryRemoteName(cwd).pipe( | |
| 555 | Effect.catch(() => Effect.succeed(null)), | |
| 598 | const listRemoteNames = (cwd: string): Effect.Effect<ReadonlyArray<string>, GitCommandError> => | |
| 599 | runGitStdout("GitCore.listRemoteNames", cwd, ["remote"]).pipe( | |
| 600 | Effect.map((stdout) => parseRemoteNames(stdout).toReversed()), | |
| 556 | 601 | ); |
| 557 | const defaultBranch = | |
| 558 | primaryRemoteName === null ? null : yield* resolveDefaultBranchName(cwd, primaryRemoteName); | |
| 559 | const candidates = [ | |
| 560 | configuredBaseBranch.length > 0 ? configuredBaseBranch : null, | |
| 561 | defaultBranch, | |
| 562 | ...DEFAULT_BASE_BRANCH_CANDIDATES, | |
| 563 | ]; | |
| 564 | ||
| 565 | for (const candidate of candidates) { | |
| 566 | if (!candidate) { | |
| 567 | continue; | |
| 568 | } | |
| 569 | 602 | |
| 570 | const remotePrefix = | |
| 571 | primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null; | |
| 572 | const normalizedCandidate = candidate.startsWith("origin/") | |
| 573 | ? candidate.slice("origin/".length) | |
| 574 | : remotePrefix && candidate.startsWith(remotePrefix) | |
| 575 | ? candidate.slice(remotePrefix.length) | |
| 576 | : candidate; | |
| 577 | if (normalizedCandidate.length === 0 || normalizedCandidate === branch) { | |
| 578 | continue; | |
| 603 | const resolvePrimaryRemoteName = (cwd: string): Effect.Effect<string, GitCommandError> => | |
| 604 | Effect.gen(function* () { | |
| 605 | if (yield* originRemoteExists(cwd)) { | |
| 606 | return "origin"; | |
| 579 | 607 | } |
| 608 | const remotes = yield* listRemoteNames(cwd); | |
| 609 | const [firstRemote] = remotes; | |
| 610 | if (firstRemote) { | |
| 611 | return firstRemote; | |
| 612 | } | |
| 613 | return yield* createGitCommandError( | |
| 614 | "GitCore.resolvePrimaryRemoteName", | |
| 615 | cwd, | |
| 616 | ["remote"], | |
| 617 | "No git remote is configured for this repository.", | |
| 618 | ); | |
| 619 | }); | |
| 580 | 620 | |
| 581 | if (yield* branchExists(cwd, normalizedCandidate)) { | |
| 582 | return normalizedCandidate; | |
| 621 | const resolvePushRemoteName = ( | |
| 622 | cwd: string, | |
| 623 | branch: string, | |
| 624 | ): Effect.Effect<string | null, GitCommandError> => | |
| 625 | Effect.gen(function* () { | |
| 626 | const branchPushRemote = yield* runGitStdout( | |
| 627 | "GitCore.resolvePushRemoteName.branchPushRemote", | |
| 628 | cwd, | |
| 629 | ["config", "--get", `branch.${branch}.pushRemote`], | |
| 630 | true, | |
| 631 | ).pipe(Effect.map((stdout) => stdout.trim())); | |
| 632 | if (branchPushRemote.length > 0) { | |
| 633 | return branchPushRemote; | |
| 583 | 634 | } |
| 584 | 635 | |
| 585 | if ( | |
| 586 | primaryRemoteName && | |
| 587 | (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate)) | |
| 588 | ) { | |
| 589 | return `${primaryRemoteName}/${normalizedCandidate}`; | |
| 636 | const pushDefaultRemote = yield* runGitStdout( | |
| 637 | "GitCore.resolvePushRemoteName.remotePushDefault", | |
| 638 | cwd, | |
| 639 | ["config", "--get", "remote.pushDefault"], | |
| 640 | true, | |
| 641 | ).pipe(Effect.map((stdout) => stdout.trim())); | |
| 642 | if (pushDefaultRemote.length > 0) { | |
| 643 | return pushDefaultRemote; | |
| 590 | 644 | } |
| 591 | } | |
| 592 | 645 | |
| 593 | return null; | |
| 594 | }); | |
| 646 | return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null))); | |
| 647 | }); | |
| 595 | 648 | |
| 596 | const computeAheadCountAgainstBase = ( | |
| 597 | cwd: string, | |
| 598 | branch: string, | |
| 599 | ): Effect.Effect<number, GitCommandError> => | |
| 600 | Effect.gen(function* () { | |
| 601 | const baseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch); | |
| 602 | if (!baseBranch) { | |
| 603 | return 0; | |
| 604 | } | |
| 649 | const ensureRemote: GitCoreShape["ensureRemote"] = (input) => | |
| 650 | Effect.gen(function* () { | |
| 651 | const preferredName = sanitizeRemoteName(input.preferredName); | |
| 652 | const normalizedTargetUrl = normalizeRemoteUrl(input.url); | |
| 653 | const remoteFetchUrls = yield* runGitStdout( | |
| 654 | "GitCore.ensureRemote.listRemoteUrls", | |
| 655 | input.cwd, | |
| 656 | ["remote", "-v"], | |
| 657 | ).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); | |
| 605 | 658 | |
| 606 | const result = yield* executeGit( | |
| 607 | "GitCore.computeAheadCountAgainstBase", | |
| 608 | cwd, | |
| 609 | ["rev-list", "--count", `${baseBranch}..HEAD`], | |
| 610 | { allowNonZeroExit: true }, | |
| 611 | ); | |
| 612 | if (result.code !== 0) { | |
| 613 | return 0; | |
| 614 | } | |
| 659 | for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { | |
| 660 | if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) { | |
| 661 | return remoteName; | |
| 662 | } | |
| 663 | } | |
| 615 | 664 | |
| 616 | const parsed = Number.parseInt(result.stdout.trim(), 10); | |
| 617 | return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; | |
| 618 | }); | |
| 665 | let remoteName = preferredName; | |
| 666 | let suffix = 1; | |
| 667 | while (remoteFetchUrls.has(remoteName)) { | |
| 668 | remoteName = `${preferredName}-${suffix}`; | |
| 669 | suffix += 1; | |
| 670 | } | |
| 619 | 671 | |
| 620 | const readBranchRecency = (cwd: string): Effect.Effect<Map<string, number>, GitCommandError> => | |
| 621 | Effect.gen(function* () { | |
| 622 | const branchRecency = yield* executeGit( | |
| 623 | "GitCore.readBranchRecency", | |
| 624 | cwd, | |
| 625 | [ | |
| 626 | "for-each-ref", | |
| 627 | "--format=%(refname:short)%09%(committerdate:unix)", | |
| 628 | "refs/heads", | |
| 629 | "refs/remotes", | |
| 630 | ], | |
| 631 | { | |
| 632 | timeoutMs: 15_000, | |
| 633 | allowNonZeroExit: true, | |
| 634 | }, | |
| 635 | ); | |
| 672 | yield* runGit("GitCore.ensureRemote.add", input.cwd, [ | |
| 673 | "remote", | |
| 674 | "add", | |
| 675 | remoteName, | |
| 676 | input.url, | |
| 677 | ]); | |
| 678 | return remoteName; | |
| 679 | }); | |
| 636 | 680 | |
| 637 | const branchLastCommit = new Map<string, number>(); | |
| 638 | if (branchRecency.code !== 0) { | |
| 639 | return branchLastCommit; | |
| 640 | } | |
| 681 | const resolveBaseBranchForNoUpstream = ( | |
| 682 | cwd: string, | |
| 683 | branch: string, | |
| 684 | ): Effect.Effect<string | null, GitCommandError> => | |
| 685 | Effect.gen(function* () { | |
| 686 | const configuredBaseBranch = yield* runGitStdout( | |
| 687 | "GitCore.resolveBaseBranchForNoUpstream.config", | |
| 688 | cwd, | |
| 689 | ["config", "--get", `branch.${branch}.gh-merge-base`], | |
| 690 | true, | |
| 691 | ).pipe(Effect.map((stdout) => stdout.trim())); | |
| 641 | 692 | |
| 642 | for (const line of branchRecency.stdout.split("\n")) { | |
| 643 | if (line.length === 0) { | |
| 644 | continue; | |
| 645 | } | |
| 646 | const [name, lastCommitRaw] = line.split("\t"); | |
| 647 | if (!name) { | |
| 648 | continue; | |
| 649 | } | |
| 650 | const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10); | |
| 651 | branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0); | |
| 652 | } | |
| 693 | const primaryRemoteName = yield* resolvePrimaryRemoteName(cwd).pipe( | |
| 694 | Effect.catch(() => Effect.succeed(null)), | |
| 695 | ); | |
| 696 | const defaultBranch = | |
| 697 | primaryRemoteName === null | |
| 698 | ? null | |
| 699 | : yield* resolveDefaultBranchName(cwd, primaryRemoteName); | |
| 700 | const candidates = [ | |
| 701 | configuredBaseBranch.length > 0 ? configuredBaseBranch : null, | |
| 702 | defaultBranch, | |
| 703 | ...DEFAULT_BASE_BRANCH_CANDIDATES, | |
| 704 | ]; | |
| 705 | ||
| 706 | for (const candidate of candidates) { | |
| 707 | if (!candidate) { | |
| 708 | continue; | |
| 709 | } | |
| 653 | 710 | |
| 654 | return branchLastCommit; | |
| 655 | }); | |
| 711 | const remotePrefix = | |
| 712 | primaryRemoteName && primaryRemoteName !== "origin" ? `${primaryRemoteName}/` : null; | |
| 713 | const normalizedCandidate = candidate.startsWith("origin/") | |
| 714 | ? candidate.slice("origin/".length) | |
| 715 | : remotePrefix && candidate.startsWith(remotePrefix) | |
| 716 | ? candidate.slice(remotePrefix.length) | |
| 717 | : candidate; | |
| 718 | if (normalizedCandidate.length === 0 || normalizedCandidate === branch) { | |
| 719 | continue; | |
| 720 | } | |
| 656 | 721 | |
| 657 | const statusDetails: GitCoreShape["statusDetails"] = (cwd) => | |
| 658 | Effect.gen(function* () { | |
| 659 | yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true })); | |
| 660 | ||
| 661 | const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all( | |
| 662 | [ | |
| 663 | runGitStdout("GitCore.statusDetails.status", cwd, [ | |
| 664 | "status", | |
| 665 | "--porcelain=2", | |
| 666 | "--branch", | |
| 667 | ]), | |
| 668 | runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), | |
| 669 | runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, [ | |
| 670 | "diff", | |
| 671 | "--cached", | |
| 672 | "--numstat", | |
| 673 | ]), | |
| 674 | ], | |
| 675 | { concurrency: "unbounded" }, | |
| 676 | ); | |
| 722 | if (yield* branchExists(cwd, normalizedCandidate)) { | |
| 723 | return normalizedCandidate; | |
| 724 | } | |
| 677 | 725 | |
| 678 | let branch: string | null = null; | |
| 679 | let upstreamRef: string | null = null; | |
| 680 | let aheadCount = 0; | |
| 681 | let behindCount = 0; | |
| 682 | let hasWorkingTreeChanges = false; | |
| 683 | const changedFilesWithoutNumstat = new Set<string>(); | |
| 684 | ||
| 685 | for (const line of statusStdout.split(/\r?\n/g)) { | |
| 686 | if (line.startsWith("# branch.head ")) { | |
| 687 | const value = line.slice("# branch.head ".length).trim(); | |
| 688 | branch = value.startsWith("(") ? null : value; | |
| 689 | continue; | |
| 690 | } | |
| 691 | if (line.startsWith("# branch.upstream ")) { | |
| 692 | const value = line.slice("# branch.upstream ".length).trim(); | |
| 693 | upstreamRef = value.length > 0 ? value : null; | |
| 694 | continue; | |
| 726 | if ( | |
| 727 | primaryRemoteName && | |
| 728 | (yield* remoteBranchExists(cwd, primaryRemoteName, normalizedCandidate)) | |
| 729 | ) { | |
| 730 | return `${primaryRemoteName}/${normalizedCandidate}`; | |
| 731 | } | |
| 695 | 732 | } |
| 696 | if (line.startsWith("# branch.ab ")) { | |
| 697 | const value = line.slice("# branch.ab ".length).trim(); | |
| 698 | const parsed = parseBranchAb(value); | |
| 699 | aheadCount = parsed.ahead; | |
| 700 | behindCount = parsed.behind; | |
| 701 | continue; | |
| 733 | ||
| 734 | return null; | |
| 735 | }); | |
| 736 | ||
| 737 | const computeAheadCountAgainstBase = ( | |
| 738 | cwd: string, | |
| 739 | branch: string, | |
| 740 | ): Effect.Effect<number, GitCommandError> => | |
| 741 | Effect.gen(function* () { | |
| 742 | const baseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch); | |
| 743 | if (!baseBranch) { | |
| 744 | return 0; | |
| 702 | 745 | } |
| 703 | if (line.trim().length > 0 && !line.startsWith("#")) { | |
| 704 | hasWorkingTreeChanges = true; | |
| 705 | const pathValue = parsePorcelainPath(line); | |
| 706 | if (pathValue) changedFilesWithoutNumstat.add(pathValue); | |
| 746 | ||
| 747 | const result = yield* executeGit( | |
| 748 | "GitCore.computeAheadCountAgainstBase", | |
| 749 | cwd, | |
| 750 | ["rev-list", "--count", `${baseBranch}..HEAD`], | |
| 751 | { allowNonZeroExit: true }, | |
| 752 | ); | |
| 753 | if (result.code !== 0) { | |
| 754 | return 0; | |
| 707 | 755 | } |
| 708 | } | |
| 709 | 756 | |
| 710 | if (!upstreamRef && branch) { | |
| 711 | aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe( | |
| 712 | Effect.catch(() => Effect.succeed(0)), | |
| 757 | const parsed = Number.parseInt(result.stdout.trim(), 10); | |
| 758 | return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; | |
| 759 | }); | |
| 760 | ||
| 761 | const readBranchRecency = (cwd: string): Effect.Effect<Map<string, number>, GitCommandError> => | |
| 762 | Effect.gen(function* () { | |
| 763 | const branchRecency = yield* executeGit( | |
| 764 | "GitCore.readBranchRecency", | |
| 765 | cwd, | |
| 766 | [ | |
| 767 | "for-each-ref", | |
| 768 | "--format=%(refname:short)%09%(committerdate:unix)", | |
| 769 | "refs/heads", | |
| 770 | "refs/remotes", | |
| 771 | ], | |
| 772 | { | |
| 773 | timeoutMs: 15_000, | |
| 774 | allowNonZeroExit: true, | |
| 775 | }, | |
| 713 | 776 | ); |
| 714 | behindCount = 0; | |
| 715 | } | |
| 716 | 777 | |
| 717 | const stagedEntries = parseNumstatEntries(stagedNumstatStdout); | |
| 718 | const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); | |
| 719 | const fileStatMap = new Map<string, { insertions: number; deletions: number }>(); | |
| 720 | for (const entry of [...stagedEntries, ...unstagedEntries]) { | |
| 721 | const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 }; | |
| 722 | existing.insertions += entry.insertions; | |
| 723 | existing.deletions += entry.deletions; | |
| 724 | fileStatMap.set(entry.path, existing); | |
| 725 | } | |
| 778 | const branchLastCommit = new Map<string, number>(); | |
| 779 | if (branchRecency.code !== 0) { | |
| 780 | return branchLastCommit; | |
| 781 | } | |
| 726 | 782 | |
| 727 | let insertions = 0; | |
| 728 | let deletions = 0; | |
| 729 | const files = Array.from(fileStatMap.entries()) | |
| 730 | .map(([filePath, stat]) => { | |
| 731 | insertions += stat.insertions; | |
| 732 | deletions += stat.deletions; | |
| 733 | return { path: filePath, insertions: stat.insertions, deletions: stat.deletions }; | |
| 734 | }) | |
| 735 | .toSorted((a, b) => a.path.localeCompare(b.path)); | |
| 736 | ||
| 737 | for (const filePath of changedFilesWithoutNumstat) { | |
| 738 | if (fileStatMap.has(filePath)) continue; | |
| 739 | files.push({ path: filePath, insertions: 0, deletions: 0 }); | |
| 740 | } | |
| 741 | files.sort((a, b) => a.path.localeCompare(b.path)); | |
| 742 | ||
| 743 | return { | |
| 744 | branch, | |
| 745 | upstreamRef, | |
| 746 | hasWorkingTreeChanges, | |
| 747 | workingTree: { | |
| 748 | files, | |
| 749 | insertions, | |
| 750 | deletions, | |
| 751 | }, | |
| 752 | hasUpstream: upstreamRef !== null, | |
| 753 | aheadCount, | |
| 754 | behindCount, | |
| 755 | }; | |
| 756 | }); | |
| 783 | for (const line of branchRecency.stdout.split("\n")) { | |
| 784 | if (line.length === 0) { | |
| 785 | continue; | |
| 786 | } | |
| 787 | const [name, lastCommitRaw] = line.split("\t"); | |
| 788 | if (!name) { | |
| 789 | continue; | |
| 790 | } | |
| 791 | const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10); | |
| 792 | branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0); | |
| 793 | } | |
| 757 | 794 | |
| 758 | const status: GitCoreShape["status"] = (input) => | |
| 759 | statusDetails(input.cwd).pipe( | |
| 760 | Effect.map((details) => ({ | |
| 761 | branch: details.branch, | |
| 762 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, | |
| 763 | workingTree: details.workingTree, | |
| 764 | hasUpstream: details.hasUpstream, | |
| 765 | aheadCount: details.aheadCount, | |
| 766 | behindCount: details.behindCount, | |
| 767 | pr: null, | |
| 768 | })), | |
| 769 | ); | |
| 770 | ||
| 771 | const prepareCommitContext: GitCoreShape["prepareCommitContext"] = (cwd, filePaths) => | |
| 772 | Effect.gen(function* () { | |
| 773 | if (filePaths && filePaths.length > 0) { | |
| 774 | yield* runGit("GitCore.prepareCommitContext.reset", cwd, ["reset"]).pipe( | |
| 775 | Effect.catch(() => Effect.void), | |
| 795 | return branchLastCommit; | |
| 796 | }); | |
| 797 | ||
| 798 | const statusDetails: GitCoreShape["statusDetails"] = (cwd) => | |
| 799 | Effect.gen(function* () { | |
| 800 | yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true })); | |
| 801 | ||
| 802 | const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all( | |
| 803 | [ | |
| 804 | runGitStdout("GitCore.statusDetails.status", cwd, [ | |
| 805 | "status", | |
| 806 | "--porcelain=2", | |
| 807 | "--branch", | |
| 808 | ]), | |
| 809 | runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), | |
| 810 | runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, [ | |
| 811 | "diff", | |
| 812 | "--cached", | |
| 813 | "--numstat", | |
| 814 | ]), | |
| 815 | ], | |
| 816 | { concurrency: "unbounded" }, | |
| 776 | 817 | ); |
| 777 | yield* runGit("GitCore.prepareCommitContext.addSelected", cwd, [ | |
| 778 | "add", | |
| 779 | "-A", | |
| 780 | "--", | |
| 781 | ...filePaths, | |
| 782 | ]); | |
| 783 | } else { | |
| 784 | yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]); | |
| 785 | } | |
| 786 | 818 | |
| 787 | const stagedSummary = yield* runGitStdout("GitCore.prepareCommitContext.stagedSummary", cwd, [ | |
| 788 | "diff", | |
| 789 | "--cached", | |
| 790 | "--name-status", | |
| 791 | ]).pipe(Effect.map((stdout) => stdout.trim())); | |
| 792 | if (stagedSummary.length === 0) { | |
| 793 | return null; | |
| 794 | } | |
| 819 | let branch: string | null = null; | |
| 820 | let upstreamRef: string | null = null; | |
| 821 | let aheadCount = 0; | |
| 822 | let behindCount = 0; | |
| 823 | let hasWorkingTreeChanges = false; | |
| 824 | const changedFilesWithoutNumstat = new Set<string>(); | |
| 825 | ||
| 826 | for (const line of statusStdout.split(/\r?\n/g)) { | |
| 827 | if (line.startsWith("# branch.head ")) { | |
| 828 | const value = line.slice("# branch.head ".length).trim(); | |
| 829 | branch = value.startsWith("(") ? null : value; | |
| 830 | continue; | |
| 831 | } | |
| 832 | if (line.startsWith("# branch.upstream ")) { | |
| 833 | const value = line.slice("# branch.upstream ".length).trim(); | |
| 834 | upstreamRef = value.length > 0 ? value : null; | |
| 835 | continue; | |
| 836 | } | |
| 837 | if (line.startsWith("# branch.ab ")) { | |
| 838 | const value = line.slice("# branch.ab ".length).trim(); | |
| 839 | const parsed = parseBranchAb(value); | |
| 840 | aheadCount = parsed.ahead; | |
| 841 | behindCount = parsed.behind; | |
| 842 | continue; | |
| 843 | } | |
| 844 | if (line.trim().length > 0 && !line.startsWith("#")) { | |
| 845 | hasWorkingTreeChanges = true; | |
| 846 | const pathValue = parsePorcelainPath(line); | |
| 847 | if (pathValue) changedFilesWithoutNumstat.add(pathValue); | |
| 848 | } | |
| 849 | } | |
| 795 | 850 | |
| 796 | const stagedPatch = yield* runGitStdout("GitCore.prepareCommitContext.stagedPatch", cwd, [ | |
| 797 | "diff", | |
| 798 | "--cached", | |
| 799 | "--patch", | |
| 800 | "--minimal", | |
| 801 | ]); | |
| 851 | if (!upstreamRef && branch) { | |
| 852 | aheadCount = yield* computeAheadCountAgainstBase(cwd, branch).pipe( | |
| 853 | Effect.catch(() => Effect.succeed(0)), | |
| 854 | ); | |
| 855 | behindCount = 0; | |
| 856 | } | |
| 802 | 857 | |
| 803 | return { | |
| 804 | stagedSummary, | |
| 805 | stagedPatch, | |
| 806 | }; | |
| 807 | }); | |
| 858 | const stagedEntries = parseNumstatEntries(stagedNumstatStdout); | |
| 859 | const unstagedEntries = parseNumstatEntries(unstagedNumstatStdout); | |
| 860 | const fileStatMap = new Map<string, { insertions: number; deletions: number }>(); | |
| 861 | for (const entry of [...stagedEntries, ...unstagedEntries]) { | |
| 862 | const existing = fileStatMap.get(entry.path) ?? { insertions: 0, deletions: 0 }; | |
| 863 | existing.insertions += entry.insertions; | |
| 864 | existing.deletions += entry.deletions; | |
| 865 | fileStatMap.set(entry.path, existing); | |
| 866 | } | |
| 808 | 867 | |
| 809 | const commit: GitCoreShape["commit"] = (cwd, subject, body) => | |
| 810 | Effect.gen(function* () { | |
| 811 | const args = ["commit", "-m", subject]; | |
| 812 | const trimmedBody = body.trim(); | |
| 813 | if (trimmedBody.length > 0) { | |
| 814 | args.push("-m", trimmedBody); | |
| 815 | } | |
| 816 | yield* runGit("GitCore.commit.commit", cwd, args); | |
| 817 | const commitSha = yield* runGitStdout("GitCore.commit.revParseHead", cwd, [ | |
| 818 | "rev-parse", | |
| 819 | "HEAD", | |
| 820 | ]).pipe(Effect.map((stdout) => stdout.trim())); | |
| 868 | let insertions = 0; | |
| 869 | let deletions = 0; | |
| 870 | const files = Array.from(fileStatMap.entries()) | |
| 871 | .map(([filePath, stat]) => { | |
| 872 | insertions += stat.insertions; | |
| 873 | deletions += stat.deletions; | |
| 874 | return { path: filePath, insertions: stat.insertions, deletions: stat.deletions }; | |
| 875 | }) | |
| 876 | .toSorted((a, b) => a.path.localeCompare(b.path)); | |
| 877 | ||
| 878 | for (const filePath of changedFilesWithoutNumstat) { | |
| 879 | if (fileStatMap.has(filePath)) continue; | |
| 880 | files.push({ path: filePath, insertions: 0, deletions: 0 }); | |
| 881 | } | |
| 882 | files.sort((a, b) => a.path.localeCompare(b.path)); | |
| 821 | 883 | |
| 822 | return { commitSha }; | |
| 823 | }); | |
| 884 | return { | |
| 885 | branch, | |
| 886 | upstreamRef, | |
| 887 | hasWorkingTreeChanges, | |
| 888 | workingTree: { | |
| 889 | files, | |
| 890 | insertions, | |
| 891 | deletions, | |
| 892 | }, | |
| 893 | hasUpstream: upstreamRef !== null, | |
| 894 | aheadCount, | |
| 895 | behindCount, | |
| 896 | }; | |
| 897 | }); | |
| 824 | 898 | |
| 825 | const pushCurrentBranch: GitCoreShape["pushCurrentBranch"] = (cwd, fallbackBranch) => | |
| 826 | Effect.gen(function* () { | |
| 827 | const details = yield* statusDetails(cwd); | |
| 828 | const branch = details.branch ?? fallbackBranch; | |
| 829 | if (!branch) { | |
| 830 | return yield* createGitCommandError( | |
| 831 | "GitCore.pushCurrentBranch", | |
| 899 | const status: GitCoreShape["status"] = (input) => | |
| 900 | statusDetails(input.cwd).pipe( | |
| 901 | Effect.map((details) => ({ | |
| 902 | branch: details.branch, | |
| 903 | hasWorkingTreeChanges: details.hasWorkingTreeChanges, | |
| 904 | workingTree: details.workingTree, | |
| 905 | hasUpstream: details.hasUpstream, | |
| 906 | aheadCount: details.aheadCount, | |
| 907 | behindCount: details.behindCount, | |
| 908 | pr: null, | |
| 909 | })), | |
| 910 | ); | |
| 911 | ||
| 912 | const prepareCommitContext: GitCoreShape["prepareCommitContext"] = (cwd, filePaths) => | |
| 913 | Effect.gen(function* () { | |
| 914 | if (filePaths && filePaths.length > 0) { | |
| 915 | yield* runGit("GitCore.prepareCommitContext.reset", cwd, ["reset"]).pipe( | |
| 916 | Effect.catch(() => Effect.void), | |
| 917 | ); | |
| 918 | yield* runGit("GitCore.prepareCommitContext.addSelected", cwd, [ | |
| 919 | "add", | |
| 920 | "-A", | |
| 921 | "--", | |
| 922 | ...filePaths, | |
| 923 | ]); | |
| 924 | } else { | |
| 925 | yield* runGit("GitCore.prepareCommitContext.addAll", cwd, ["add", "-A"]); | |
| 926 | } | |
| 927 | ||
| 928 | const stagedSummary = yield* runGitStdout( | |
| 929 | "GitCore.prepareCommitContext.stagedSummary", | |
| 832 | 930 | cwd, |
| 833 | ["push"], | |
| 834 | "Cannot push from detached HEAD.", | |
| 835 | ); | |
| 836 | } | |
| 931 | ["diff", "--cached", "--name-status"], | |
| 932 | ).pipe(Effect.map((stdout) => stdout.trim())); | |
| 933 | if (stagedSummary.length === 0) { | |
| 934 | return null; | |
| 935 | } | |
| 837 | 936 | |
| 838 | const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; | |
| 839 | if (hasNoLocalDelta) { | |
| 840 | if (details.hasUpstream) { | |
| 841 | return { | |
| 842 | status: "skipped_up_to_date" as const, | |
| 843 | branch, | |
| 844 | ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), | |
| 845 | }; | |
| 937 | const stagedPatch = yield* runGitStdout("GitCore.prepareCommitContext.stagedPatch", cwd, [ | |
| 938 | "diff", | |
| 939 | "--cached", | |
| 940 | "--patch", | |
| 941 | "--minimal", | |
| 942 | ]); | |
| 943 | ||
| 944 | return { | |
| 945 | stagedSummary, | |
| 946 | stagedPatch, | |
| 947 | }; | |
| 948 | }); | |
| 949 | ||
| 950 | const commit: GitCoreShape["commit"] = (cwd, subject, body) => | |
| 951 | Effect.gen(function* () { | |
| 952 | const args = ["commit", "-m", subject]; | |
| 953 | const trimmedBody = body.trim(); | |
| 954 | if (trimmedBody.length > 0) { | |
| 955 | args.push("-m", trimmedBody); | |
| 846 | 956 | } |
| 957 | yield* runGit("GitCore.commit.commit", cwd, args); | |
| 958 | const commitSha = yield* runGitStdout("GitCore.commit.revParseHead", cwd, [ | |
| 959 | "rev-parse", | |
| 960 | "HEAD", | |
| 961 | ]).pipe(Effect.map((stdout) => stdout.trim())); | |
| 847 | 962 | |
| 848 | const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( | |
| 849 | Effect.catch(() => Effect.succeed(null)), | |
| 850 | ); | |
| 851 | if (comparableBaseBranch) { | |
| 852 | const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( | |
| 853 | Effect.catch(() => Effect.succeed(null)), | |
| 963 | return { commitSha }; | |
| 964 | }); | |
| 965 | ||
| 966 | const pushCurrentBranch: GitCoreShape["pushCurrentBranch"] = (cwd, fallbackBranch) => | |
| 967 | Effect.gen(function* () { | |
| 968 | const details = yield* statusDetails(cwd); | |
| 969 | const branch = details.branch ?? fallbackBranch; | |
| 970 | if (!branch) { | |
| 971 | return yield* createGitCommandError( | |
| 972 | "GitCore.pushCurrentBranch", | |
| 973 | cwd, | |
| 974 | ["push"], | |
| 975 | "Cannot push from detached HEAD.", | |
| 854 | 976 | ); |
| 855 | if (!publishRemoteName) { | |
| 977 | } | |
| 978 | ||
| 979 | const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; | |
| 980 | if (hasNoLocalDelta) { | |
| 981 | if (details.hasUpstream) { | |
| 856 | 982 | return { |
| 857 | 983 | status: "skipped_up_to_date" as const, |
| 858 | 984 | branch, |
| 985 | ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), | |
| 859 | 986 | }; |
| 860 | 987 | } |
| 861 | 988 | |
| 862 | const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe( | |
| 863 | Effect.catch(() => Effect.succeed(false)), | |
| 989 | const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( | |
| 990 | Effect.catch(() => Effect.succeed(null)), | |
| 864 | 991 | ); |
| 865 | if (hasRemoteBranch) { | |
| 866 | return { | |
| 867 | status: "skipped_up_to_date" as const, | |
| 868 | branch, | |
| 869 | }; | |
| 992 | if (comparableBaseBranch) { | |
| 993 | const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( | |
| 994 | Effect.catch(() => Effect.succeed(null)), | |
| 995 | ); | |
| 996 | if (!publishRemoteName) { | |
| 997 | return { | |
| 998 | status: "skipped_up_to_date" as const, | |
| 999 | branch, | |
| 1000 | }; | |
| 1001 | } | |
| 1002 | ||
| 1003 | const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe( | |
| 1004 | Effect.catch(() => Effect.succeed(false)), | |
| 1005 | ); | |
| 1006 | if (hasRemoteBranch) { | |
| 1007 | return { | |
| 1008 | status: "skipped_up_to_date" as const, | |
| 1009 | branch, | |
| 1010 | }; | |
| 1011 | } | |
| 870 | 1012 | } |
| 871 | 1013 | } |
| 872 | } | |
| 873 | 1014 | |
| 874 | if (!details.hasUpstream) { | |
| 875 | const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); | |
| 876 | if (!publishRemoteName) { | |
| 877 | return yield* createGitCommandError( | |
| 878 | "GitCore.pushCurrentBranch", | |
| 879 | cwd, | |
| 880 | ["push"], | |
| 881 | "Cannot push because no git remote is configured for this repository.", | |
| 882 | ); | |
| 1015 | if (!details.hasUpstream) { | |
| 1016 | const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); | |
| 1017 | if (!publishRemoteName) { | |
| 1018 | return yield* createGitCommandError( | |
| 1019 | "GitCore.pushCurrentBranch", | |
| 1020 | cwd, | |
| 1021 | ["push"], | |
| 1022 | "Cannot push because no git remote is configured for this repository.", | |
| 1023 | ); | |
| 1024 | } | |
| 1025 | yield* runGit("GitCore.pushCurrentBranch.pushWithUpstream", cwd, [ | |
| 1026 | "push", | |
| 1027 | "-u", | |
| 1028 | publishRemoteName, | |
| 1029 | branch, | |
| 1030 | ]); | |
| 1031 | return { | |
| 1032 | status: "pushed" as const, | |
| 1033 | branch, | |
| 1034 | upstreamBranch: `${publishRemoteName}/${branch}`, | |
| 1035 | setUpstream: true, | |
| 1036 | }; | |
| 883 | 1037 | } |
| 884 | yield* runGit("GitCore.pushCurrentBranch.pushWithUpstream", cwd, [ | |
| 885 | "push", | |
| 886 | "-u", | |
| 887 | publishRemoteName, | |
| 888 | branch, | |
| 889 | ]); | |
| 890 | return { | |
| 891 | status: "pushed" as const, | |
| 892 | branch, | |
| 893 | upstreamBranch: `${publishRemoteName}/${branch}`, | |
| 894 | setUpstream: true, | |
| 895 | }; | |
| 896 | } | |
| 897 | 1038 | |
| 898 | const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe( | |
| 899 | Effect.catch(() => Effect.succeed(null)), | |
| 900 | ); | |
| 901 | if (currentUpstream) { | |
| 902 | yield* runGit("GitCore.pushCurrentBranch.pushUpstream", cwd, [ | |
| 903 | "push", | |
| 904 | currentUpstream.remoteName, | |
| 905 | `HEAD:${currentUpstream.upstreamBranch}`, | |
| 906 | ]); | |
| 1039 | const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe( | |
| 1040 | Effect.catch(() => Effect.succeed(null)), | |
| 1041 | ); | |
| 1042 | if (currentUpstream) { | |
| 1043 | yield* runGit("GitCore.pushCurrentBranch.pushUpstream", cwd, [ | |
| 1044 | "push", | |
| 1045 | currentUpstream.remoteName, | |
| 1046 | `HEAD:${currentUpstream.upstreamBranch}`, | |
| 1047 | ]); | |
| 1048 | return { | |
| 1049 | status: "pushed" as const, | |
| 1050 | branch, | |
| 1051 | upstreamBranch: currentUpstream.upstreamRef, | |
| 1052 | setUpstream: false, | |
| 1053 | }; | |
| 1054 | } | |
| 1055 | ||
| 1056 | yield* runGit("GitCore.pushCurrentBranch.push", cwd, ["push"]); | |
| 907 | 1057 | return { |
| 908 | 1058 | status: "pushed" as const, |
| 909 | 1059 | branch, |
| 910 | upstreamBranch: currentUpstream.upstreamRef, | |
| 1060 | ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), | |
| 911 | 1061 | setUpstream: false, |
| 912 | 1062 | }; |
| 913 | } | |
| 914 | ||
| 915 | yield* runGit("GitCore.pushCurrentBranch.push", cwd, ["push"]); | |
| 916 | return { | |
| 917 | status: "pushed" as const, | |
| 918 | branch, | |
| 919 | ...(details.upstreamRef ? { upstreamBranch: details.upstreamRef } : {}), | |
| 920 | setUpstream: false, | |
| 921 | }; | |
| 922 | }); | |
| 1063 | }); | |
| 923 | 1064 | |
| 924 | const pullCurrentBranch: GitCoreShape["pullCurrentBranch"] = (cwd) => | |
| 925 | Effect.gen(function* () { | |
| 926 | const details = yield* statusDetails(cwd); | |
| 927 | const branch = details.branch; | |
| 928 | if (!branch) { | |
| 929 | return yield* createGitCommandError( | |
| 930 | "GitCore.pullCurrentBranch", | |
| 1065 | const pullCurrentBranch: GitCoreShape["pullCurrentBranch"] = (cwd) => | |
| 1066 | Effect.gen(function* () { | |
| 1067 | const details = yield* statusDetails(cwd); | |
| 1068 | const branch = details.branch; | |
| 1069 | if (!branch) { | |
| 1070 | return yield* createGitCommandError( | |
| 1071 | "GitCore.pullCurrentBranch", | |
| 1072 | cwd, | |
| 1073 | ["pull", "--ff-only"], | |
| 1074 | "Cannot pull from detached HEAD.", | |
| 1075 | ); | |
| 1076 | } | |
| 1077 | if (!details.hasUpstream) { | |
| 1078 | return yield* createGitCommandError( | |
| 1079 | "GitCore.pullCurrentBranch", | |
| 1080 | cwd, | |
| 1081 | ["pull", "--ff-only"], | |
| 1082 | "Current branch has no upstream configured. Push with upstream first.", | |
| 1083 | ); | |
| 1084 | } | |
| 1085 | const beforeSha = yield* runGitStdout( | |
| 1086 | "GitCore.pullCurrentBranch.beforeSha", | |
| 931 | 1087 | cwd, |
| 932 | ["pull", "--ff-only"], | |
| 933 | "Cannot pull from detached HEAD.", | |
| 934 | ); | |
| 935 | } | |
| 936 | if (!details.hasUpstream) { | |
| 937 | return yield* createGitCommandError( | |
| 938 | "GitCore.pullCurrentBranch", | |
| 1088 | ["rev-parse", "HEAD"], | |
| 1089 | true, | |
| 1090 | ).pipe(Effect.map((stdout) => stdout.trim())); | |
| 1091 | yield* executeGit("GitCore.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { | |
| 1092 | timeoutMs: 30_000, | |
| 1093 | fallbackErrorMessage: "git pull failed", | |
| 1094 | }); | |
| 1095 | const afterSha = yield* runGitStdout( | |
| 1096 | "GitCore.pullCurrentBranch.afterSha", | |
| 939 | 1097 | cwd, |
| 940 | ["pull", "--ff-only"], | |
| 941 | "Current branch has no upstream configured. Push with upstream first.", | |
| 942 | ); | |
| 943 | } | |
| 944 | const beforeSha = yield* runGitStdout( | |
| 945 | "GitCore.pullCurrentBranch.beforeSha", | |
| 946 | cwd, | |
| 947 | ["rev-parse", "HEAD"], | |
| 948 | true, | |
| 949 | ).pipe(Effect.map((stdout) => stdout.trim())); | |
| 950 | yield* executeGit("GitCore.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { | |
| 951 | timeoutMs: 30_000, | |
| 952 | fallbackErrorMessage: "git pull failed", | |
| 953 | }); | |
| 954 | const afterSha = yield* runGitStdout( | |
| 955 | "GitCore.pullCurrentBranch.afterSha", | |
| 956 | cwd, | |
| 957 | ["rev-parse", "HEAD"], | |
| 958 | true, | |
| 959 | ).pipe(Effect.map((stdout) => stdout.trim())); | |
| 960 | ||
| 961 | const refreshed = yield* statusDetails(cwd); | |
| 962 | return { | |
| 963 | status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", | |
| 964 | branch, | |
| 965 | upstreamBranch: refreshed.upstreamRef, | |
| 966 | }; | |
| 967 | }); | |
| 1098 | ["rev-parse", "HEAD"], | |
| 1099 | true, | |
| 1100 | ).pipe(Effect.map((stdout) => stdout.trim())); | |
| 968 | 1101 | |
| 969 | const readRangeContext: GitCoreShape["readRangeContext"] = (cwd, baseBranch) => | |
| 970 | Effect.gen(function* () { | |
| 971 | const range = `${baseBranch}..HEAD`; | |
| 972 | const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( | |
| 973 | [ | |
| 974 | runGitStdout("GitCore.readRangeContext.log", cwd, ["log", "--oneline", range]), | |
| 975 | runGitStdout("GitCore.readRangeContext.diffStat", cwd, ["diff", "--stat", range]), | |
| 976 | runGitStdout("GitCore.readRangeContext.diffPatch", cwd, [ | |
| 977 | "diff", | |
| 978 | "--patch", | |
| 979 | "--minimal", | |
| 980 | range, | |
| 981 | ]), | |
| 982 | ], | |
| 983 | { concurrency: "unbounded" }, | |
| 984 | ); | |
| 1102 | const refreshed = yield* statusDetails(cwd); | |
| 1103 | return { | |
| 1104 | status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", | |
| 1105 | branch, | |
| 1106 | upstreamBranch: refreshed.upstreamRef, | |
| 1107 | }; | |
| 1108 | }); | |
| 985 | 1109 | |
| 986 | return { | |
| 987 | commitSummary, | |
| 988 | diffSummary, | |
| 989 | diffPatch, | |
| 990 | }; | |
| 991 | }); | |
| 1110 | const readRangeContext: GitCoreShape["readRangeContext"] = (cwd, baseBranch) => | |
| 1111 | Effect.gen(function* () { | |
| 1112 | const range = `${baseBranch}..HEAD`; | |
| 1113 | const [commitSummary, diffSummary, diffPatch] = yield* Effect.all( | |
| 1114 | [ | |
| 1115 | runGitStdout("GitCore.readRangeContext.log", cwd, ["log", "--oneline", range]), | |
| 1116 | runGitStdout("GitCore.readRangeContext.diffStat", cwd, ["diff", "--stat", range]), | |
| 1117 | runGitStdout("GitCore.readRangeContext.diffPatch", cwd, [ | |
| 1118 | "diff", | |
| 1119 | "--patch", | |
| 1120 | "--minimal", | |
| 1121 | range, | |
| 1122 | ]), | |
| 1123 | ], | |
| 1124 | { concurrency: "unbounded" }, | |
| 1125 | ); | |
| 992 | 1126 | |
| 993 | const readConfigValue: GitCoreShape["readConfigValue"] = (cwd, key) => | |
| 994 | runGitStdout("GitCore.readConfigValue", cwd, ["config", "--get", key], true).pipe( | |
| 995 | Effect.map((stdout) => stdout.trim()), | |
| 996 | Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), | |
| 997 | ); | |
| 1127 | return { | |
| 1128 | commitSummary, | |
| 1129 | diffSummary, | |
| 1130 | diffPatch, | |
| 1131 | }; | |
| 1132 | }); | |
| 998 | 1133 | |
| 999 | const listBranches: GitCoreShape["listBranches"] = (input) => | |
| 1000 | Effect.gen(function* () { | |
| 1001 | const branchRecencyPromise = readBranchRecency(input.cwd).pipe( | |
| 1002 | Effect.catch(() => Effect.succeed(new Map<string, number>())), | |
| 1003 | ); | |
| 1004 | const localBranchResult = yield* executeGit( | |
| 1005 | "GitCore.listBranches.branchNoColor", | |
| 1006 | input.cwd, | |
| 1007 | ["branch", "--no-color"], | |
| 1008 | { | |
| 1009 | timeoutMs: 10_000, | |
| 1010 | allowNonZeroExit: true, | |
| 1011 | }, | |
| 1134 | const readConfigValue: GitCoreShape["readConfigValue"] = (cwd, key) => | |
| 1135 | runGitStdout("GitCore.readConfigValue", cwd, ["config", "--get", key], true).pipe( | |
| 1136 | Effect.map((stdout) => stdout.trim()), | |
| 1137 | Effect.map((trimmed) => (trimmed.length > 0 ? trimmed : null)), | |
| 1012 | 1138 | ); |
| 1013 | 1139 | |
| 1014 | if (localBranchResult.code !== 0) { | |
| 1015 | const stderr = localBranchResult.stderr.trim(); | |
| 1016 | if (stderr.toLowerCase().includes("not a git repository")) { | |
| 1017 | return { branches: [], isRepo: false, hasOriginRemote: false }; | |
| 1018 | } | |
| 1019 | return yield* createGitCommandError( | |
| 1020 | "GitCore.listBranches", | |
| 1140 | const listBranches: GitCoreShape["listBranches"] = (input) => | |
| 1141 | Effect.gen(function* () { | |
| 1142 | const branchRecencyPromise = readBranchRecency(input.cwd).pipe( | |
| 1143 | Effect.catch(() => Effect.succeed(new Map<string, number>())), | |
| 1144 | ); | |
| 1145 | const localBranchResult = yield* executeGit( | |
| 1146 | "GitCore.listBranches.branchNoColor", | |
| 1021 | 1147 | input.cwd, |
| 1022 | 1148 | ["branch", "--no-color"], |
| 1023 | stderr || "git branch failed", | |
| 1149 | { | |
| 1150 | timeoutMs: 10_000, | |
| 1151 | allowNonZeroExit: true, | |
| 1152 | }, | |
| 1024 | 1153 | ); |
| 1025 | } | |
| 1026 | ||
| 1027 | const remoteBranchResultEffect = executeGit( | |
| 1028 | "GitCore.listBranches.remoteBranches", | |
| 1029 | input.cwd, | |
| 1030 | ["branch", "--no-color", "--remotes"], | |
| 1031 | { | |
| 1032 | timeoutMs: 10_000, | |
| 1033 | allowNonZeroExit: true, | |
| 1034 | }, | |
| 1035 | ).pipe( | |
| 1036 | Effect.catch((error) => | |
| 1037 | Effect.logWarning( | |
| 1038 | `GitCore.listBranches: remote branch lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote branch list.`, | |
| 1039 | ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })), | |
| 1040 | ), | |
| 1041 | ); | |
| 1042 | 1154 | |
| 1043 | const remoteNamesResultEffect = executeGit( | |
| 1044 | "GitCore.listBranches.remoteNames", | |
| 1045 | input.cwd, | |
| 1046 | ["remote"], | |
| 1047 | { | |
| 1048 | timeoutMs: 5_000, | |
| 1049 | allowNonZeroExit: true, | |
| 1050 | }, | |
| 1051 | ).pipe( | |
| 1052 | Effect.catch((error) => | |
| 1053 | Effect.logWarning( | |
| 1054 | `GitCore.listBranches: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`, | |
| 1055 | ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })), | |
| 1056 | ), | |
| 1057 | ); | |
| 1155 | if (localBranchResult.code !== 0) { | |
| 1156 | const stderr = localBranchResult.stderr.trim(); | |
| 1157 | if (stderr.toLowerCase().includes("not a git repository")) { | |
| 1158 | return { branches: [], isRepo: false, hasOriginRemote: false }; | |
| 1159 | } | |
| 1160 | return yield* createGitCommandError( | |
| 1161 | "GitCore.listBranches", | |
| 1162 | input.cwd, | |
| 1163 | ["branch", "--no-color"], | |
| 1164 | stderr || "git branch failed", | |
| 1165 | ); | |
| 1166 | } | |
| 1058 | 1167 | |
| 1059 | const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] = | |
| 1060 | yield* Effect.all( | |
| 1061 | [ | |
| 1062 | executeGit( | |
| 1063 | "GitCore.listBranches.defaultRef", | |
| 1064 | input.cwd, | |
| 1065 | ["symbolic-ref", "refs/remotes/origin/HEAD"], | |
| 1066 | { | |
| 1067 | timeoutMs: 5_000, | |
| 1068 | allowNonZeroExit: true, | |
| 1069 | }, | |
| 1070 | ), | |
| 1071 | executeGit( | |
| 1072 | "GitCore.listBranches.worktreeList", | |
| 1073 | input.cwd, | |
| 1074 | ["worktree", "list", "--porcelain"], | |
| 1075 | { | |
| 1076 | timeoutMs: 5_000, | |
| 1077 | allowNonZeroExit: true, | |
| 1078 | }, | |
| 1079 | ), | |
| 1080 | remoteBranchResultEffect, | |
| 1081 | remoteNamesResultEffect, | |
| 1082 | branchRecencyPromise, | |
| 1083 | ], | |
| 1084 | { concurrency: "unbounded" }, | |
| 1168 | const remoteBranchResultEffect = executeGit( | |
| 1169 | "GitCore.listBranches.remoteBranches", | |
| 1170 | input.cwd, | |
| 1171 | ["branch", "--no-color", "--remotes"], | |
| 1172 | { | |
| 1173 | timeoutMs: 10_000, | |
| 1174 | allowNonZeroExit: true, | |
| 1175 | }, | |
| 1176 | ).pipe( | |
| 1177 | Effect.catch((error) => | |
| 1178 | Effect.logWarning( | |
| 1179 | `GitCore.listBranches: remote branch lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote branch list.`, | |
| 1180 | ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })), | |
| 1181 | ), | |
| 1085 | 1182 | ); |
| 1086 | 1183 | |
| 1087 | const remoteNames = | |
| 1088 | remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; | |
| 1089 | if (remoteBranchResult.code !== 0 && remoteBranchResult.stderr.trim().length > 0) { | |
| 1090 | yield* Effect.logWarning( | |
| 1091 | `GitCore.listBranches: remote branch lookup returned code ${remoteBranchResult.code} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote branch list.`, | |
| 1092 | ); | |
| 1093 | } | |
| 1094 | if (remoteNamesResult.code !== 0 && remoteNamesResult.stderr.trim().length > 0) { | |
| 1095 | yield* Effect.logWarning( | |
| 1096 | `GitCore.listBranches: remote name lookup returned code ${remoteNamesResult.code} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`, | |
| 1184 | const remoteNamesResultEffect = executeGit( | |
| 1185 | "GitCore.listBranches.remoteNames", | |
| 1186 | input.cwd, | |
| 1187 | ["remote"], | |
| 1188 | { | |
| 1189 | timeoutMs: 5_000, | |
| 1190 | allowNonZeroExit: true, | |
| 1191 | }, | |
| 1192 | ).pipe( | |
| 1193 | Effect.catch((error) => | |
| 1194 | Effect.logWarning( | |
| 1195 | `GitCore.listBranches: remote name lookup failed for ${input.cwd}: ${error.message}. Falling back to an empty remote name list.`, | |
| 1196 | ).pipe(Effect.as({ code: 1, stdout: "", stderr: "" })), | |
| 1197 | ), | |
| 1097 | 1198 | ); |
| 1098 | } | |
| 1099 | 1199 | |
| 1100 | const defaultBranch = | |
| 1101 | defaultRef.code === 0 | |
| 1102 | ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") | |
| 1103 | : null; | |
| 1200 | const [defaultRef, worktreeList, remoteBranchResult, remoteNamesResult, branchLastCommit] = | |
| 1201 | yield* Effect.all( | |
| 1202 | [ | |
| 1203 | executeGit( | |
| 1204 | "GitCore.listBranches.defaultRef", | |
| 1205 | input.cwd, | |
| 1206 | ["symbolic-ref", "refs/remotes/origin/HEAD"], | |
| 1207 | { | |
| 1208 | timeoutMs: 5_000, | |
| 1209 | allowNonZeroExit: true, | |
| 1210 | }, | |
| 1211 | ), | |
| 1212 | executeGit( | |
| 1213 | "GitCore.listBranches.worktreeList", | |
| 1214 | input.cwd, | |
| 1215 | ["worktree", "list", "--porcelain"], | |
| 1216 | { | |
| 1217 | timeoutMs: 5_000, | |
| 1218 | allowNonZeroExit: true, | |
| 1219 | }, | |
| 1220 | ), | |
| 1221 | remoteBranchResultEffect, | |
| 1222 | remoteNamesResultEffect, | |
| 1223 | branchRecencyPromise, | |
| 1224 | ], | |
| 1225 | { concurrency: "unbounded" }, | |
| 1226 | ); | |
| 1104 | 1227 | |
| 1105 | const worktreeMap = new Map<string, string>(); | |
| 1106 | if (worktreeList.code === 0) { | |
| 1107 | let currentPath: string | null = null; | |
| 1108 | for (const line of worktreeList.stdout.split("\n")) { | |
| 1109 | if (line.startsWith("worktree ")) { | |
| 1110 | const candidatePath = line.slice("worktree ".length); | |
| 1111 | const exists = yield* fileSystem.stat(candidatePath).pipe( | |
| 1112 | Effect.map(() => true), | |
| 1113 | Effect.catch(() => Effect.succeed(false)), | |
| 1114 | ); | |
| 1115 | currentPath = exists ? candidatePath : null; | |
| 1116 | } else if (line.startsWith("branch refs/heads/") && currentPath) { | |
| 1117 | worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); | |
| 1118 | } else if (line === "") { | |
| 1119 | currentPath = null; | |
| 1228 | const remoteNames = | |
| 1229 | remoteNamesResult.code === 0 ? parseRemoteNames(remoteNamesResult.stdout) : []; | |
| 1230 | if (remoteBranchResult.code !== 0 && remoteBranchResult.stderr.trim().length > 0) { | |
| 1231 | yield* Effect.logWarning( | |
| 1232 | `GitCore.listBranches: remote branch lookup returned code ${remoteBranchResult.code} for ${input.cwd}: ${remoteBranchResult.stderr.trim()}. Falling back to an empty remote branch list.`, | |
| 1233 | ); | |
| 1234 | } | |
| 1235 | if (remoteNamesResult.code !== 0 && remoteNamesResult.stderr.trim().length > 0) { | |
| 1236 | yield* Effect.logWarning( | |
| 1237 | `GitCore.listBranches: remote name lookup returned code ${remoteNamesResult.code} for ${input.cwd}: ${remoteNamesResult.stderr.trim()}. Falling back to an empty remote name list.`, | |
| 1238 | ); | |
| 1239 | } | |
| 1240 | ||
| 1241 | const defaultBranch = | |
| 1242 | defaultRef.code === 0 | |
| 1243 | ? defaultRef.stdout.trim().replace(/^refs\/remotes\/origin\//, "") | |
| 1244 | : null; | |
| 1245 | ||
| 1246 | const worktreeMap = new Map<string, string>(); | |
| 1247 | if (worktreeList.code === 0) { | |
| 1248 | let currentPath: string | null = null; | |
| 1249 | for (const line of worktreeList.stdout.split("\n")) { | |
| 1250 | if (line.startsWith("worktree ")) { | |
| 1251 | const candidatePath = line.slice("worktree ".length); | |
| 1252 | const exists = yield* fileSystem.stat(candidatePath).pipe( | |
| 1253 | Effect.map(() => true), | |
| 1254 | Effect.catch(() => Effect.succeed(false)), | |
| 1255 | ); | |
| 1256 | currentPath = exists ? candidatePath : null; | |
| 1257 | } else if (line.startsWith("branch refs/heads/") && currentPath) { | |
| 1258 | worktreeMap.set(line.slice("branch refs/heads/".length), currentPath); | |
| 1259 | } else if (line === "") { | |
| 1260 | currentPath = null; | |
| 1261 | } | |
| 1120 | 1262 | } |
| 1121 | 1263 | } |
| 1122 | } | |
| 1123 | 1264 | |
| 1124 | const localBranches = localBranchResult.stdout | |
| 1125 | .split("\n") | |
| 1126 | .map(parseBranchLine) | |
| 1127 | .filter((branch): branch is { name: string; current: boolean } => branch !== null) | |
| 1128 | .map((branch) => ({ | |
| 1129 | name: branch.name, | |
| 1130 | current: branch.current, | |
| 1131 | isRemote: false, | |
| 1132 | isDefault: branch.name === defaultBranch, | |
| 1133 | worktreePath: worktreeMap.get(branch.name) ?? null, | |
| 1134 | })) | |
| 1135 | .toSorted((a, b) => { | |
| 1136 | const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; | |
| 1137 | const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; | |
| 1138 | if (aPriority !== bPriority) return aPriority - bPriority; | |
| 1139 | ||
| 1140 | const aLastCommit = branchLastCommit.get(a.name) ?? 0; | |
| 1141 | const bLastCommit = branchLastCommit.get(b.name) ?? 0; | |
| 1142 | if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; | |
| 1143 | return a.name.localeCompare(b.name); | |
| 1144 | }); | |
| 1265 | const localBranches = localBranchResult.stdout | |
| 1266 | .split("\n") | |
| 1267 | .map(parseBranchLine) | |
| 1268 | .filter((branch): branch is { name: string; current: boolean } => branch !== null) | |
| 1269 | .map((branch) => ({ | |
| 1270 | name: branch.name, | |
| 1271 | current: branch.current, | |
| 1272 | isRemote: false, | |
| 1273 | isDefault: branch.name === defaultBranch, | |
| 1274 | worktreePath: worktreeMap.get(branch.name) ?? null, | |
| 1275 | })) | |
| 1276 | .toSorted((a, b) => { | |
| 1277 | const aPriority = a.current ? 0 : a.isDefault ? 1 : 2; | |
| 1278 | const bPriority = b.current ? 0 : b.isDefault ? 1 : 2; | |
| 1279 | if (aPriority !== bPriority) return aPriority - bPriority; | |
| 1280 | ||
| 1281 | const aLastCommit = branchLastCommit.get(a.name) ?? 0; | |
| 1282 | const bLastCommit = branchLastCommit.get(b.name) ?? 0; | |
| 1283 | if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; | |
| 1284 | return a.name.localeCompare(b.name); | |
| 1285 | }); | |
| 1286 | ||
| 1287 | const remoteBranches = | |
| 1288 | remoteBranchResult.code === 0 | |
| 1289 | ? remoteBranchResult.stdout | |
| 1290 | .split("\n") | |
| 1291 | .map(parseBranchLine) | |
| 1292 | .filter((branch): branch is { name: string; current: boolean } => branch !== null) | |
| 1293 | .map((branch) => { | |
| 1294 | const parsedRemoteRef = parseRemoteRefWithRemoteNames(branch.name, remoteNames); | |
| 1295 | const remoteBranch: { | |
| 1296 | name: string; | |
| 1297 | current: boolean; | |
| 1298 | isRemote: boolean; | |
| 1299 | remoteName?: string; | |
| 1300 | isDefault: boolean; | |
| 1301 | worktreePath: string | null; | |
| 1302 | } = { | |
| 1303 | name: branch.name, | |
| 1304 | current: false, | |
| 1305 | isRemote: true, | |
| 1306 | isDefault: false, | |
| 1307 | worktreePath: null, | |
| 1308 | }; | |
| 1309 | if (parsedRemoteRef) { | |
| 1310 | remoteBranch.remoteName = parsedRemoteRef.remoteName; | |
| 1311 | } | |
| 1312 | return remoteBranch; | |
| 1313 | }) | |
| 1314 | .toSorted((a, b) => { | |
| 1315 | const aLastCommit = branchLastCommit.get(a.name) ?? 0; | |
| 1316 | const bLastCommit = branchLastCommit.get(b.name) ?? 0; | |
| 1317 | if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; | |
| 1318 | return a.name.localeCompare(b.name); | |
| 1319 | }) | |
| 1320 | : []; | |
| 1321 | ||
| 1322 | const branches = [...localBranches, ...remoteBranches]; | |
| 1323 | ||
| 1324 | return { branches, isRepo: true, hasOriginRemote: remoteNames.includes("origin") }; | |
| 1325 | }); | |
| 1145 | 1326 | |
| 1146 | const remoteBranches = | |
| 1147 | remoteBranchResult.code === 0 | |
| 1148 | ? remoteBranchResult.stdout | |
| 1149 | .split("\n") | |
| 1150 | .map(parseBranchLine) | |
| 1151 | .filter((branch): branch is { name: string; current: boolean } => branch !== null) | |
| 1152 | .map((branch) => { | |
| 1153 | const parsedRemoteRef = parseRemoteRefWithRemoteNames(branch.name, remoteNames); | |
| 1154 | const remoteBranch: { | |
| 1155 | name: string; | |
| 1156 | current: boolean; | |
| 1157 | isRemote: boolean; | |
| 1158 | remoteName?: string; | |
| 1159 | isDefault: boolean; | |
| 1160 | worktreePath: string | null; | |
| 1161 | } = { | |
| 1162 | name: branch.name, | |
| 1163 | current: false, | |
| 1164 | isRemote: true, | |
| 1165 | isDefault: false, | |
| 1166 | worktreePath: null, | |
| 1167 | }; | |
| 1168 | if (parsedRemoteRef) { | |
| 1169 | remoteBranch.remoteName = parsedRemoteRef.remoteName; | |
| 1170 | } | |
| 1171 | return remoteBranch; | |
| 1172 | }) | |
| 1173 | .toSorted((a, b) => { | |
| 1174 | const aLastCommit = branchLastCommit.get(a.name) ?? 0; | |
| 1175 | const bLastCommit = branchLastCommit.get(b.name) ?? 0; | |
| 1176 | if (aLastCommit !== bLastCommit) return bLastCommit - aLastCommit; | |
| 1177 | return a.name.localeCompare(b.name); | |
| 1178 | }) | |
| 1179 | : []; | |
| 1180 | ||
| 1181 | const branches = [...localBranches, ...remoteBranches]; | |
| 1182 | ||
| 1183 | return { branches, isRepo: true, hasOriginRemote: remoteNames.includes("origin") }; | |
| 1184 | }); | |
| 1327 | const createWorktree: GitCoreShape["createWorktree"] = (input) => | |
| 1328 | Effect.gen(function* () { | |
| 1329 | const targetBranch = input.newBranch ?? input.branch; | |
| 1330 | const sanitizedBranch = targetBranch.replace(/\//g, "-"); | |
| 1331 | const repoName = path.basename(input.cwd); | |
| 1332 | const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); | |
| 1333 | const args = input.newBranch | |
| 1334 | ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch] | |
| 1335 | : ["worktree", "add", worktreePath, input.branch]; | |
| 1336 | ||
| 1337 | yield* executeGit("GitCore.createWorktree", input.cwd, args, { | |
| 1338 | fallbackErrorMessage: "git worktree add failed", | |
| 1339 | }); | |
| 1185 | 1340 | |
| 1186 | const createWorktree: GitCoreShape["createWorktree"] = (input) => | |
| 1187 | Effect.gen(function* () { | |
| 1188 | const targetBranch = input.newBranch ?? input.branch; | |
| 1189 | const sanitizedBranch = targetBranch.replace(/\//g, "-"); | |
| 1190 | const repoName = path.basename(input.cwd); | |
| 1191 | const worktreePath = input.path ?? path.join(worktreesDir, repoName, sanitizedBranch); | |
| 1192 | const args = input.newBranch | |
| 1193 | ? ["worktree", "add", "-b", input.newBranch, worktreePath, input.branch] | |
| 1194 | : ["worktree", "add", worktreePath, input.branch]; | |
| 1195 | ||
| 1196 | yield* executeGit("GitCore.createWorktree", input.cwd, args, { | |
| 1197 | fallbackErrorMessage: "git worktree add failed", | |
| 1341 | return { | |
| 1342 | worktree: { | |
| 1343 | path: worktreePath, | |
| 1344 | branch: targetBranch, | |
| 1345 | }, | |
| 1346 | }; | |
| 1198 | 1347 | }); |
| 1199 | 1348 | |
| 1200 | return { | |
| 1201 | worktree: { | |
| 1202 | path: worktreePath, | |
| 1203 | branch: targetBranch, | |
| 1204 | }, | |
| 1205 | }; | |
| 1206 | }); | |
| 1349 | const fetchPullRequestBranch: GitCoreShape["fetchPullRequestBranch"] = (input) => | |
| 1350 | Effect.gen(function* () { | |
| 1351 | const remoteName = yield* resolvePrimaryRemoteName(input.cwd); | |
| 1352 | yield* executeGit( | |
| 1353 | "GitCore.fetchPullRequestBranch", | |
| 1354 | input.cwd, | |
| 1355 | [ | |
| 1356 | "fetch", | |
| 1357 | "--quiet", | |
| 1358 | "--no-tags", | |
| 1359 | remoteName, | |
| 1360 | `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, | |
| 1361 | ], | |
| 1362 | { | |
| 1363 | fallbackErrorMessage: "git fetch pull request branch failed", | |
| 1364 | }, | |
| 1365 | ); | |
| 1366 | }).pipe(Effect.asVoid); | |
| 1207 | 1367 | |
| 1208 | const fetchPullRequestBranch: GitCoreShape["fetchPullRequestBranch"] = (input) => | |
| 1209 | Effect.gen(function* () { | |
| 1210 | const remoteName = yield* resolvePrimaryRemoteName(input.cwd); | |
| 1211 | yield* executeGit( | |
| 1212 | "GitCore.fetchPullRequestBranch", | |
| 1213 | input.cwd, | |
| 1214 | [ | |
| 1368 | const fetchRemoteBranch: GitCoreShape["fetchRemoteBranch"] = (input) => | |
| 1369 | Effect.gen(function* () { | |
| 1370 | yield* runGit("GitCore.fetchRemoteBranch.fetch", input.cwd, [ | |
| 1215 | 1371 | "fetch", |
| 1216 | 1372 | "--quiet", |
| 1217 | 1373 | "--no-tags", |
| 1218 | remoteName, | |
| 1219 | `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, | |
| 1220 | ], | |
| 1221 | { | |
| 1222 | fallbackErrorMessage: "git fetch pull request branch failed", | |
| 1223 | }, | |
| 1224 | ); | |
| 1225 | }).pipe(Effect.asVoid); | |
| 1374 | input.remoteName, | |
| 1375 | `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, | |
| 1376 | ]); | |
| 1226 | 1377 | |
| 1227 | const fetchRemoteBranch: GitCoreShape["fetchRemoteBranch"] = (input) => | |
| 1228 | Effect.gen(function* () { | |
| 1229 | yield* runGit("GitCore.fetchRemoteBranch.fetch", input.cwd, [ | |
| 1230 | "fetch", | |
| 1231 | "--quiet", | |
| 1232 | "--no-tags", | |
| 1233 | input.remoteName, | |
| 1234 | `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, | |
| 1378 | const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch); | |
| 1379 | const targetRef = `${input.remoteName}/${input.remoteBranch}`; | |
| 1380 | yield* runGit( | |
| 1381 | "GitCore.fetchRemoteBranch.materialize", | |
| 1382 | input.cwd, | |
| 1383 | localBranchAlreadyExists | |
| 1384 | ? ["branch", "--force", input.localBranch, targetRef] | |
| 1385 | : ["branch", input.localBranch, targetRef], | |
| 1386 | ); | |
| 1387 | }).pipe(Effect.asVoid); | |
| 1388 | ||
| 1389 | const setBranchUpstream: GitCoreShape["setBranchUpstream"] = (input) => | |
| 1390 | runGit("GitCore.setBranchUpstream", input.cwd, [ | |
| 1391 | "branch", | |
| 1392 | "--set-upstream-to", | |
| 1393 | `${input.remoteName}/${input.remoteBranch}`, | |
| 1394 | input.branch, | |
| 1235 | 1395 | ]); |
| 1236 | 1396 | |
| 1237 | const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch); | |
| 1238 | const targetRef = `${input.remoteName}/${input.remoteBranch}`; | |
| 1239 | yield* runGit( | |
| 1240 | "GitCore.fetchRemoteBranch.materialize", | |
| 1241 | input.cwd, | |
| 1242 | localBranchAlreadyExists | |
| 1243 | ? ["branch", "--force", input.localBranch, targetRef] | |
| 1244 | : ["branch", input.localBranch, targetRef], | |
| 1245 | ); | |
| 1246 | }).pipe(Effect.asVoid); | |
| 1247 | ||
| 1248 | const setBranchUpstream: GitCoreShape["setBranchUpstream"] = (input) => | |
| 1249 | runGit("GitCore.setBranchUpstream", input.cwd, [ | |
| 1250 | "branch", | |
| 1251 | "--set-upstream-to", | |
| 1252 | `${input.remoteName}/${input.remoteBranch}`, | |
| 1253 | input.branch, | |
| 1254 | ]); | |
| 1255 | ||
| 1256 | const removeWorktree: GitCoreShape["removeWorktree"] = (input) => | |
| 1257 | Effect.gen(function* () { | |
| 1258 | const args = ["worktree", "remove"]; | |
| 1259 | if (input.force) { | |
| 1260 | args.push("--force"); | |
| 1261 | } | |
| 1262 | args.push(input.path); | |
| 1263 | yield* executeGit("GitCore.removeWorktree", input.cwd, args, { | |
| 1264 | timeoutMs: 15_000, | |
| 1265 | fallbackErrorMessage: "git worktree remove failed", | |
| 1266 | }).pipe( | |
| 1267 | Effect.mapError((error) => | |
| 1268 | createGitCommandError( | |
| 1269 | "GitCore.removeWorktree", | |
| 1270 | input.cwd, | |
| 1271 | args, | |
| 1272 | `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error instanceof Error ? error.message : String(error)}`, | |
| 1273 | error, | |
| 1397 | const removeWorktree: GitCoreShape["removeWorktree"] = (input) => | |
| 1398 | Effect.gen(function* () { | |
| 1399 | const args = ["worktree", "remove"]; | |
| 1400 | if (input.force) { | |
| 1401 | args.push("--force"); | |
| 1402 | } | |
| 1403 | args.push(input.path); | |
| 1404 | yield* executeGit("GitCore.removeWorktree", input.cwd, args, { | |
| 1405 | timeoutMs: 15_000, | |
| 1406 | fallbackErrorMessage: "git worktree remove failed", | |
| 1407 | }).pipe( | |
| 1408 | Effect.mapError((error) => | |
| 1409 | createGitCommandError( | |
| 1410 | "GitCore.removeWorktree", | |
| 1411 | input.cwd, | |
| 1412 | args, | |
| 1413 | `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error instanceof Error ? error.message : String(error)}`, | |
| 1414 | error, | |
| 1415 | ), | |
| 1274 | 1416 | ), |
| 1275 | ), | |
| 1276 | ); | |
| 1277 | }); | |
| 1278 | ||
| 1279 | const renameBranch: GitCoreShape["renameBranch"] = (input) => | |
| 1280 | Effect.gen(function* () { | |
| 1281 | if (input.oldBranch === input.newBranch) { | |
| 1282 | return { branch: input.newBranch }; | |
| 1283 | } | |
| 1284 | const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch); | |
| 1417 | ); | |
| 1418 | }); | |
| 1285 | 1419 | |
| 1286 | yield* executeGit( | |
| 1287 | "GitCore.renameBranch", | |
| 1288 | input.cwd, | |
| 1289 | ["branch", "-m", "--", input.oldBranch, targetBranch], | |
| 1290 | { | |
| 1291 | timeoutMs: 10_000, | |
| 1292 | fallbackErrorMessage: "git branch rename failed", | |
| 1293 | }, | |
| 1294 | ); | |
| 1420 | const renameBranch: GitCoreShape["renameBranch"] = (input) => | |
| 1421 | Effect.gen(function* () { | |
| 1422 | if (input.oldBranch === input.newBranch) { | |
| 1423 | return { branch: input.newBranch }; | |
| 1424 | } | |
| 1425 | const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch); | |
| 1295 | 1426 | |
| 1296 | return { branch: targetBranch }; | |
| 1297 | }); | |
| 1427 | yield* executeGit( | |
| 1428 | "GitCore.renameBranch", | |
| 1429 | input.cwd, | |
| 1430 | ["branch", "-m", "--", input.oldBranch, targetBranch], | |
| 1431 | { | |
| 1432 | timeoutMs: 10_000, | |
| 1433 | fallbackErrorMessage: "git branch rename failed", | |
| 1434 | }, | |
| 1435 | ); | |
| 1298 | 1436 | |
| 1299 | const createBranch: GitCoreShape["createBranch"] = (input) => | |
| 1300 | executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], { | |
| 1301 | timeoutMs: 10_000, | |
| 1302 | fallbackErrorMessage: "git branch create failed", | |
| 1303 | }).pipe(Effect.asVoid); | |
| 1437 | return { branch: targetBranch }; | |
| 1438 | }); | |
| 1304 | 1439 | |
| 1305 | const checkoutBranch: GitCoreShape["checkoutBranch"] = (input) => | |
| 1306 | Effect.gen(function* () { | |
| 1307 | const [localInputExists, remoteExists] = yield* Effect.all( | |
| 1308 | [ | |
| 1309 | executeGit( | |
| 1310 | "GitCore.checkoutBranch.localInputExists", | |
| 1311 | input.cwd, | |
| 1312 | ["show-ref", "--verify", "--quiet", `refs/heads/${input.branch}`], | |
| 1313 | { | |
| 1314 | timeoutMs: 5_000, | |
| 1315 | allowNonZeroExit: true, | |
| 1316 | }, | |
| 1317 | ).pipe(Effect.map((result) => result.code === 0)), | |
| 1318 | executeGit( | |
| 1319 | "GitCore.checkoutBranch.remoteExists", | |
| 1320 | input.cwd, | |
| 1321 | ["show-ref", "--verify", "--quiet", `refs/remotes/${input.branch}`], | |
| 1322 | { | |
| 1323 | timeoutMs: 5_000, | |
| 1324 | allowNonZeroExit: true, | |
| 1325 | }, | |
| 1326 | ).pipe(Effect.map((result) => result.code === 0)), | |
| 1327 | ], | |
| 1328 | { concurrency: "unbounded" }, | |
| 1329 | ); | |
| 1440 | const createBranch: GitCoreShape["createBranch"] = (input) => | |
| 1441 | executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], { | |
| 1442 | timeoutMs: 10_000, | |
| 1443 | fallbackErrorMessage: "git branch create failed", | |
| 1444 | }).pipe(Effect.asVoid); | |
| 1330 | 1445 | |
| 1331 | const localTrackingBranch = remoteExists | |
| 1332 | ? yield* executeGit( | |
| 1333 | "GitCore.checkoutBranch.localTrackingBranch", | |
| 1334 | input.cwd, | |
| 1335 | ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], | |
| 1336 | { | |
| 1337 | timeoutMs: 5_000, | |
| 1338 | allowNonZeroExit: true, | |
| 1339 | }, | |
| 1340 | ).pipe( | |
| 1341 | Effect.map((result) => | |
| 1342 | result.code === 0 | |
| 1343 | ? parseTrackingBranchByUpstreamRef(result.stdout, input.branch) | |
| 1344 | : null, | |
| 1345 | ), | |
| 1346 | ) | |
| 1347 | : null; | |
| 1446 | const checkoutBranch: GitCoreShape["checkoutBranch"] = (input) => | |
| 1447 | Effect.gen(function* () { | |
| 1448 | const [localInputExists, remoteExists] = yield* Effect.all( | |
| 1449 | [ | |
| 1450 | executeGit( | |
| 1451 | "GitCore.checkoutBranch.localInputExists", | |
| 1452 | input.cwd, | |
| 1453 | ["show-ref", "--verify", "--quiet", `refs/heads/${input.branch}`], | |
| 1454 | { | |
| 1455 | timeoutMs: 5_000, | |
| 1456 | allowNonZeroExit: true, | |
| 1457 | }, | |
| 1458 | ).pipe(Effect.map((result) => result.code === 0)), | |
| 1459 | executeGit( | |
| 1460 | "GitCore.checkoutBranch.remoteExists", | |
| 1461 | input.cwd, | |
| 1462 | ["show-ref", "--verify", "--quiet", `refs/remotes/${input.branch}`], | |
| 1463 | { | |
| 1464 | timeoutMs: 5_000, | |
| 1465 | allowNonZeroExit: true, | |
| 1466 | }, | |
| 1467 | ).pipe(Effect.map((result) => result.code === 0)), | |
| 1468 | ], | |
| 1469 | { concurrency: "unbounded" }, | |
| 1470 | ); | |
| 1348 | 1471 | |
| 1349 | const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.branch); | |
| 1350 | const localTrackedBranchTargetExists = | |
| 1351 | remoteExists && localTrackedBranchCandidate | |
| 1472 | const localTrackingBranch = remoteExists | |
| 1352 | 1473 | ? yield* executeGit( |
| 1353 | "GitCore.checkoutBranch.localTrackedBranchTargetExists", | |
| 1474 | "GitCore.checkoutBranch.localTrackingBranch", | |
| 1354 | 1475 | input.cwd, |
| 1355 | ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`], | |
| 1476 | ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], | |
| 1356 | 1477 | { |
| 1357 | 1478 | timeoutMs: 5_000, |
| 1358 | 1479 | allowNonZeroExit: true, |
| 1359 | 1480 | }, |
| 1360 | ).pipe(Effect.map((result) => result.code === 0)) | |
| 1361 | : false; | |
| 1481 | ).pipe( | |
| 1482 | Effect.map((result) => | |
| 1483 | result.code === 0 | |
| 1484 | ? parseTrackingBranchByUpstreamRef(result.stdout, input.branch) | |
| 1485 | : null, | |
| 1486 | ), | |
| 1487 | ) | |
| 1488 | : null; | |
| 1362 | 1489 | |
| 1363 | const checkoutArgs = localInputExists | |
| 1364 | ? ["checkout", input.branch] | |
| 1365 | : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists | |
| 1490 | const localTrackedBranchCandidate = deriveLocalBranchNameFromRemoteRef(input.branch); | |
| 1491 | const localTrackedBranchTargetExists = | |
| 1492 | remoteExists && localTrackedBranchCandidate | |
| 1493 | ? yield* executeGit( | |
| 1494 | "GitCore.checkoutBranch.localTrackedBranchTargetExists", | |
| 1495 | input.cwd, | |
| 1496 | ["show-ref", "--verify", "--quiet", `refs/heads/${localTrackedBranchCandidate}`], | |
| 1497 | { | |
| 1498 | timeoutMs: 5_000, | |
| 1499 | allowNonZeroExit: true, | |
| 1500 | }, | |
| 1501 | ).pipe(Effect.map((result) => result.code === 0)) | |
| 1502 | : false; | |
| 1503 | ||
| 1504 | const checkoutArgs = localInputExists | |
| 1366 | 1505 | ? ["checkout", input.branch] |
| 1367 | : remoteExists && !localTrackingBranch | |
| 1368 | ? ["checkout", "--track", input.branch] | |
| 1369 | : remoteExists && localTrackingBranch | |
| 1370 | ? ["checkout", localTrackingBranch] | |
| 1371 | : ["checkout", input.branch]; | |
| 1506 | : remoteExists && !localTrackingBranch && localTrackedBranchTargetExists | |
| 1507 | ? ["checkout", input.branch] | |
| 1508 | : remoteExists && !localTrackingBranch | |
| 1509 | ? ["checkout", "--track", input.branch] | |
| 1510 | : remoteExists && localTrackingBranch | |
| 1511 | ? ["checkout", localTrackingBranch] | |
| 1512 | : ["checkout", input.branch]; | |
| 1513 | ||
| 1514 | yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, checkoutArgs, { | |
| 1515 | timeoutMs: 10_000, | |
| 1516 | fallbackErrorMessage: "git checkout failed", | |
| 1517 | }); | |
| 1372 | 1518 | |
| 1373 | yield* executeGit("GitCore.checkoutBranch.checkout", input.cwd, checkoutArgs, { | |
| 1374 | timeoutMs: 10_000, | |
| 1375 | fallbackErrorMessage: "git checkout failed", | |
| 1519 | // Refresh upstream refs in the background so checkout remains responsive. | |
| 1520 | yield* Effect.forkScoped( | |
| 1521 | refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.ignoreCause({ log: true })), | |
| 1522 | ); | |
| 1376 | 1523 | }); |
| 1377 | 1524 | |
| 1378 | // Refresh upstream refs in the background so checkout remains responsive. | |
| 1379 | yield* Effect.forkScoped( | |
| 1380 | refreshCheckedOutBranchUpstream(input.cwd).pipe(Effect.ignoreCause({ log: true })), | |
| 1525 | const initRepo: GitCoreShape["initRepo"] = (input) => | |
| 1526 | executeGit("GitCore.initRepo", input.cwd, ["init"], { | |
| 1527 | timeoutMs: 10_000, | |
| 1528 | fallbackErrorMessage: "git init failed", | |
| 1529 | }).pipe(Effect.asVoid); | |
| 1530 | ||
| 1531 | const listLocalBranchNames: GitCoreShape["listLocalBranchNames"] = (cwd) => | |
| 1532 | runGitStdout("GitCore.listLocalBranchNames", cwd, [ | |
| 1533 | "branch", | |
| 1534 | "--list", | |
| 1535 | "--format=%(refname:short)", | |
| 1536 | ]).pipe( | |
| 1537 | Effect.map((stdout) => | |
| 1538 | stdout | |
| 1539 | .split("\n") | |
| 1540 | .map((line) => line.trim()) | |
| 1541 | .filter((line) => line.length > 0), | |
| 1542 | ), | |
| 1381 | 1543 | ); |
| 1382 | }); | |
| 1383 | ||
| 1384 | const initRepo: GitCoreShape["initRepo"] = (input) => | |
| 1385 | executeGit("GitCore.initRepo", input.cwd, ["init"], { | |
| 1386 | timeoutMs: 10_000, | |
| 1387 | fallbackErrorMessage: "git init failed", | |
| 1388 | }).pipe(Effect.asVoid); | |
| 1389 | ||
| 1390 | const listLocalBranchNames: GitCoreShape["listLocalBranchNames"] = (cwd) => | |
| 1391 | runGitStdout("GitCore.listLocalBranchNames", cwd, [ | |
| 1392 | "branch", | |
| 1393 | "--list", | |
| 1394 | "--format=%(refname:short)", | |
| 1395 | ]).pipe( | |
| 1396 | Effect.map((stdout) => | |
| 1397 | stdout | |
| 1398 | .split("\n") | |
| 1399 | .map((line) => line.trim()) | |
| 1400 | .filter((line) => line.length > 0), | |
| 1401 | ), | |
| 1402 | ); | |
| 1403 | 1544 | |
| 1404 | return { | |
| 1405 | status, | |
| 1406 | statusDetails, | |
| 1407 | prepareCommitContext, | |
| 1408 | commit, | |
| 1409 | pushCurrentBranch, | |
| 1410 | pullCurrentBranch, | |
| 1411 | readRangeContext, | |
| 1412 | readConfigValue, | |
| 1413 | listBranches, | |
| 1414 | createWorktree, | |
| 1415 | fetchPullRequestBranch, | |
| 1416 | ensureRemote, | |
| 1417 | fetchRemoteBranch, | |
| 1418 | setBranchUpstream, | |
| 1419 | removeWorktree, | |
| 1420 | renameBranch, | |
| 1421 | createBranch, | |
| 1422 | checkoutBranch, | |
| 1423 | initRepo, | |
| 1424 | listLocalBranchNames, | |
| 1425 | } satisfies GitCoreShape; | |
| 1426 | }); | |
| 1545 | return { | |
| 1546 | execute, | |
| 1547 | status, | |
| 1548 | statusDetails, | |
| 1549 | prepareCommitContext, | |
| 1550 | commit, | |
| 1551 | pushCurrentBranch, | |
| 1552 | pullCurrentBranch, | |
| 1553 | readRangeContext, | |
| 1554 | readConfigValue, | |
| 1555 | listBranches, | |
| 1556 | createWorktree, | |
| 1557 | fetchPullRequestBranch, | |
| 1558 | ensureRemote, | |
| 1559 | fetchRemoteBranch, | |
| 1560 | setBranchUpstream, | |
| 1561 | removeWorktree, | |
| 1562 | renameBranch, | |
| 1563 | createBranch, | |
| 1564 | checkoutBranch, | |
| 1565 | initRepo, | |
| 1566 | listLocalBranchNames, | |
| 1567 | } satisfies GitCoreShape; | |
| 1568 | }); | |
| 1427 | 1569 | |
| 1428 | export const GitCoreLive = Layer.effect(GitCore, makeGitCore); | |
| 1570 | export const GitCoreLive = Layer.effect(GitCore, makeGitCore()); |
apps/server/src/git/Layers/GitManager.test.ts+10-9| ... | ... | @@ -15,9 +15,8 @@ import { |
| 15 | 15 | GitHubCli, |
| 16 | 16 | } from "../Services/GitHubCli.ts"; |
| 17 | 17 | import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; |
| 18 | import { GitServiceLive } from "./GitService.ts"; | |
| 19 | import { GitService } from "../Services/GitService.ts"; | |
| 20 | 18 | import { GitCoreLive } from "./GitCore.ts"; |
| 19 | import { GitCore } from "../Services/GitCore.ts"; | |
| 21 | 20 | import { makeGitManager } from "./GitManager.ts"; |
| 22 | 21 | import { ServerConfig } from "../../config.ts"; |
| 23 | 22 | |
| ... | ... | @@ -107,11 +106,11 @@ function runGit( |
| 107 | 106 | ): Effect.Effect< |
| 108 | 107 | { readonly code: number; readonly stdout: string; readonly stderr: string }, |
| 109 | 108 | GitCommandError, |
| 110 | GitService | |
| 109 | GitCore | |
| 111 | 110 | > { |
| 112 | 111 | return Effect.gen(function* () { |
| 113 | const gitService = yield* GitService; | |
| 114 | return yield* gitService.execute({ | |
| 112 | const gitCore = yield* GitCore; | |
| 113 | return yield* gitCore.execute({ | |
| 115 | 114 | operation: "GitManager.test.runGit", |
| 116 | 115 | cwd, |
| 117 | 116 | args, |
| ... | ... | @@ -125,7 +124,7 @@ function initRepo( |
| 125 | 124 | ): Effect.Effect< |
| 126 | 125 | void, |
| 127 | 126 | PlatformError.PlatformError | GitCommandError, |
| 128 | FileSystem.FileSystem | Scope.Scope | GitService | |
| 127 | FileSystem.FileSystem | Scope.Scope | GitCore | |
| 129 | 128 | > { |
| 130 | 129 | return Effect.gen(function* () { |
| 131 | 130 | const fs = yield* FileSystem.FileSystem; |
| ... | ... | @@ -141,7 +140,7 @@ function initRepo( |
| 141 | 140 | function createBareRemote(): Effect.Effect< |
| 142 | 141 | string, |
| 143 | 142 | PlatformError.PlatformError | GitCommandError, |
| 144 | FileSystem.FileSystem | Scope.Scope | GitService | |
| 143 | FileSystem.FileSystem | Scope.Scope | GitCore | |
| 145 | 144 | > { |
| 146 | 145 | return Effect.gen(function* () { |
| 147 | 146 | const remoteDir = yield* makeTempDir("t3code-git-remote-"); |
| ... | ... | @@ -480,7 +479,6 @@ function makeManager(input?: { |
| 480 | 479 | }); |
| 481 | 480 | |
| 482 | 481 | const gitCoreLayer = GitCoreLive.pipe( |
| 483 | Layer.provideMerge(GitServiceLive), | |
| 484 | 482 | Layer.provideMerge(NodeServices.layer), |
| 485 | 483 | Layer.provideMerge(ServerConfigLayer), |
| 486 | 484 | ); |
| ... | ... | @@ -497,7 +495,10 @@ function makeManager(input?: { |
| 497 | 495 | ); |
| 498 | 496 | } |
| 499 | 497 | |
| 500 | const GitManagerTestLayer = Layer.provideMerge(GitServiceLive, NodeServices.layer); | |
| 498 | const GitManagerTestLayer = GitCoreLive.pipe( | |
| 499 | Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-git-manager-test-" })), | |
| 500 | Layer.provideMerge(NodeServices.layer), | |
| 501 | ); | |
| 501 | 502 | |
| 502 | 503 | it.layer(GitManagerTestLayer)("GitManager", (it) => { |
| 503 | 504 | it.effect("status includes PR metadata when branch already has an open PR", () => |
apps/server/src/git/Layers/GitService.test.ts deleted-59| ... | ... | @@ -1,59 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { it, assert } from "@effect/vitest"; | |
| 3 | import { Effect, Layer, Schema } from "effect"; | |
| 4 | ||
| 5 | import { GitCommandError } from "../Errors.ts"; | |
| 6 | import { GitServiceLive } from "./GitService.ts"; | |
| 7 | import { GitService } from "../Services/GitService.ts"; | |
| 8 | ||
| 9 | const layer = it.layer(Layer.provideMerge(GitServiceLive, NodeServices.layer)); | |
| 10 | ||
| 11 | layer("GitServiceLive", (it) => { | |
| 12 | it.effect("runGit executes successful git commands", () => | |
| 13 | Effect.gen(function* () { | |
| 14 | const gitService = yield* GitService; | |
| 15 | const result = yield* gitService.execute({ | |
| 16 | operation: "GitProcess.test.version", | |
| 17 | cwd: process.cwd(), | |
| 18 | args: ["--version"], | |
| 19 | }); | |
| 20 | ||
| 21 | assert.equal(result.code, 0); | |
| 22 | assert.ok(result.stdout.toLowerCase().includes("git version")); | |
| 23 | }), | |
| 24 | ); | |
| 25 | ||
| 26 | it.effect("runGit can return non-zero exit codes when allowed", () => | |
| 27 | Effect.gen(function* () { | |
| 28 | const gitService = yield* GitService; | |
| 29 | const result = yield* gitService.execute({ | |
| 30 | operation: "GitProcess.test.allowNonZero", | |
| 31 | cwd: process.cwd(), | |
| 32 | args: ["rev-parse", "--verify", "__definitely_missing_ref__"], | |
| 33 | allowNonZeroExit: true, | |
| 34 | }); | |
| 35 | ||
| 36 | assert.notEqual(result.code, 0); | |
| 37 | }), | |
| 38 | ); | |
| 39 | ||
| 40 | it.effect("runGit fails with GitCommandError when non-zero exits are not allowed", () => | |
| 41 | Effect.gen(function* () { | |
| 42 | const gitService = yield* GitService; | |
| 43 | const result = yield* Effect.result( | |
| 44 | gitService.execute({ | |
| 45 | operation: "GitProcess.test.failOnNonZero", | |
| 46 | cwd: process.cwd(), | |
| 47 | args: ["rev-parse", "--verify", "__definitely_missing_ref__"], | |
| 48 | }), | |
| 49 | ); | |
| 50 | ||
| 51 | assert.equal(result._tag, "Failure"); | |
| 52 | if (result._tag === "Failure") { | |
| 53 | assert.ok(Schema.is(GitCommandError)(result.failure)); | |
| 54 | assert.equal(result.failure.operation, "GitProcess.test.failOnNonZero"); | |
| 55 | assert.equal(result.failure.command, "git rev-parse --verify __definitely_missing_ref__"); | |
| 56 | } | |
| 57 | }), | |
| 58 | ); | |
| 59 | }); |
apps/server/src/git/Layers/GitService.ts deleted-144| ... | ... | @@ -1,144 +0,0 @@ |
| 1 | /** | |
| 2 | * Git process helpers - Effect-native git execution with typed errors. | |
| 3 | * | |
| 4 | * Centralizes child-process git invocation for server modules. This module | |
| 5 | * only executes git commands and reports structured failures. | |
| 6 | * | |
| 7 | * @module GitServiceLive | |
| 8 | */ | |
| 9 | import { Effect, Layer, Option, Schema, Stream } from "effect"; | |
| 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | |
| 11 | import { GitCommandError } from "../Errors.ts"; | |
| 12 | import { | |
| 13 | ExecuteGitInput, | |
| 14 | ExecuteGitResult, | |
| 15 | GitService, | |
| 16 | GitServiceShape, | |
| 17 | } from "../Services/GitService.ts"; | |
| 18 | ||
| 19 | const DEFAULT_TIMEOUT_MS = 30_000; | |
| 20 | const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; | |
| 21 | ||
| 22 | function quoteGitCommand(args: ReadonlyArray<string>): string { | |
| 23 | return `git ${args.join(" ")}`; | |
| 24 | } | |
| 25 | ||
| 26 | function toGitCommandError( | |
| 27 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, | |
| 28 | detail: string, | |
| 29 | ) { | |
| 30 | return (cause: unknown) => | |
| 31 | Schema.is(GitCommandError)(cause) | |
| 32 | ? cause | |
| 33 | : new GitCommandError({ | |
| 34 | operation: input.operation, | |
| 35 | command: quoteGitCommand(input.args), | |
| 36 | cwd: input.cwd, | |
| 37 | detail: `${cause instanceof Error && cause.message.length > 0 ? cause.message : "Unknown error"} - ${detail}`, | |
| 38 | ...(cause !== undefined ? { cause } : {}), | |
| 39 | }); | |
| 40 | } | |
| 41 | ||
| 42 | const collectOutput = Effect.fn(function* <E>( | |
| 43 | input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">, | |
| 44 | stream: Stream.Stream<Uint8Array, E>, | |
| 45 | maxOutputBytes: number, | |
| 46 | ): Effect.fn.Return<string, GitCommandError> { | |
| 47 | const decoder = new TextDecoder(); | |
| 48 | let bytes = 0; | |
| 49 | let text = ""; | |
| 50 | ||
| 51 | yield* Stream.runForEach(stream, (chunk) => | |
| 52 | Effect.gen(function* () { | |
| 53 | bytes += chunk.byteLength; | |
| 54 | if (bytes > maxOutputBytes) { | |
| 55 | return yield* new GitCommandError({ | |
| 56 | operation: input.operation, | |
| 57 | command: quoteGitCommand(input.args), | |
| 58 | cwd: input.cwd, | |
| 59 | detail: `${quoteGitCommand(input.args)} output exceeded ${maxOutputBytes} bytes and was truncated.`, | |
| 60 | }); | |
| 61 | } | |
| 62 | text += decoder.decode(chunk, { stream: true }); | |
| 63 | }), | |
| 64 | ).pipe(Effect.mapError(toGitCommandError(input, "output stream failed."))); | |
| 65 | ||
| 66 | text += decoder.decode(); | |
| 67 | return text; | |
| 68 | }); | |
| 69 | ||
| 70 | const makeGitService = Effect.gen(function* () { | |
| 71 | const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; | |
| 72 | ||
| 73 | const execute: GitServiceShape["execute"] = Effect.fnUntraced(function* (input) { | |
| 74 | const commandInput = { | |
| 75 | ...input, | |
| 76 | args: [...input.args], | |
| 77 | } as const; | |
| 78 | const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; | |
| 79 | const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; | |
| 80 | ||
| 81 | const commandEffect = Effect.gen(function* () { | |
| 82 | const child = yield* commandSpawner | |
| 83 | .spawn( | |
| 84 | ChildProcess.make("git", commandInput.args, { | |
| 85 | cwd: commandInput.cwd, | |
| 86 | ...(input.env ? { env: input.env } : {}), | |
| 87 | }), | |
| 88 | ) | |
| 89 | .pipe(Effect.mapError(toGitCommandError(commandInput, "failed to spawn."))); | |
| 90 | ||
| 91 | const [stdout, stderr, exitCode] = yield* Effect.all( | |
| 92 | [ | |
| 93 | collectOutput(commandInput, child.stdout, maxOutputBytes), | |
| 94 | collectOutput(commandInput, child.stderr, maxOutputBytes), | |
| 95 | child.exitCode.pipe( | |
| 96 | Effect.map((value) => Number(value)), | |
| 97 | Effect.mapError(toGitCommandError(commandInput, "failed to report exit code.")), | |
| 98 | ), | |
| 99 | ], | |
| 100 | { concurrency: "unbounded" }, | |
| 101 | ); | |
| 102 | ||
| 103 | if (!input.allowNonZeroExit && exitCode !== 0) { | |
| 104 | const trimmedStderr = stderr.trim(); | |
| 105 | return yield* new GitCommandError({ | |
| 106 | operation: commandInput.operation, | |
| 107 | command: quoteGitCommand(commandInput.args), | |
| 108 | cwd: commandInput.cwd, | |
| 109 | detail: | |
| 110 | trimmedStderr.length > 0 | |
| 111 | ? `${quoteGitCommand(commandInput.args)} failed: ${trimmedStderr}` | |
| 112 | : `${quoteGitCommand(commandInput.args)} failed with code ${exitCode}.`, | |
| 113 | }); | |
| 114 | } | |
| 115 | ||
| 116 | return { code: exitCode, stdout, stderr } satisfies ExecuteGitResult; | |
| 117 | }); | |
| 118 | ||
| 119 | return yield* commandEffect.pipe( | |
| 120 | Effect.scoped, | |
| 121 | Effect.timeoutOption(timeoutMs), | |
| 122 | Effect.flatMap((result) => | |
| 123 | Option.match(result, { | |
| 124 | onNone: () => | |
| 125 | Effect.fail( | |
| 126 | new GitCommandError({ | |
| 127 | operation: commandInput.operation, | |
| 128 | command: quoteGitCommand(commandInput.args), | |
| 129 | cwd: commandInput.cwd, | |
| 130 | detail: `${quoteGitCommand(commandInput.args)} timed out.`, | |
| 131 | }), | |
| 132 | ), | |
| 133 | onSome: Effect.succeed, | |
| 134 | }), | |
| 135 | ), | |
| 136 | ); | |
| 137 | }); | |
| 138 | ||
| 139 | return { | |
| 140 | execute, | |
| 141 | } satisfies GitServiceShape; | |
| 142 | }); | |
| 143 | ||
| 144 | export const GitServiceLive = Layer.effect(GitService, makeGitService); |
apps/server/src/git/Services/GitCore.ts+21| ... | ... | @@ -24,6 +24,22 @@ import type { |
| 24 | 24 | |
| 25 | 25 | import type { GitCommandError } from "../Errors.ts"; |
| 26 | 26 | |
| 27 | export interface ExecuteGitInput { | |
| 28 | readonly operation: string; | |
| 29 | readonly cwd: string; | |
| 30 | readonly args: ReadonlyArray<string>; | |
| 31 | readonly env?: NodeJS.ProcessEnv; | |
| 32 | readonly allowNonZeroExit?: boolean; | |
| 33 | readonly timeoutMs?: number; | |
| 34 | readonly maxOutputBytes?: number; | |
| 35 | } | |
| 36 | ||
| 37 | export interface ExecuteGitResult { | |
| 38 | readonly code: number; | |
| 39 | readonly stdout: string; | |
| 40 | readonly stderr: string; | |
| 41 | } | |
| 42 | ||
| 27 | 43 | export interface GitStatusDetails extends Omit<GitStatusResult, "pr"> { |
| 28 | 44 | upstreamRef: string | null; |
| 29 | 45 | } |
| ... | ... | @@ -86,6 +102,11 @@ export interface GitSetBranchUpstreamInput { |
| 86 | 102 | * GitCoreShape - Service API for low-level Git repository interactions. |
| 87 | 103 | */ |
| 88 | 104 | export interface GitCoreShape { |
| 105 | /** | |
| 106 | * Execute a raw Git command. | |
| 107 | */ | |
| 108 | readonly execute: (input: ExecuteGitInput) => Effect.Effect<ExecuteGitResult, GitCommandError>; | |
| 109 | ||
| 89 | 110 | /** |
| 90 | 111 | * Read Git status for a repository. |
| 91 | 112 | */ |
apps/server/src/git/Services/GitService.ts deleted-45| ... | ... | @@ -1,45 +0,0 @@ |
| 1 | /** | |
| 2 | * GitService - Service for Git command execution. | |
| 3 | * | |
| 4 | * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed | |
| 5 | * domain errors for Git command execution. | |
| 6 | * | |
| 7 | * @module GitService | |
| 8 | */ | |
| 9 | import { ServiceMap } from "effect"; | |
| 10 | import type { Effect } from "effect"; | |
| 11 | ||
| 12 | import type { GitCommandError } from "../Errors.ts"; | |
| 13 | ||
| 14 | export interface ExecuteGitInput { | |
| 15 | readonly operation: string; | |
| 16 | readonly cwd: string; | |
| 17 | readonly args: ReadonlyArray<string>; | |
| 18 | readonly env?: NodeJS.ProcessEnv; | |
| 19 | readonly allowNonZeroExit?: boolean; | |
| 20 | readonly timeoutMs?: number; | |
| 21 | readonly maxOutputBytes?: number; | |
| 22 | } | |
| 23 | ||
| 24 | export interface ExecuteGitResult { | |
| 25 | readonly code: number; | |
| 26 | readonly stdout: string; | |
| 27 | readonly stderr: string; | |
| 28 | } | |
| 29 | ||
| 30 | /** | |
| 31 | * GitServiceShape - Service API for Git command execution. | |
| 32 | */ | |
| 33 | export interface GitServiceShape { | |
| 34 | /** | |
| 35 | * Execute a Git command. | |
| 36 | */ | |
| 37 | readonly execute: (input: ExecuteGitInput) => Effect.Effect<ExecuteGitResult, GitCommandError>; | |
| 38 | } | |
| 39 | ||
| 40 | /** | |
| 41 | * GitService - Service for Git command execution. | |
| 42 | */ | |
| 43 | export class GitService extends ServiceMap.Service<GitService, GitServiceShape>()( | |
| 44 | "t3/git/Services/GitService", | |
| 45 | ) {} |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+2-1| ... | ... | @@ -19,6 +19,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; |
| 19 | 19 | |
| 20 | 20 | import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; |
| 21 | 21 | import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; |
| 22 | import { GitCoreLive } from "../../git/Layers/GitCore.ts"; | |
| 22 | 23 | import { CheckpointReactorLive } from "./CheckpointReactor.ts"; |
| 23 | 24 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 24 | 25 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| ... | ... | @@ -260,7 +261,7 @@ describe("CheckpointReactor", () => { |
| 260 | 261 | Layer.provideMerge(orchestrationLayer), |
| 261 | 262 | Layer.provideMerge(RuntimeReceiptBusLive), |
| 262 | 263 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), |
| 263 | Layer.provideMerge(CheckpointStoreLive), | |
| 264 | Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(GitCoreLive))), | |
| 264 | 265 | Layer.provideMerge(ServerConfigLayer), |
| 265 | 266 | Layer.provideMerge(NodeServices.layer), |
| 266 | 267 | ); |
apps/server/src/provider/Layers/ClaudeSdkFastMode.probe.test.ts deleted-149| ... | ... | @@ -1,149 +0,0 @@ |
| 1 | import { EventEmitter } from "node:events"; | |
| 2 | import { PassThrough } from "node:stream"; | |
| 3 | import { query, type SpawnOptions, type SpawnedProcess } from "@anthropic-ai/claude-agent-sdk"; | |
| 4 | import { afterEach, describe, expect, it } from "vitest"; | |
| 5 | ||
| 6 | async function* emptyPrompt(): AsyncGenerator<never, void, void> {} | |
| 7 | ||
| 8 | class FakeClaudeCodeProcess implements SpawnedProcess { | |
| 9 | readonly stdin = new PassThrough(); | |
| 10 | readonly stdout = new PassThrough(); | |
| 11 | killed = false; | |
| 12 | exitCode: number | null = null; | |
| 13 | ||
| 14 | private readonly events = new EventEmitter(); | |
| 15 | private bufferedInput = ""; | |
| 16 | ||
| 17 | constructor( | |
| 18 | private readonly onMessage: ( | |
| 19 | message: Record<string, unknown>, | |
| 20 | process: FakeClaudeCodeProcess, | |
| 21 | ) => void, | |
| 22 | ) { | |
| 23 | this.stdin.setEncoding("utf8"); | |
| 24 | this.stdin.on("data", (chunk: string) => { | |
| 25 | this.bufferedInput += chunk; | |
| 26 | this.drainInput(); | |
| 27 | }); | |
| 28 | } | |
| 29 | ||
| 30 | emitJson(message: unknown): void { | |
| 31 | this.stdout.write(`${JSON.stringify(message)}\n`); | |
| 32 | } | |
| 33 | ||
| 34 | kill(_signal: NodeJS.Signals): boolean { | |
| 35 | this.killed = true; | |
| 36 | this.exitCode = 0; | |
| 37 | this.stdout.end(); | |
| 38 | this.events.emit("exit", 0, null); | |
| 39 | return true; | |
| 40 | } | |
| 41 | ||
| 42 | on( | |
| 43 | event: "exit" | "error", | |
| 44 | listener: | |
| 45 | | ((code: number | null, signal: NodeJS.Signals | null) => void) | |
| 46 | | ((error: Error) => void), | |
| 47 | ): void { | |
| 48 | this.events.on(event, listener); | |
| 49 | } | |
| 50 | ||
| 51 | once( | |
| 52 | event: "exit" | "error", | |
| 53 | listener: | |
| 54 | | ((code: number | null, signal: NodeJS.Signals | null) => void) | |
| 55 | | ((error: Error) => void), | |
| 56 | ): void { | |
| 57 | this.events.once(event, listener); | |
| 58 | } | |
| 59 | ||
| 60 | off( | |
| 61 | event: "exit" | "error", | |
| 62 | listener: | |
| 63 | | ((code: number | null, signal: NodeJS.Signals | null) => void) | |
| 64 | | ((error: Error) => void), | |
| 65 | ): void { | |
| 66 | this.events.off(event, listener); | |
| 67 | } | |
| 68 | ||
| 69 | private drainInput(): void { | |
| 70 | while (true) { | |
| 71 | const newlineIndex = this.bufferedInput.indexOf("\n"); | |
| 72 | if (newlineIndex === -1) { | |
| 73 | return; | |
| 74 | } | |
| 75 | const line = this.bufferedInput.slice(0, newlineIndex).trim(); | |
| 76 | this.bufferedInput = this.bufferedInput.slice(newlineIndex + 1); | |
| 77 | if (line.length === 0) { | |
| 78 | continue; | |
| 79 | } | |
| 80 | this.onMessage(JSON.parse(line) as Record<string, unknown>, this); | |
| 81 | } | |
| 82 | } | |
| 83 | } | |
| 84 | ||
| 85 | describe("Claude SDK fast mode probe", () => { | |
| 86 | let activeQuery: ReturnType<typeof query> | null = null; | |
| 87 | ||
| 88 | afterEach(() => { | |
| 89 | activeQuery?.close(); | |
| 90 | activeQuery = null; | |
| 91 | }); | |
| 92 | ||
| 93 | it("passes fast mode through the SDK settings flag", async () => { | |
| 94 | let spawnOptions: SpawnOptions | undefined; | |
| 95 | ||
| 96 | activeQuery = query({ | |
| 97 | prompt: emptyPrompt(), | |
| 98 | options: { | |
| 99 | persistSession: false, | |
| 100 | settings: { | |
| 101 | fastMode: true, | |
| 102 | }, | |
| 103 | spawnClaudeCodeProcess: (options): SpawnedProcess => { | |
| 104 | spawnOptions = options; | |
| 105 | return new FakeClaudeCodeProcess((message, process) => { | |
| 106 | if ( | |
| 107 | message.type !== "control_request" || | |
| 108 | typeof message.request_id !== "string" || | |
| 109 | !message.request || | |
| 110 | typeof message.request !== "object" || | |
| 111 | (message.request as { subtype?: unknown }).subtype !== "initialize" | |
| 112 | ) { | |
| 113 | return; | |
| 114 | } | |
| 115 | ||
| 116 | process.emitJson({ | |
| 117 | type: "control_response", | |
| 118 | response: { | |
| 119 | subtype: "success", | |
| 120 | request_id: message.request_id, | |
| 121 | response: { | |
| 122 | commands: [], | |
| 123 | agents: [], | |
| 124 | output_style: "default", | |
| 125 | available_output_styles: ["default"], | |
| 126 | models: [], | |
| 127 | account: { | |
| 128 | subscriptionType: "max", | |
| 129 | }, | |
| 130 | fast_mode_state: "on", | |
| 131 | }, | |
| 132 | }, | |
| 133 | }); | |
| 134 | }); | |
| 135 | }, | |
| 136 | }, | |
| 137 | }); | |
| 138 | ||
| 139 | const initialization = await activeQuery.initializationResult(); | |
| 140 | expect(initialization.fast_mode_state).toBe("on"); | |
| 141 | ||
| 142 | expect(spawnOptions).toBeDefined(); | |
| 143 | const settingsFlagIndex = spawnOptions?.args.indexOf("--settings") ?? -1; | |
| 144 | expect(settingsFlagIndex).toBeGreaterThan(-1); | |
| 145 | expect(JSON.parse(spawnOptions?.args[settingsFlagIndex + 1] ?? "")).toEqual({ | |
| 146 | fastMode: true, | |
| 147 | }); | |
| 148 | }); | |
| 149 | }); |
apps/server/src/serverLayers.ts+6-7| ... | ... | @@ -31,7 +31,6 @@ import { GitManagerLive } from "./git/Layers/GitManager"; |
| 31 | 31 | import { GitCoreLive } from "./git/Layers/GitCore"; |
| 32 | 32 | import { GitHubCliLive } from "./git/Layers/GitHubCli"; |
| 33 | 33 | import { CodexTextGenerationLive } from "./git/Layers/CodexTextGeneration"; |
| 34 | import { GitServiceLive } from "./git/Layers/GitService"; | |
| 35 | 34 | import { PtyAdapter } from "./terminal/Services/PTY"; |
| 36 | 35 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; |
| 37 | 36 | |
| ... | ... | @@ -86,8 +85,8 @@ export function makeServerProviderLayer(): Layer.Layer< |
| 86 | 85 | } |
| 87 | 86 | |
| 88 | 87 | export function makeServerRuntimeServicesLayer() { |
| 89 | const gitCoreLayer = GitCoreLive.pipe(Layer.provideMerge(GitServiceLive)); | |
| 90 | 88 | const textGenerationLayer = CodexTextGenerationLive; |
| 89 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive)); | |
| 91 | 90 | |
| 92 | 91 | const orchestrationLayer = OrchestrationEngineLive.pipe( |
| 93 | 92 | Layer.provide(OrchestrationProjectionPipelineLive), |
| ... | ... | @@ -97,13 +96,13 @@ export function makeServerRuntimeServicesLayer() { |
| 97 | 96 | |
| 98 | 97 | const checkpointDiffQueryLayer = CheckpointDiffQueryLive.pipe( |
| 99 | 98 | Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), |
| 100 | Layer.provideMerge(CheckpointStoreLive), | |
| 99 | Layer.provideMerge(checkpointStoreLayer), | |
| 101 | 100 | ); |
| 102 | 101 | |
| 103 | 102 | const runtimeServicesLayer = Layer.mergeAll( |
| 104 | 103 | orchestrationLayer, |
| 105 | 104 | OrchestrationProjectionSnapshotQueryLive, |
| 106 | CheckpointStoreLive, | |
| 105 | checkpointStoreLayer, | |
| 107 | 106 | checkpointDiffQueryLayer, |
| 108 | 107 | RuntimeReceiptBusLive, |
| 109 | 108 | ); |
| ... | ... | @@ -112,7 +111,7 @@ export function makeServerRuntimeServicesLayer() { |
| 112 | 111 | ); |
| 113 | 112 | const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( |
| 114 | 113 | Layer.provideMerge(runtimeServicesLayer), |
| 115 | Layer.provideMerge(gitCoreLayer), | |
| 114 | Layer.provideMerge(GitCoreLive), | |
| 116 | 115 | Layer.provideMerge(textGenerationLayer), |
| 117 | 116 | ); |
| 118 | 117 | const checkpointReactorLayer = CheckpointReactorLive.pipe( |
| ... | ... | @@ -127,14 +126,14 @@ export function makeServerRuntimeServicesLayer() { |
| 127 | 126 | const terminalLayer = TerminalManagerLive.pipe(Layer.provide(makeRuntimePtyAdapterLayer())); |
| 128 | 127 | |
| 129 | 128 | const gitManagerLayer = GitManagerLive.pipe( |
| 130 | Layer.provideMerge(gitCoreLayer), | |
| 129 | Layer.provideMerge(GitCoreLive), | |
| 131 | 130 | Layer.provideMerge(GitHubCliLive), |
| 132 | 131 | Layer.provideMerge(textGenerationLayer), |
| 133 | 132 | ); |
| 134 | 133 | |
| 135 | 134 | return Layer.mergeAll( |
| 136 | 135 | orchestrationReactorLayer, |
| 137 | gitCoreLayer, | |
| 136 | GitCoreLive, | |
| 138 | 137 | gitManagerLayer, |
| 139 | 138 | terminalLayer, |
| 140 | 139 | KeybindingsLive, |
apps/web/package.json+1-1| ... | ... | @@ -51,7 +51,7 @@ |
| 51 | 51 | "@vitejs/plugin-react": "^6.0.0", |
| 52 | 52 | "@vitest/browser-playwright": "^4.0.18", |
| 53 | 53 | "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", |
| 54 | "msw": "^2.12.10", | |
| 54 | "msw": "2.12.11", | |
| 55 | 55 | "playwright": "^1.58.2", |
| 56 | 56 | "tailwindcss": "^4.0.0", |
| 57 | 57 | "typescript": "catalog:", |
apps/web/public/mockServiceWorker.js+1-1| ... | ... | @@ -7,7 +7,7 @@ |
| 7 | 7 | * - Please do NOT modify this file. |
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | const PACKAGE_VERSION = '2.12.10' | |
| 10 | const PACKAGE_VERSION = '2.12.11' | |
| 11 | 11 | const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' |
| 12 | 12 | const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') |
| 13 | 13 | const activeClientIds = new Set() |
bun.lock+1-1| ... | ... | @@ -112,7 +112,7 @@ |
| 112 | 112 | "@vitejs/plugin-react": "^6.0.0", |
| 113 | 113 | "@vitest/browser-playwright": "^4.0.18", |
| 114 | 114 | "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", |
| 115 | "msw": "^2.12.10", | |
| 115 | "msw": "2.12.11", | |
| 116 | 116 | "playwright": "^1.58.2", |
| 117 | 117 | "tailwindcss": "^4.0.0", |
| 118 | 118 | "typescript": "catalog:", |
scripts/claude-fast-mode-probe.ts deleted-129| ... | ... | @@ -1,129 +0,0 @@ |
| 1 | import { query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk"; | |
| 2 | ||
| 3 | type ProbeMode = "off" | "on"; | |
| 4 | ||
| 5 | async function* emptyPrompt(): AsyncGenerator<never, void, void> {} | |
| 6 | ||
| 7 | function parseArgs(argv: ReadonlyArray<string>): { | |
| 8 | readonly mode: ProbeMode | "both"; | |
| 9 | readonly model?: string; | |
| 10 | readonly cwd?: string; | |
| 11 | readonly prompt?: string; | |
| 12 | } { | |
| 13 | let mode: ProbeMode | "both" = "both"; | |
| 14 | let model: string | undefined; | |
| 15 | let cwd: string | undefined; | |
| 16 | let prompt: string | undefined; | |
| 17 | ||
| 18 | for (let index = 0; index < argv.length; index += 1) { | |
| 19 | const value = argv[index]; | |
| 20 | if (!value) { | |
| 21 | continue; | |
| 22 | } | |
| 23 | if (value === "--mode") { | |
| 24 | const next = argv[index + 1]; | |
| 25 | if (next === "off" || next === "on" || next === "both") { | |
| 26 | mode = next; | |
| 27 | index += 1; | |
| 28 | } | |
| 29 | continue; | |
| 30 | } | |
| 31 | if (value === "--model") { | |
| 32 | model = argv[index + 1]; | |
| 33 | index += 1; | |
| 34 | continue; | |
| 35 | } | |
| 36 | if (value === "--cwd") { | |
| 37 | cwd = argv[index + 1]; | |
| 38 | index += 1; | |
| 39 | continue; | |
| 40 | } | |
| 41 | if (value === "--prompt") { | |
| 42 | prompt = argv[index + 1] ?? prompt; | |
| 43 | index += 1; | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | return { | |
| 48 | mode, | |
| 49 | ...(model ? { model } : {}), | |
| 50 | ...(cwd ? { cwd } : {}), | |
| 51 | ...(prompt ? { prompt } : {}), | |
| 52 | }; | |
| 53 | } | |
| 54 | ||
| 55 | async function runProbe(input: { | |
| 56 | readonly mode: ProbeMode; | |
| 57 | readonly model?: string; | |
| 58 | readonly cwd?: string; | |
| 59 | readonly prompt?: string; | |
| 60 | }): Promise<void> { | |
| 61 | const messages = query({ | |
| 62 | prompt: input.prompt ?? emptyPrompt(), | |
| 63 | options: { | |
| 64 | ...(input.model ? { model: input.model } : {}), | |
| 65 | ...(input.cwd ? { cwd: input.cwd } : {}), | |
| 66 | persistSession: false, | |
| 67 | tools: [], | |
| 68 | permissionMode: "plan", | |
| 69 | includePartialMessages: true, | |
| 70 | ...(input.mode === "on" ? { settings: { fastMode: true } } : {}), | |
| 71 | }, | |
| 72 | }); | |
| 73 | ||
| 74 | const summary = { | |
| 75 | mode: input.mode, | |
| 76 | initFastModeState: null as string | null, | |
| 77 | resultFastModeState: null as string | null, | |
| 78 | resultSubtype: null as string | null, | |
| 79 | resultText: null as string | null, | |
| 80 | }; | |
| 81 | ||
| 82 | const initialization = await messages.initializationResult(); | |
| 83 | summary.initFastModeState = initialization.fast_mode_state ?? null; | |
| 84 | ||
| 85 | if (!input.prompt) { | |
| 86 | messages.close(); | |
| 87 | console.log(JSON.stringify(summary, null, 2)); | |
| 88 | return; | |
| 89 | } | |
| 90 | ||
| 91 | for await (const message of messages) { | |
| 92 | handleProbeMessage(message, summary); | |
| 93 | } | |
| 94 | console.log(JSON.stringify(summary, null, 2)); | |
| 95 | } | |
| 96 | ||
| 97 | function handleProbeMessage( | |
| 98 | message: SDKMessage, | |
| 99 | summary: { | |
| 100 | mode: ProbeMode; | |
| 101 | initFastModeState: string | null; | |
| 102 | resultFastModeState: string | null; | |
| 103 | resultSubtype: string | null; | |
| 104 | resultText: string | null; | |
| 105 | }, | |
| 106 | ): void { | |
| 107 | if (message.type !== "result") { | |
| 108 | return; | |
| 109 | } | |
| 110 | summary.resultSubtype = message.subtype; | |
| 111 | summary.resultFastModeState = message.fast_mode_state ?? null; | |
| 112 | summary.resultText = message.subtype === "success" ? message.result : message.errors.join("\n"); | |
| 113 | } | |
| 114 | ||
| 115 | async function main(): Promise<void> { | |
| 116 | const args = parseArgs(process.argv.slice(2)); | |
| 117 | const modes = args.mode === "both" ? (["off", "on"] as const) : [args.mode]; | |
| 118 | ||
| 119 | for (const mode of modes) { | |
| 120 | await runProbe({ | |
| 121 | mode, | |
| 122 | ...(args.model ? { model: args.model } : {}), | |
| 123 | ...(args.cwd ? { cwd: args.cwd } : {}), | |
| 124 | ...(args.prompt ? { prompt: args.prompt } : {}), | |
| 125 | }); | |
| 126 | } | |
| 127 | } | |
| 128 | ||
| 129 | await main(); |
scripts/claude-haiku-thinking-probe.ts deleted-261| ... | ... | @@ -1,261 +0,0 @@ |
| 1 | import { query, type SDKMessage, type ThinkingConfig } from "@anthropic-ai/claude-agent-sdk"; | |
| 2 | ||
| 3 | type ProbeCase = | |
| 4 | | "default" | |
| 5 | | "effort-low" | |
| 6 | | "effort-high" | |
| 7 | | "settings-off" | |
| 8 | | "settings-on" | |
| 9 | | "thinking-disabled" | |
| 10 | | "thinking-enabled"; | |
| 11 | ||
| 12 | function parseArgs(argv: ReadonlyArray<string>): { | |
| 13 | readonly model: string; | |
| 14 | readonly cwd?: string; | |
| 15 | readonly prompt: string; | |
| 16 | readonly cases: ReadonlyArray<ProbeCase>; | |
| 17 | } { | |
| 18 | let model = "claude-haiku-4-5"; | |
| 19 | let cwd: string | undefined; | |
| 20 | let prompt = | |
| 21 | "Think step by step about this before answering: what is 27 multiplied by 43? Return the final number only."; | |
| 22 | let cases: ReadonlyArray<ProbeCase> = [ | |
| 23 | "default", | |
| 24 | "effort-low", | |
| 25 | "effort-high", | |
| 26 | "settings-off", | |
| 27 | "settings-on", | |
| 28 | "thinking-disabled", | |
| 29 | "thinking-enabled", | |
| 30 | ]; | |
| 31 | ||
| 32 | for (let index = 0; index < argv.length; index += 1) { | |
| 33 | const value = argv[index]; | |
| 34 | if (!value) { | |
| 35 | continue; | |
| 36 | } | |
| 37 | if (value === "--model") { | |
| 38 | model = argv[index + 1] ?? model; | |
| 39 | index += 1; | |
| 40 | continue; | |
| 41 | } | |
| 42 | if (value === "--cwd") { | |
| 43 | cwd = argv[index + 1] ?? cwd; | |
| 44 | index += 1; | |
| 45 | continue; | |
| 46 | } | |
| 47 | if (value === "--prompt") { | |
| 48 | prompt = argv[index + 1] ?? prompt; | |
| 49 | index += 1; | |
| 50 | continue; | |
| 51 | } | |
| 52 | if (value === "--cases") { | |
| 53 | const next = argv[index + 1]; | |
| 54 | if (next) { | |
| 55 | const requested = next | |
| 56 | .split(",") | |
| 57 | .map((entry) => entry.trim()) | |
| 58 | .filter( | |
| 59 | (entry): entry is ProbeCase => | |
| 60 | entry === "default" || | |
| 61 | entry === "effort-low" || | |
| 62 | entry === "effort-high" || | |
| 63 | entry === "settings-off" || | |
| 64 | entry === "settings-on" || | |
| 65 | entry === "thinking-disabled" || | |
| 66 | entry === "thinking-enabled", | |
| 67 | ); | |
| 68 | if (requested.length > 0) { | |
| 69 | cases = requested; | |
| 70 | } | |
| 71 | } | |
| 72 | index += 1; | |
| 73 | } | |
| 74 | } | |
| 75 | ||
| 76 | return { | |
| 77 | model, | |
| 78 | prompt, | |
| 79 | ...(cwd ? { cwd } : {}), | |
| 80 | cases, | |
| 81 | }; | |
| 82 | } | |
| 83 | ||
| 84 | function caseOptions(caseName: ProbeCase): { | |
| 85 | readonly label: ProbeCase; | |
| 86 | readonly effort?: "low" | "high"; | |
| 87 | readonly settings?: { alwaysThinkingEnabled: boolean }; | |
| 88 | readonly thinking?: ThinkingConfig; | |
| 89 | } { | |
| 90 | switch (caseName) { | |
| 91 | case "effort-low": | |
| 92 | return { | |
| 93 | label: caseName, | |
| 94 | effort: "low", | |
| 95 | }; | |
| 96 | case "effort-high": | |
| 97 | return { | |
| 98 | label: caseName, | |
| 99 | effort: "high", | |
| 100 | }; | |
| 101 | case "settings-off": | |
| 102 | return { | |
| 103 | label: caseName, | |
| 104 | settings: { | |
| 105 | alwaysThinkingEnabled: false, | |
| 106 | }, | |
| 107 | }; | |
| 108 | case "settings-on": | |
| 109 | return { | |
| 110 | label: caseName, | |
| 111 | settings: { | |
| 112 | alwaysThinkingEnabled: true, | |
| 113 | }, | |
| 114 | }; | |
| 115 | case "thinking-disabled": | |
| 116 | return { | |
| 117 | label: caseName, | |
| 118 | thinking: { | |
| 119 | type: "disabled", | |
| 120 | }, | |
| 121 | }; | |
| 122 | case "thinking-enabled": | |
| 123 | return { | |
| 124 | label: caseName, | |
| 125 | thinking: { | |
| 126 | type: "enabled", | |
| 127 | budgetTokens: 1024, | |
| 128 | }, | |
| 129 | }; | |
| 130 | default: | |
| 131 | return { | |
| 132 | label: caseName, | |
| 133 | }; | |
| 134 | } | |
| 135 | } | |
| 136 | ||
| 137 | type ProbeSummary = { | |
| 138 | readonly case: ProbeCase; | |
| 139 | readonly model: string; | |
| 140 | initModelInfo: { | |
| 141 | value: string | null; | |
| 142 | supportsEffort: boolean | null; | |
| 143 | supportsAdaptiveThinking: boolean | null; | |
| 144 | supportedEffortLevels: ReadonlyArray<string> | null; | |
| 145 | } | null; | |
| 146 | resultSubtype: string | null; | |
| 147 | resultText: string | null; | |
| 148 | assistantThinkingBlockCount: number; | |
| 149 | streamThinkingEventTypes: Record<string, number>; | |
| 150 | error: string | null; | |
| 151 | }; | |
| 152 | ||
| 153 | async function runCase(input: { | |
| 154 | readonly caseName: ProbeCase; | |
| 155 | readonly model: string; | |
| 156 | readonly cwd?: string; | |
| 157 | readonly prompt: string; | |
| 158 | }): Promise<ProbeSummary> { | |
| 159 | const caseConfig = caseOptions(input.caseName); | |
| 160 | const summary: ProbeSummary = { | |
| 161 | case: input.caseName, | |
| 162 | model: input.model, | |
| 163 | initModelInfo: null, | |
| 164 | resultSubtype: null, | |
| 165 | resultText: null, | |
| 166 | assistantThinkingBlockCount: 0, | |
| 167 | streamThinkingEventTypes: {}, | |
| 168 | error: null, | |
| 169 | }; | |
| 170 | ||
| 171 | try { | |
| 172 | const messages = query({ | |
| 173 | prompt: input.prompt, | |
| 174 | options: { | |
| 175 | model: input.model, | |
| 176 | ...(input.cwd ? { cwd: input.cwd } : {}), | |
| 177 | persistSession: false, | |
| 178 | tools: [], | |
| 179 | permissionMode: "bypassPermissions", | |
| 180 | includePartialMessages: true, | |
| 181 | maxTurns: 1, | |
| 182 | ...(caseConfig.effort ? { effort: caseConfig.effort } : {}), | |
| 183 | ...(caseConfig.settings ? { settings: caseConfig.settings } : {}), | |
| 184 | ...(caseConfig.thinking ? { thinking: caseConfig.thinking } : {}), | |
| 185 | }, | |
| 186 | }); | |
| 187 | ||
| 188 | const initialization = await messages.initializationResult(); | |
| 189 | const initModel = | |
| 190 | initialization.models.find((candidate) => candidate.value === input.model) ?? | |
| 191 | initialization.models.find((candidate) => candidate.value.includes("haiku")) ?? | |
| 192 | null; | |
| 193 | summary.initModelInfo = initModel | |
| 194 | ? { | |
| 195 | value: initModel.value, | |
| 196 | supportsEffort: initModel.supportsEffort ?? null, | |
| 197 | supportsAdaptiveThinking: initModel.supportsAdaptiveThinking ?? null, | |
| 198 | supportedEffortLevels: initModel.supportedEffortLevels ?? null, | |
| 199 | } | |
| 200 | : null; | |
| 201 | ||
| 202 | for await (const message of messages) { | |
| 203 | handleMessage(summary, message); | |
| 204 | } | |
| 205 | } catch (error) { | |
| 206 | summary.error = error instanceof Error ? error.message : String(error); | |
| 207 | } | |
| 208 | ||
| 209 | return summary; | |
| 210 | } | |
| 211 | ||
| 212 | function handleMessage(summary: ProbeSummary, message: SDKMessage): void { | |
| 213 | if (message.type === "stream_event") { | |
| 214 | const event = message.event as { | |
| 215 | type?: string; | |
| 216 | delta?: { | |
| 217 | type?: string; | |
| 218 | }; | |
| 219 | }; | |
| 220 | const eventType = event.delta?.type ?? event.type; | |
| 221 | if (typeof eventType === "string" && eventType.includes("thinking")) { | |
| 222 | summary.streamThinkingEventTypes[eventType] = | |
| 223 | (summary.streamThinkingEventTypes[eventType] ?? 0) + 1; | |
| 224 | } | |
| 225 | return; | |
| 226 | } | |
| 227 | ||
| 228 | if (message.type === "assistant") { | |
| 229 | for (const block of message.message.content) { | |
| 230 | if (typeof block?.type === "string" && block.type.includes("thinking")) { | |
| 231 | summary.assistantThinkingBlockCount += 1; | |
| 232 | } | |
| 233 | } | |
| 234 | return; | |
| 235 | } | |
| 236 | ||
| 237 | if (message.type === "result") { | |
| 238 | summary.resultSubtype = message.subtype; | |
| 239 | summary.resultText = message.subtype === "success" ? message.result : message.errors.join("\n"); | |
| 240 | } | |
| 241 | } | |
| 242 | ||
| 243 | async function main(): Promise<void> { | |
| 244 | const args = parseArgs(process.argv.slice(2)); | |
| 245 | const results: ProbeSummary[] = []; | |
| 246 | ||
| 247 | for (const caseName of args.cases) { | |
| 248 | results.push( | |
| 249 | await runCase({ | |
| 250 | caseName, | |
| 251 | model: args.model, | |
| 252 | ...(args.cwd ? { cwd: args.cwd } : {}), | |
| 253 | prompt: args.prompt, | |
| 254 | }), | |
| 255 | ); | |
| 256 | } | |
| 257 | ||
| 258 | console.log(JSON.stringify(results, null, 2)); | |
| 259 | } | |
| 260 | ||
| 261 | await main(); |