History

Cache git status refreshes across sibling worktrees

- Key status results by canonical cwd and invalidate on git actions - Share upstream refreshes by git common dir with a brief failure cooldown - Update tests for shared refresh and fetch argument handling

Julius Marminge committed 7 months ago

Commit 9653af51 parent 7b669d84 files · +343 −159Browse files

Unified · Split

4 files
  1. apps/server/src/git/Layers/GitCore.test.ts +125 −3
  2. apps/server/src/git/Layers/GitCore.ts +23 −8
  3. apps/server/src/git/Layers/GitManager.test.ts +31 −0
  4. apps/server/src/git/Layers/GitManager.ts +164 −148
apps/server/src/git/Layers/GitCore.test.ts+125−3
@@ -629,7 +629,7 @@ it.layer(TestLayer)("git integration", (it) => { · 628 unmodified lines
629 const realGitCore = yield* GitCore;629 const realGitCore = yield* GitCore;
630 let refreshFetchAttempts = 0;630 let refreshFetchAttempts = 0;
631 const core = yield* makeIsolatedGitCore((input) => {631 const core = yield* makeIsolatedGitCore((input) => {
632 if (input.args[0] === "fetch") {632 if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {
633 refreshFetchAttempts += 1;633 refreshFetchAttempts += 1;
634 return Effect.fail(634 return Effect.fail(
635 new GitCommandError({635 new GitCommandError({
@@ -675,7 +675,7 @@ it.layer(TestLayer)("git integration", (it) => { · 39 unmodified lines
675 const realGitCore = yield* GitCore;675 const realGitCore = yield* GitCore;
676 let refreshFetchAttempts = 0;676 let refreshFetchAttempts = 0;
677 const core = yield* makeIsolatedGitCore((input) => {677 const core = yield* makeIsolatedGitCore((input) => {
678 if (input.args[0] === "fetch") {678 if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {
679 refreshFetchAttempts += 1;679 refreshFetchAttempts += 1;
680 return Effect.succeed({680 return Effect.succeed({
681 code: 0,681 code: 0,
@@ -696,6 +696,126 @@ it.layer(TestLayer)("git integration", (it) => { · 14 unmodified lines
696 }),696 }),
697 );697 );
698 698
699 it.effect("shares upstream refreshes across worktrees that use the same git common dir", () =>
700 Effect.gen(function* () {
701 const ok = (stdout = "") =>
702 Effect.succeed({
703 code: 0,
704 stdout,
705 stderr: "",
706 stdoutTruncated: false,
707 stderrTruncated: false,
708 });
709
710 let fetchCount = 0;
711 const core = yield* makeIsolatedGitCore((input) => {
712 if (
713 input.args[0] === "rev-parse" &&
714 input.args[1] === "--abbrev-ref" &&
715 input.args[2] === "--symbolic-full-name" &&
716 input.args[3] === "@{upstream}"
717 ) {
718 return ok("origin/main\n");
719 }
720 if (input.args[0] === "remote") {
721 return ok("origin\n");
722 }
723 if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") {
724 return ok("/repo/.git\n");
725 }
726 if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {
727 fetchCount += 1;
728 expect(input.cwd).toBe("/repo");
729 return ok();
730 }
731 if (input.operation === "GitCore.statusDetails.status") {
732 return ok("# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n");
733 }
734 if (
735 input.operation === "GitCore.statusDetails.unstagedNumstat" ||
736 input.operation === "GitCore.statusDetails.stagedNumstat"
737 ) {
738 return ok();
739 }
740 return Effect.fail(
741 new GitCommandError({
742 operation: input.operation,
743 command: `git ${input.args.join(" ")}`,
744 cwd: input.cwd,
745 detail: "Unexpected git command in shared refresh cache test.",
746 }),
747 );
748 });
749
750 yield* core.statusDetails("/repo/worktrees/main");
751 yield* core.statusDetails("/repo/worktrees/pr-123");
752 expect(fetchCount).toBe(1);
753 }),
754 );
755
756 it.effect("briefly backs off failed upstream refreshes across sibling worktrees", () =>
757 Effect.gen(function* () {
758 const ok = (stdout = "") =>
759 Effect.succeed({
760 code: 0,
761 stdout,
762 stderr: "",
763 stdoutTruncated: false,
764 stderrTruncated: false,
765 });
766
767 let fetchCount = 0;
768 const core = yield* makeIsolatedGitCore((input) => {
769 if (
770 input.args[0] === "rev-parse" &&
771 input.args[1] === "--abbrev-ref" &&
772 input.args[2] === "--symbolic-full-name" &&
773 input.args[3] === "@{upstream}"
774 ) {
775 return ok("origin/main\n");
776 }
777 if (input.args[0] === "remote") {
778 return ok("origin\n");
779 }
780 if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") {
781 return ok("/repo/.git\n");
782 }
783 if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {
784 fetchCount += 1;
785 return Effect.fail(
786 new GitCommandError({
787 operation: input.operation,
788 command: `git ${input.args.join(" ")}`,
789 cwd: input.cwd,
790 detail: "simulated fetch timeout",
791 }),
792 );
793 }
794 if (input.operation === "GitCore.statusDetails.status") {
795 return ok("# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n");
796 }
797 if (
798 input.operation === "GitCore.statusDetails.unstagedNumstat" ||
799 input.operation === "GitCore.statusDetails.stagedNumstat"
800 ) {
801 return ok();
802 }
803 return Effect.fail(
804 new GitCommandError({
805 operation: input.operation,
806 command: `git ${input.args.join(" ")}`,
807 cwd: input.cwd,
808 detail: "Unexpected git command in refresh failure cooldown test.",
809 }),
810 );
811 });
812
813 yield* core.statusDetails("/repo/worktrees/main");
814 yield* core.statusDetails("/repo/worktrees/pr-123");
815 expect(fetchCount).toBe(1);
816 }),
817 );
818
699 it.effect("throws when branch does not exist", () =>819 it.effect("throws when branch does not exist", () =>
700 Effect.gen(function* () {820 Effect.gen(function* () {
701 const tmp = yield* makeTmpDir();821 const tmp = yield* makeTmpDir();
@@ -767,7 +887,7 @@ it.layer(TestLayer)("git integration", (it) => { · 65 unmodified lines
767 const realGitCore = yield* GitCore;887 const realGitCore = yield* GitCore;
768 let fetchArgs: readonly string[] | null = null;888 let fetchArgs: readonly string[] | null = null;
769 const core = yield* makeIsolatedGitCore((input) => {889 const core = yield* makeIsolatedGitCore((input) => {
770 if (input.args[0] === "fetch") {890 if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {
771 fetchArgs = [...input.args];891 fetchArgs = [...input.args];
772 return Effect.succeed({892 return Effect.succeed({
773 code: 0,893 code: 0,
@@ -784,6 +904,8 @@ it.layer(TestLayer)("git integration", (it) => { · 10 unmodified lines
784 expect(status.branch).toBe("upstream/feature");904 expect(status.branch).toBe("upstream/feature");
785 expect(status.upstreamRef).toBe(`${remoteName}/${featureBranch}`);905 expect(status.upstreamRef).toBe(`${remoteName}/${featureBranch}`);
786 expect(fetchArgs).toEqual([906 expect(fetchArgs).toEqual([
907 "--git-dir",
908 path.join(source, ".git"),
787 "fetch",909 "fetch",
788 "--quiet",910 "--quiet",
789 "--no-tags",911 "--no-tags",
apps/server/src/git/Layers/GitCore.ts+23−8
@@ -46,6 +46,7 @@ const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; · 45 unmodified lines
46const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;46const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;
47const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);47const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
48const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);48const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
49const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5);
49const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;50const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
50const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;51const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;
51 52
@@ -55,7 +56,7 @@ type TraceTailState = { · 3 unmodified lines
55};56};
56 57
57class StatusUpstreamRefreshCacheKey extends Data.Class<{58class StatusUpstreamRefreshCacheKey extends Data.Class<{
58 cwd: string;59 gitCommonDir: string;
59 upstreamRef: string;60 upstreamRef: string;
60 remoteName: string;61 remoteName: string;
61 upstreamBranch: string;62 upstreamBranch: string;
@@ -811,14 +812,16 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 749 unmodified lines
811 });812 });
812 813
813 const fetchUpstreamRefForStatus = (814 const fetchUpstreamRefForStatus = (
814 cwd: string,815 gitCommonDir: string,
815 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },816 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },
816 ): Effect.Effect<void, GitCommandError> => {817 ): Effect.Effect<void, GitCommandError> => {
817 const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;818 const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;
819 const fetchCwd =
820 path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir;
818 return executeGit(821 return executeGit(
819 "GitCore.fetchUpstreamRefForStatus",822 "GitCore.fetchUpstreamRefForStatus",
820 cwd,823 fetchCwd,
821 ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],824 ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],
822 {825 {
823 allowNonZeroExit: true,826 allowNonZeroExit: true,
824 timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT),827 timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT),
@@ -826,10 +829,18 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 1 unmodified line
826 ).pipe(Effect.asVoid);829 ).pipe(Effect.asVoid);
827 };830 };
828 831
832 const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) {
833 const gitCommonDir = yield* runGitStdout("GitCore.resolveGitCommonDir", cwd, [
834 "rev-parse",
835 "--git-common-dir",
836 ]).pipe(Effect.map((stdout) => stdout.trim()));
837 return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir);
838 });
839
829 const refreshStatusUpstreamCacheEntry = Effect.fn("refreshStatusUpstreamCacheEntry")(function* (840 const refreshStatusUpstreamCacheEntry = Effect.fn("refreshStatusUpstreamCacheEntry")(function* (
830 cacheKey: StatusUpstreamRefreshCacheKey,841 cacheKey: StatusUpstreamRefreshCacheKey,
831 ) {842 ) {
832 yield* fetchUpstreamRefForStatus(cacheKey.cwd, {843 yield* fetchUpstreamRefForStatus(cacheKey.gitCommonDir, {
833 upstreamRef: cacheKey.upstreamRef,844 upstreamRef: cacheKey.upstreamRef,
834 remoteName: cacheKey.remoteName,845 remoteName: cacheKey.remoteName,
835 upstreamBranch: cacheKey.upstreamBranch,846 upstreamBranch: cacheKey.upstreamBranch,
@@ -840,8 +851,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 4 unmodified lines
840 const statusUpstreamRefreshCache = yield* Cache.makeWith({851 const statusUpstreamRefreshCache = yield* Cache.makeWith({
841 capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY,852 capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY,
842 lookup: refreshStatusUpstreamCacheEntry,853 lookup: refreshStatusUpstreamCacheEntry,
843 // Keep successful refreshes warm; drop failures immediately so next request can retry.854 // Keep successful refreshes warm and briefly back off failed refreshes to avoid retry storms.
844 timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_UPSTREAM_REFRESH_INTERVAL : Duration.zero),855 timeToLive: (exit) =>
856 Exit.isSuccess(exit)
857 ? STATUS_UPSTREAM_REFRESH_INTERVAL
858 : STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN,
845 });859 });
846 860
847 const refreshStatusUpstreamIfStale = Effect.fn("refreshStatusUpstreamIfStale")(function* (861 const refreshStatusUpstreamIfStale = Effect.fn("refreshStatusUpstreamIfStale")(function* (
@@ -849,10 +863,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 1 unmodified line
849 ) {863 ) {
850 const upstream = yield* resolveCurrentUpstream(cwd);864 const upstream = yield* resolveCurrentUpstream(cwd);
851 if (!upstream) return;865 if (!upstream) return;
866 const gitCommonDir = yield* resolveGitCommonDir(cwd);
852 yield* Cache.get(867 yield* Cache.get(
853 statusUpstreamRefreshCache,868 statusUpstreamRefreshCache,
854 new StatusUpstreamRefreshCacheKey({869 new StatusUpstreamRefreshCacheKey({
855 cwd,870 gitCommonDir,
856 upstreamRef: upstream.upstreamRef,871 upstreamRef: upstream.upstreamRef,
857 remoteName: upstream.remoteName,872 remoteName: upstream.remoteName,
858 upstreamBranch: upstream.upstreamBranch,873 upstreamBranch: upstream.upstreamBranch,
apps/server/src/git/Layers/GitManager.test.ts+31−0
@@ -673,6 +673,37 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 672 unmodified lines
673 }),673 }),
674 );674 );
675 675
676 it.effect("status briefly caches repeated lookups for the same cwd", () =>
677 Effect.gen(function* () {
678 const repoDir = yield* makeTempDir("t3code-git-manager-");
679 yield* initRepo(repoDir);
680 yield* runGit(repoDir, ["checkout", "-b", "feature/status-cache"]);
681 const remoteDir = yield* createBareRemote();
682 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
683 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-cache"]);
684
685 const existingPr = {
686 number: 113,
687 title: "Cached PR",
688 url: "https://github.com/pingdotgg/codething-mvp/pull/113",
689 baseRefName: "main",
690 headRefName: "feature/status-cache",
691 };
692 const { manager, ghCalls } = yield* makeManager({
693 ghScenario: {
694 prListSequence: [JSON.stringify([existingPr]), JSON.stringify([existingPr])],
695 },
696 });
697
698 const first = yield* manager.status({ cwd: repoDir });
699 const second = yield* manager.status({ cwd: repoDir });
700
701 expect(first.pr?.number).toBe(113);
702 expect(second.pr?.number).toBe(113);
703 expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1);
704 }),
705 );
706
676 it.effect(707 it.effect(
677 "status ignores unrelated fork PRs when the current branch tracks the same repository",708 "status ignores unrelated fork PRs when the current branch tracks the same repository",
678 () =>709 () =>
apps/server/src/git/Layers/GitManager.ts+164−148
@@ -1,7 +1,7 @@
1import { randomUUID } from "node:crypto";1import { randomUUID } from "node:crypto";
2import { realpathSync } from "node:fs";2import { realpathSync } from "node:fs";
3 3
4import { Effect, FileSystem, Layer, Option, Path, Ref } from "effect";4import { Cache, Duration, Effect, Exit, FileSystem, Layer, Option, Path, Ref } from "effect";
5import {5import {
6 GitActionProgressEvent,6 GitActionProgressEvent,
7 GitActionProgressPhase,7 GitActionProgressPhase,
@@ -32,6 +32,8 @@ const COMMIT_TIMEOUT_MS = 10 * 60_000; · 24 unmodified lines
32const MAX_PROGRESS_TEXT_LENGTH = 500;32const MAX_PROGRESS_TEXT_LENGTH = 500;
33const SHORT_SHA_LENGTH = 7;33const SHORT_SHA_LENGTH = 7;
34const TOAST_DESCRIPTION_MAX = 72;34const TOAST_DESCRIPTION_MAX = 72;
35const STATUS_RESULT_CACHE_TTL = Duration.seconds(1);
36const STATUS_RESULT_CACHE_CAPACITY = 2_048;
35type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "action"> : never;37type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "action"> : never;
36type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>;38type 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?.worktreePath1325 ...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 !== rootWorktreePath1330
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({