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
629629 const realGitCore = yield* GitCore;
630630 let refreshFetchAttempts = 0;
631631 const core = yield* makeIsolatedGitCore((input) => {
632− if (input.args[0] === "fetch") {
632+ if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {
633633 refreshFetchAttempts += 1;
634634 return Effect.fail(
635635 new GitCommandError({
@@ -675,7 +675,7 @@ it.layer(TestLayer)("git integration", (it) => { · 39 unmodified lines
675675 const realGitCore = yield* GitCore;
676676 let refreshFetchAttempts = 0;
677677 const core = yield* makeIsolatedGitCore((input) => {
678− if (input.args[0] === "fetch") {
678+ if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {
679679 refreshFetchAttempts += 1;
680680 return Effect.succeed({
681681 code: 0,
@@ -696,6 +696,126 @@ it.layer(TestLayer)("git integration", (it) => { · 14 unmodified lines
696696 }),
697697 );
698698
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+
699819 it.effect("throws when branch does not exist", () =>
700820 Effect.gen(function* () {
701821 const tmp = yield* makeTmpDir();
@@ -767,7 +887,7 @@ it.layer(TestLayer)("git integration", (it) => { · 65 unmodified lines
767887 const realGitCore = yield* GitCore;
768888 let fetchArgs: readonly string[] | null = null;
769889 const core = yield* makeIsolatedGitCore((input) => {
770− if (input.args[0] === "fetch") {
890+ if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {
771891 fetchArgs = [...input.args];
772892 return Effect.succeed({
773893 code: 0,
@@ -784,6 +904,8 @@ it.layer(TestLayer)("git integration", (it) => { · 10 unmodified lines
784904 expect(status.branch).toBe("upstream/feature");
785905 expect(status.upstreamRef).toBe(`${remoteName}/${featureBranch}`);
786906 expect(fetchArgs).toEqual([
907+ "--git-dir",
908+ path.join(source, ".git"),
787909 "fetch",
788910 "--quiet",
789911 "--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
4646 const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;
4747 const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
4848 const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
49+const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5);
4950 const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
5051 const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;
5152
@@ -55,7 +56,7 @@ type TraceTailState = { · 3 unmodified lines
5556 };
5657
5758 class StatusUpstreamRefreshCacheKey extends Data.Class<{
58− cwd: string;
59+ gitCommonDir: string;
5960 upstreamRef: string;
6061 remoteName: string;
6162 upstreamBranch: string;
@@ -811,14 +812,16 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 749 unmodified lines
811812 });
812813
813814 const fetchUpstreamRefForStatus = (
814− cwd: string,
815+ gitCommonDir: string,
815816 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },
816817 ): Effect.Effect<void, GitCommandError> => {
817818 const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;
819+ const fetchCwd =
820+ path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir;
818821 return executeGit(
819822 "GitCore.fetchUpstreamRefForStatus",
820− cwd,
821− ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],
823+ fetchCwd,
824+ ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],
822825 {
823826 allowNonZeroExit: true,
824827 timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT),
@@ -826,10 +829,18 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 1 unmodified line
826829 ).pipe(Effect.asVoid);
827830 };
828831
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+
829840 const refreshStatusUpstreamCacheEntry = Effect.fn("refreshStatusUpstreamCacheEntry")(function* (
830841 cacheKey: StatusUpstreamRefreshCacheKey,
831842 ) {
832− yield* fetchUpstreamRefForStatus(cacheKey.cwd, {
843+ yield* fetchUpstreamRefForStatus(cacheKey.gitCommonDir, {
833844 upstreamRef: cacheKey.upstreamRef,
834845 remoteName: cacheKey.remoteName,
835846 upstreamBranch: cacheKey.upstreamBranch,
@@ -840,8 +851,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 4 unmodified lines
840851 const statusUpstreamRefreshCache = yield* Cache.makeWith({
841852 capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY,
842853 lookup: refreshStatusUpstreamCacheEntry,
843− // Keep successful refreshes warm; drop failures immediately so next request can retry.
844− timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_UPSTREAM_REFRESH_INTERVAL : Duration.zero),
854+ // Keep successful refreshes warm and briefly back off failed refreshes to avoid retry storms.
855+ timeToLive: (exit) =>
856+ Exit.isSuccess(exit)
857+ ? STATUS_UPSTREAM_REFRESH_INTERVAL
858+ : STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN,
845859 });
846860
847861 const refreshStatusUpstreamIfStale = Effect.fn("refreshStatusUpstreamIfStale")(function* (
@@ -849,10 +863,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 1 unmodified line
849863 ) {
850864 const upstream = yield* resolveCurrentUpstream(cwd);
851865 if (!upstream) return;
866+ const gitCommonDir = yield* resolveGitCommonDir(cwd);
852867 yield* Cache.get(
853868 statusUpstreamRefreshCache,
854869 new StatusUpstreamRefreshCacheKey({
855− cwd,
870+ gitCommonDir,
856871 upstreamRef: upstream.upstreamRef,
857872 remoteName: upstream.remoteName,
858873 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
673673 }),
674674 );
675675
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+
676707 it.effect(
677708 "status ignores unrelated fork PRs when the current branch tracks the same repository",
678709 () =>
apps/server/src/git/Layers/GitManager.ts+164−148
@@ -1,7 +1,7 @@
11 import { randomUUID } from "node:crypto";
22 import { realpathSync } from "node:fs";
33
4−import { Effect, FileSystem, Layer, Option, Path, Ref } from "effect";
4+import { Cache, Duration, Effect, Exit, FileSystem, Layer, Option, Path, Ref } from "effect";
55 import {
66 GitActionProgressEvent,
77 GitActionProgressPhase,
@@ -32,6 +32,8 @@ const COMMIT_TIMEOUT_MS = 10 * 60_000; · 24 unmodified lines
3232 const MAX_PROGRESS_TEXT_LENGTH = 500;
3333 const SHORT_SHA_LENGTH = 7;
3434 const TOAST_DESCRIPTION_MAX = 72;
35+const STATUS_RESULT_CACHE_TTL = Duration.seconds(1);
36+const STATUS_RESULT_CACHE_CAPACITY = 2_048;
3537 type StripProgressContext<T> = T extends any ? Omit<T, "actionId" | "cwd" | "action"> : never;
3638 type GitActionProgressPayload = StripProgressContext<GitActionProgressEvent>;
3739
@@ -656,6 +658,38 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 618 unmodified lines
656658 const path = yield* Path.Path;
657659
658660 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));
659693
660694 const readConfigValueNullable = (cwd: string, key: string) =>
661695 gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null)));
@@ -1223,28 +1257,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 561 unmodified lines
12231257 });
12241258
12251259 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));
12481261 });
12491262
12501263 const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")(
@@ -1263,143 +1276,145 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 12 unmodified lines
12631276 const preparePullRequestThread: GitManagerShape["preparePullRequestThread"] = Effect.fn(
12641277 "preparePullRequestThread",
12651278 )(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({
12761283 cwd: input.cwd,
12771284 reference: normalizedReference,
1278− force: true,
12791285 });
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);
12951287
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+ }
13091309
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+ });
13401323
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,
13601381 );
1361− }
13621382
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);
13681411
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);
13781412 return {
13791413 pullRequest,
1380− branch: localPullRequestBranch,
1381− worktreePath: existingBranchAfterFetch.worktreePath,
1414+ branch: worktree.worktree.branch,
1415+ worktreePath: worktree.worktree.path,
13821416 };
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)));
14031418 });
14041419
14051420 const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* (
@@ -1582,6 +1597,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 176 unmodified lines
15821597 });
15831598
15841599 return yield* runAction().pipe(
1600+ Effect.ensuring(invalidateStatusResultCache(input.cwd)),
15851601 Effect.tapError((error) =>
15861602 Effect.flatMap(Ref.get(currentPhase), (phase) =>
15871603 progress.emit({