| @@ -1,7 +1,7 @@ |
| 1 | 1 | import { randomUUID } from "node:crypto"; |
| 2 | 2 | import { realpathSync } from "node:fs"; |
| 3 | 3 | |
| 4 | | −import { Effect, FileSystem, Layer, Option, Path, Ref } from "effect"; |
| 4 | +import { Cache, Duration, Effect, Exit, FileSystem, Layer, Option, Path, Ref } from "effect"; |
| 5 | 5 | import { |
| 6 | 6 | GitActionProgressEvent, |
| 7 | 7 | GitActionProgressPhase, |
| @@ -32,6 +32,8 @@ const COMMIT_TIMEOUT_MS = 10 * 60_000; · 24 unmodified lines |
| 32 | 32 | const MAX_PROGRESS_TEXT_LENGTH = 500; |
| 33 | 33 | const SHORT_SHA_LENGTH = 7; |
| 34 | 34 | const TOAST_DESCRIPTION_MAX = 72; |
| 35 | +const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); |
| 36 | +const STATUS_RESULT_CACHE_CAPACITY = 2_048; |
| 35 | 37 | type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "action"> : never; |
| 36 | 38 | type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>; |
| 37 | 39 | |
| @@ -656,6 +658,38 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 618 unmodified lines |
| 656 | 658 | const path = yield* Path.Path; |
| 657 | 659 | |
| 658 | 660 | const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; |
| 661 | + const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd); |
| 662 | + const readStatus = Effect.fn("readStatus")(function* (cwd: string) { |
| 663 | + const details = yield* gitCore.statusDetails(cwd); |
| 664 | + |
| 665 | + const pr = |
| 666 | + details.branch !== null |
| 667 | + ? yield* findLatestPr(cwd, { |
| 668 | + branch: details.branch, |
| 669 | + upstreamRef: details.upstreamRef, |
| 670 | + }).pipe( |
| 671 | + Effect.map((latest) => (latest ? toStatusPr(latest) : null)), |
| 672 | + Effect.catch(() => Effect.succeed(null)), |
| 673 | + ) |
| 674 | + : null; |
| 675 | + |
| 676 | + return { |
| 677 | + branch: details.branch, |
| 678 | + hasWorkingTreeChanges: details.hasWorkingTreeChanges, |
| 679 | + workingTree: details.workingTree, |
| 680 | + hasUpstream: details.hasUpstream, |
| 681 | + aheadCount: details.aheadCount, |
| 682 | + behindCount: details.behindCount, |
| 683 | + pr, |
| 684 | + }; |
| 685 | + }); |
| 686 | + const statusResultCache = yield* Cache.makeWith({ |
| 687 | + capacity: STATUS_RESULT_CACHE_CAPACITY, |
| 688 | + lookup: readStatus, |
| 689 | + timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), |
| 690 | + }); |
| 691 | + const invalidateStatusResultCache = (cwd: string) => |
| 692 | + Cache.invalidate(statusResultCache, normalizeStatusCacheKey(cwd)); |
| 659 | 693 | |
| 660 | 694 | const readConfigValueNullable = (cwd: string, key: string) => |
| 661 | 695 | gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); |
| @@ -1223,28 +1257,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 561 unmodified lines |
| 1223 | 1257 | }); |
| 1224 | 1258 | |
| 1225 | 1259 | const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) { |
| 1226 | | − const details = yield* gitCore.statusDetails(input.cwd); |
| 1227 | | − |
| 1228 | | − const pr = |
| 1229 | | − details.branch !== null |
| 1230 | | − ? yield* findLatestPr(input.cwd, { |
| 1231 | | − branch: details.branch, |
| 1232 | | − upstreamRef: details.upstreamRef, |
| 1233 | | − }).pipe( |
| 1234 | | − Effect.map((latest) => (latest ? toStatusPr(latest) : null)), |
| 1235 | | − Effect.catch(() => Effect.succeed(null)), |
| 1236 | | − ) |
| 1237 | | − : null; |
| 1238 | | − |
| 1239 | | − return { |
| 1240 | | − branch: details.branch, |
| 1241 | | − hasWorkingTreeChanges: details.hasWorkingTreeChanges, |
| 1242 | | − workingTree: details.workingTree, |
| 1243 | | − hasUpstream: details.hasUpstream, |
| 1244 | | − aheadCount: details.aheadCount, |
| 1245 | | − behindCount: details.behindCount, |
| 1246 | | − pr, |
| 1247 | | − }; |
| 1260 | + return yield* Cache.get(statusResultCache, normalizeStatusCacheKey(input.cwd)); |
| 1248 | 1261 | }); |
| 1249 | 1262 | |
| 1250 | 1263 | const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")( |
| @@ -1263,143 +1276,145 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 12 unmodified lines |
| 1263 | 1276 | const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn( |
| 1264 | 1277 | "preparePullRequestThread", |
| 1265 | 1278 | )(function* (input) { |
| 1266 | | − const normalizedReference = normalizePullRequestReference(input.reference); |
| 1267 | | − const rootWorktreePath = canonicalizeExistingPath(input.cwd); |
| 1268 | | − const pullRequestSummary = yield* gitHubCli.getPullRequest({ |
| 1269 | | − cwd: input.cwd, |
| 1270 | | − reference: normalizedReference, |
| 1271 | | − }); |
| 1272 | | − const pullRequest = toResolvedPullRequest(pullRequestSummary); |
| 1273 | | − |
| 1274 | | − if (input.mode === "local") { |
| 1275 | | − yield* gitHubCli.checkoutPullRequest({ |
| 1279 | + return yield* Effect.gen(function* () { |
| 1280 | + const normalizedReference = normalizePullRequestReference(input.reference); |
| 1281 | + const rootWorktreePath = canonicalizeExistingPath(input.cwd); |
| 1282 | + const pullRequestSummary = yield* gitHubCli.getPullRequest({ |
| 1276 | 1283 | cwd: input.cwd, |
| 1277 | 1284 | reference: normalizedReference, |
| 1278 | | − force: true, |
| 1279 | 1285 | }); |
| 1280 | | − const details = yield* gitCore.statusDetails(input.cwd); |
| 1281 | | − yield* configurePullRequestHeadUpstream( |
| 1282 | | − input.cwd, |
| 1283 | | − { |
| 1284 | | − ...pullRequest, |
| 1285 | | − ...toPullRequestHeadRemoteInfo(pullRequestSummary), |
| 1286 | | − }, |
| 1287 | | − details.branch ?? pullRequest.headBranch, |
| 1288 | | − ); |
| 1289 | | − return { |
| 1290 | | − pullRequest, |
| 1291 | | − branch: details.branch ?? pullRequest.headBranch, |
| 1292 | | − worktreePath: null, |
| 1293 | | − }; |
| 1294 | | − } |
| 1286 | + const pullRequest = toResolvedPullRequest(pullRequestSummary); |
| 1295 | 1287 | |
| 1296 | | − const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* ( |
| 1297 | | − worktreePath: string, |
| 1298 | | − ) { |
| 1299 | | − const details = yield* gitCore.statusDetails(worktreePath); |
| 1300 | | − yield* configurePullRequestHeadUpstream( |
| 1301 | | − worktreePath, |
| 1302 | | − { |
| 1303 | | − ...pullRequest, |
| 1304 | | − ...toPullRequestHeadRemoteInfo(pullRequestSummary), |
| 1305 | | − }, |
| 1306 | | − details.branch ?? pullRequest.headBranch, |
| 1307 | | − ); |
| 1308 | | − }); |
| 1288 | + if (input.mode === "local") { |
| 1289 | + yield* gitHubCli.checkoutPullRequest({ |
| 1290 | + cwd: input.cwd, |
| 1291 | + reference: normalizedReference, |
| 1292 | + force: true, |
| 1293 | + }); |
| 1294 | + const details = yield* gitCore.statusDetails(input.cwd); |
| 1295 | + yield* configurePullRequestHeadUpstream( |
| 1296 | + input.cwd, |
| 1297 | + { |
| 1298 | + ...pullRequest, |
| 1299 | + ...toPullRequestHeadRemoteInfo(pullRequestSummary), |
| 1300 | + }, |
| 1301 | + details.branch ?? pullRequest.headBranch, |
| 1302 | + ); |
| 1303 | + return { |
| 1304 | + pullRequest, |
| 1305 | + branch: details.branch ?? pullRequest.headBranch, |
| 1306 | + worktreePath: null, |
| 1307 | + }; |
| 1308 | + } |
| 1309 | 1309 | |
| 1310 | | − const pullRequestWithRemoteInfo = { |
| 1311 | | − ...pullRequest, |
| 1312 | | − ...toPullRequestHeadRemoteInfo(pullRequestSummary), |
| 1313 | | − } as const; |
| 1314 | | − const localPullRequestBranch = |
| 1315 | | − resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); |
| 1316 | | − |
| 1317 | | − const findLocalHeadBranch = (cwd: string) => |
| 1318 | | − gitCore.listBranches({ cwd }).pipe( |
| 1319 | | − Effect.map((result) => { |
| 1320 | | − const localBranch = result.branches.find( |
| 1321 | | − (branch) => !branch.isRemote && branch.name === localPullRequestBranch, |
| 1322 | | − ); |
| 1323 | | − if (localBranch) { |
| 1324 | | − return localBranch; |
| 1325 | | − } |
| 1326 | | − if (localPullRequestBranch === pullRequest.headBranch) { |
| 1327 | | − return null; |
| 1328 | | − } |
| 1329 | | − return ( |
| 1330 | | − result.branches.find( |
| 1331 | | − (branch) => |
| 1332 | | − !branch.isRemote && |
| 1333 | | − branch.name === pullRequest.headBranch && |
| 1334 | | − branch.worktreePath !== null && |
| 1335 | | − canonicalizeExistingPath(branch.worktreePath) !== rootWorktreePath, |
| 1336 | | − ) ?? null |
| 1337 | | − ); |
| 1338 | | − }), |
| 1339 | | − ); |
| 1310 | + const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* ( |
| 1311 | + worktreePath: string, |
| 1312 | + ) { |
| 1313 | + const details = yield* gitCore.statusDetails(worktreePath); |
| 1314 | + yield* configurePullRequestHeadUpstream( |
| 1315 | + worktreePath, |
| 1316 | + { |
| 1317 | + ...pullRequest, |
| 1318 | + ...toPullRequestHeadRemoteInfo(pullRequestSummary), |
| 1319 | + }, |
| 1320 | + details.branch ?? pullRequest.headBranch, |
| 1321 | + ); |
| 1322 | + }); |
| 1340 | 1323 | |
| 1341 | | − const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd); |
| 1342 | | − const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath |
| 1343 | | − ? canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath) |
| 1344 | | − : null; |
| 1345 | | − if ( |
| 1346 | | − existingBranchBeforeFetch?.worktreePath && |
| 1347 | | − existingBranchBeforeFetchPath !== rootWorktreePath |
| 1348 | | − ) { |
| 1349 | | − yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath); |
| 1350 | | − return { |
| 1351 | | − pullRequest, |
| 1352 | | − branch: localPullRequestBranch, |
| 1353 | | − worktreePath: existingBranchBeforeFetch.worktreePath, |
| 1354 | | − }; |
| 1355 | | − } |
| 1356 | | − if (existingBranchBeforeFetchPath === rootWorktreePath) { |
| 1357 | | − return yield* gitManagerError( |
| 1358 | | − "preparePullRequestThread", |
| 1359 | | − "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", |
| 1324 | + const pullRequestWithRemoteInfo = { |
| 1325 | + ...pullRequest, |
| 1326 | + ...toPullRequestHeadRemoteInfo(pullRequestSummary), |
| 1327 | + } as const; |
| 1328 | + const localPullRequestBranch = |
| 1329 | + resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); |
| 1330 | + |
| 1331 | + const findLocalHeadBranch = (cwd: string) => |
| 1332 | + gitCore.listBranches({ cwd }).pipe( |
| 1333 | + Effect.map((result) => { |
| 1334 | + const localBranch = result.branches.find( |
| 1335 | + (branch) => !branch.isRemote && branch.name === localPullRequestBranch, |
| 1336 | + ); |
| 1337 | + if (localBranch) { |
| 1338 | + return localBranch; |
| 1339 | + } |
| 1340 | + if (localPullRequestBranch === pullRequest.headBranch) { |
| 1341 | + return null; |
| 1342 | + } |
| 1343 | + return ( |
| 1344 | + result.branches.find( |
| 1345 | + (branch) => |
| 1346 | + !branch.isRemote && |
| 1347 | + branch.name === pullRequest.headBranch && |
| 1348 | + branch.worktreePath !== null && |
| 1349 | + canonicalizeExistingPath(branch.worktreePath) !== rootWorktreePath, |
| 1350 | + ) ?? null |
| 1351 | + ); |
| 1352 | + }), |
| 1353 | + ); |
| 1354 | + |
| 1355 | + const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd); |
| 1356 | + const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath |
| 1357 | + ? canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath) |
| 1358 | + : null; |
| 1359 | + if ( |
| 1360 | + existingBranchBeforeFetch?.worktreePath && |
| 1361 | + existingBranchBeforeFetchPath !== rootWorktreePath |
| 1362 | + ) { |
| 1363 | + yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath); |
| 1364 | + return { |
| 1365 | + pullRequest, |
| 1366 | + branch: localPullRequestBranch, |
| 1367 | + worktreePath: existingBranchBeforeFetch.worktreePath, |
| 1368 | + }; |
| 1369 | + } |
| 1370 | + if (existingBranchBeforeFetchPath === rootWorktreePath) { |
| 1371 | + return yield* gitManagerError( |
| 1372 | + "preparePullRequestThread", |
| 1373 | + "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", |
| 1374 | + ); |
| 1375 | + } |
| 1376 | + |
| 1377 | + yield* materializePullRequestHeadBranch( |
| 1378 | + input.cwd, |
| 1379 | + pullRequestWithRemoteInfo, |
| 1380 | + localPullRequestBranch, |
| 1360 | 1381 | ); |
| 1361 | | − } |
| 1362 | 1382 | |
| 1363 | | − yield* materializePullRequestHeadBranch( |
| 1364 | | − input.cwd, |
| 1365 | | − pullRequestWithRemoteInfo, |
| 1366 | | − localPullRequestBranch, |
| 1367 | | − ); |
| 1383 | + const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); |
| 1384 | + const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath |
| 1385 | + ? canonicalizeExistingPath(existingBranchAfterFetch.worktreePath) |
| 1386 | + : null; |
| 1387 | + if ( |
| 1388 | + existingBranchAfterFetch?.worktreePath && |
| 1389 | + existingBranchAfterFetchPath !== rootWorktreePath |
| 1390 | + ) { |
| 1391 | + yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath); |
| 1392 | + return { |
| 1393 | + pullRequest, |
| 1394 | + branch: localPullRequestBranch, |
| 1395 | + worktreePath: existingBranchAfterFetch.worktreePath, |
| 1396 | + }; |
| 1397 | + } |
| 1398 | + if (existingBranchAfterFetchPath === rootWorktreePath) { |
| 1399 | + return yield* gitManagerError( |
| 1400 | + "preparePullRequestThread", |
| 1401 | + "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", |
| 1402 | + ); |
| 1403 | + } |
| 1404 | + |
| 1405 | + const worktree = yield* gitCore.createWorktree({ |
| 1406 | + cwd: input.cwd, |
| 1407 | + branch: localPullRequestBranch, |
| 1408 | + path: null, |
| 1409 | + }); |
| 1410 | + yield* ensureExistingWorktreeUpstream(worktree.worktree.path); |
| 1368 | 1411 | |
| 1369 | | − const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); |
| 1370 | | − const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath |
| 1371 | | − ? canonicalizeExistingPath(existingBranchAfterFetch.worktreePath) |
| 1372 | | − : null; |
| 1373 | | − if ( |
| 1374 | | − existingBranchAfterFetch?.worktreePath && |
| 1375 | | − existingBranchAfterFetchPath !== rootWorktreePath |
| 1376 | | − ) { |
| 1377 | | − yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath); |
| 1378 | 1412 | return { |
| 1379 | 1413 | pullRequest, |
| 1380 | | − branch: localPullRequestBranch, |
| 1381 | | − worktreePath: existingBranchAfterFetch.worktreePath, |
| 1414 | + branch: worktree.worktree.branch, |
| 1415 | + worktreePath: worktree.worktree.path, |
| 1382 | 1416 | }; |
| 1383 | | − } |
| 1384 | | − if (existingBranchAfterFetchPath === rootWorktreePath) { |
| 1385 | | − return yield* gitManagerError( |
| 1386 | | − "preparePullRequestThread", |
| 1387 | | − "This PR branch is already checked out in the main repo. Use Local, or switch the main repo off that branch before creating a worktree thread.", |
| 1388 | | − ); |
| 1389 | | − } |
| 1390 | | − |
| 1391 | | − const worktree = yield* gitCore.createWorktree({ |
| 1392 | | − cwd: input.cwd, |
| 1393 | | − branch: localPullRequestBranch, |
| 1394 | | − path: null, |
| 1395 | | − }); |
| 1396 | | − yield* ensureExistingWorktreeUpstream(worktree.worktree.path); |
| 1397 | | − |
| 1398 | | − return { |
| 1399 | | − pullRequest, |
| 1400 | | − branch: worktree.worktree.branch, |
| 1401 | | − worktreePath: worktree.worktree.path, |
| 1402 | | − }; |
| 1417 | + }).pipe(Effect.ensuring(invalidateStatusResultCache(input.cwd))); |
| 1403 | 1418 | }); |
| 1404 | 1419 | |
| 1405 | 1420 | const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( |
| @@ -1582,6 +1597,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 176 unmodified lines |
| 1582 | 1597 | }); |
| 1583 | 1598 | |
| 1584 | 1599 | return yield* runAction().pipe( |
| 1600 | + Effect.ensuring(invalidateStatusResultCache(input.cwd)), |
| 1585 | 1601 | Effect.tapError((error) => |
| 1586 | 1602 | Effect.flatMap(Ref.get(currentPhase), (phase) => |
| 1587 | 1603 | progress.emit({ |