| @@ -1,7 +1,7 @@ |
| 1 | import { randomUUID } from "node:crypto"; | 1 | import { randomUUID } from "node:crypto"; |
| 2 | import { realpathSync } from "node:fs"; | 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 | import { | 5 | import { |
| 6 | GitActionProgressEvent, | 6 | GitActionProgressEvent, |
| 7 | GitActionProgressPhase, | 7 | GitActionProgressPhase, |
| @@ -32,6 +32,8 @@ const COMMIT_TIMEOUT_MS = 10 * 60_000; · 24 unmodified lines |
| 32 | const MAX_PROGRESS_TEXT_LENGTH = 500; | 32 | const MAX_PROGRESS_TEXT_LENGTH = 500; |
| 33 | const SHORT_SHA_LENGTH = 7; | 33 | const SHORT_SHA_LENGTH = 7; |
| 34 | const TOAST_DESCRIPTION_MAX = 72; | 34 | const TOAST_DESCRIPTION_MAX = 72; |
| | 35 | const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); |
| | 36 | const STATUS_RESULT_CACHE_CAPACITY = 2_048; |
| 35 | type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "action"> : never; | 37 | type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "action"> : never; |
| 36 | type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>; | 38 | type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>; |
| 37 | | 39 | |
| @@ -656,6 +658,38 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 618 unmodified lines |
| 656 | const path = yield* Path.Path; | 658 | const path = yield* Path.Path; |
| 657 | | 659 | |
| 658 | const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; | 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 | const readConfigValueNullable = (cwd: string, key: string) => | 694 | const readConfigValueNullable = (cwd: string, key: string) => |
| 661 | gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); | 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 | const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) { | 1259 | const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) { |
| 1226 | const details = yield* gitCore.statusDetails(input.cwd); | 1260 | return yield* Cache.get(statusResultCache, normalizeStatusCacheKey(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 | }; | | |
| 1248 | }); | 1261 | }); |
| 1249 | | 1262 | |
| 1250 | const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")( | 1263 | const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")( |
| @@ -1263,143 +1276,145 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 12 unmodified lines |
| 1263 | const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn( | 1276 | const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn( |
| 1264 | "preparePullRequestThread", | 1277 | "preparePullRequestThread", |
| 1265 | )(function* (input) { | 1278 | )(function* (input) { |
| 1266 | const normalizedReference = normalizePullRequestReference(input.reference); | 1279 | return yield* Effect.gen(function* () { |
| 1267 | const rootWorktreePath = canonicalizeExistingPath(input.cwd); | 1280 | const normalizedReference = normalizePullRequestReference(input.reference); |
| 1268 | const pullRequestSummary = yield* gitHubCli.getPullRequest({ | 1281 | const rootWorktreePath = canonicalizeExistingPath(input.cwd); |
| 1269 | cwd: input.cwd, | 1282 | const pullRequestSummary = yield* gitHubCli.getPullRequest({ |
| 1270 | reference: normalizedReference, | | |
| 1271 | }); | | |
| 1272 | const pullRequest = toResolvedPullRequest(pullRequestSummary); | | |
| 1273 | | | |
| 1274 | if (input.mode === "local") { | | |
| 1275 | yield* gitHubCli.checkoutPullRequest({ | | |
| 1276 | cwd: input.cwd, | 1283 | cwd: input.cwd, |
| 1277 | reference: normalizedReference, | 1284 | reference: normalizedReference, |
| 1278 | force: true, | | |
| 1279 | }); | 1285 | }); |
| 1280 | const details = yield* gitCore.statusDetails(input.cwd); | 1286 | const pullRequest = toResolvedPullRequest(pullRequestSummary); |
| 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 | } | | |
| 1295 | | 1287 | |
| 1296 | const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* ( | 1288 | if (input.mode === "local") { |
| 1297 | worktreePath: string, | 1289 | yield* gitHubCli.checkoutPullRequest({ |
| 1298 | ) { | 1290 | cwd: input.cwd, |
| 1299 | const details = yield* gitCore.statusDetails(worktreePath); | 1291 | reference: normalizedReference, |
| 1300 | yield* configurePullRequestHeadUpstream( | 1292 | force: true, |
| 1301 | worktreePath, | 1293 | }); |
| 1302 | { | 1294 | const details = yield* gitCore.statusDetails(input.cwd); |
| 1303 | ...pullRequest, | 1295 | yield* configurePullRequestHeadUpstream( |
| 1304 | ...toPullRequestHeadRemoteInfo(pullRequestSummary), | 1296 | input.cwd, |
| 1305 | }, | 1297 | { |
| 1306 | details.branch ?? pullRequest.headBranch, | 1298 | ...pullRequest, |
| 1307 | ); | 1299 | ...toPullRequestHeadRemoteInfo(pullRequestSummary), |
| 1308 | }); | 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 = { | 1310 | const ensureExistingWorktreeUpstream = Effect.fn("ensureExistingWorktreeUpstream")(function* ( |
| 1311 | ...pullRequest, | 1311 | worktreePath: string, |
| 1312 | ...toPullRequestHeadRemoteInfo(pullRequestSummary), | 1312 | ) { |
| 1313 | } as const; | 1313 | const details = yield* gitCore.statusDetails(worktreePath); |
| 1314 | const localPullRequestBranch = | 1314 | yield* configurePullRequestHeadUpstream( |
| 1315 | resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); | 1315 | worktreePath, |
| 1316 | | 1316 | { |
| 1317 | const findLocalHeadBranch = (cwd: string) => | 1317 | ...pullRequest, |
| 1318 | gitCore.listBranches({ cwd }).pipe( | 1318 | ...toPullRequestHeadRemoteInfo(pullRequestSummary), |
| 1319 | Effect.map((result) => { | 1319 | }, |
| 1320 | const localBranch = result.branches.find( | 1320 | details.branch ?? pullRequest.headBranch, |
| 1321 | (branch) => !branch.isRemote && branch.name === localPullRequestBranch, | 1321 | ); |
| 1322 | ); | 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 | ); | | |
| 1340 | | 1323 | |
| 1341 | const existingBranchBeforeFetch = yield* findLocalHeadBranch(input.cwd); | 1324 | const pullRequestWithRemoteInfo = { |
| 1342 | const existingBranchBeforeFetchPath = existingBranchBeforeFetch?.worktreePath | 1325 | ...pullRequest, |
| 1343 | ? canonicalizeExistingPath(existingBranchBeforeFetch.worktreePath) | 1326 | ...toPullRequestHeadRemoteInfo(pullRequestSummary), |
| 1344 | : null; | 1327 | } as const; |
| 1345 | if ( | 1328 | const localPullRequestBranch = |
| 1346 | existingBranchBeforeFetch?.worktreePath && | 1329 | resolvePullRequestWorktreeLocalBranchName(pullRequestWithRemoteInfo); |
| 1347 | existingBranchBeforeFetchPath !== rootWorktreePath | 1330 | |
| 1348 | ) { | 1331 | const findLocalHeadBranch = (cwd: string) => |
| 1349 | yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath); | 1332 | gitCore.listBranches({ cwd }).pipe( |
| 1350 | return { | 1333 | Effect.map((result) => { |
| 1351 | pullRequest, | 1334 | const localBranch = result.branches.find( |
| 1352 | branch: localPullRequestBranch, | 1335 | (branch) => !branch.isRemote && branch.name === localPullRequestBranch, |
| 1353 | worktreePath: existingBranchBeforeFetch.worktreePath, | 1336 | ); |
| 1354 | }; | 1337 | if (localBranch) { |
| 1355 | } | 1338 | return localBranch; |
| 1356 | if (existingBranchBeforeFetchPath === rootWorktreePath) { | 1339 | } |
| 1357 | return yield* gitManagerError( | 1340 | if (localPullRequestBranch === pullRequest.headBranch) { |
| 1358 | "preparePullRequestThread", | 1341 | return null; |
| 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.", | 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( | 1383 | const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); |
| 1364 | input.cwd, | 1384 | const existingBranchAfterFetchPath = existingBranchAfterFetch?.worktreePath |
| 1365 | pullRequestWithRemoteInfo, | 1385 | ? canonicalizeExistingPath(existingBranchAfterFetch.worktreePath) |
| 1366 | localPullRequestBranch, | 1386 | : null; |
| 1367 | ); | 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 | return { | 1412 | return { |
| 1379 | pullRequest, | 1413 | pullRequest, |
| 1380 | branch: localPullRequestBranch, | 1414 | branch: worktree.worktree.branch, |
| 1381 | worktreePath: existingBranchAfterFetch.worktreePath, | 1415 | worktreePath: worktree.worktree.path, |
| 1382 | }; | 1416 | }; |
| 1383 | } | 1417 | }).pipe(Effect.ensuring(invalidateStatusResultCache(input.cwd))); |
| 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 | }; | | |
| 1403 | }); | 1418 | }); |
| 1404 | | 1419 | |
| 1405 | const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( | 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 | return yield* runAction().pipe( | 1599 | return yield* runAction().pipe( |
| | 1600 | Effect.ensuring(invalidateStatusResultCache(input.cwd)), |
| 1585 | Effect.tapError((error) => | 1601 | Effect.tapError((error) => |
| 1586 | Effect.flatMap(Ref.get(currentPhase), (phase) => | 1602 | Effect.flatMap(Ref.get(currentPhase), (phase) => |
| 1587 | progress.emit({ | 1603 | progress.emit({ |