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