diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index e6c7f8be8..920d36fb3 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -2939,6 +2939,35 @@ describe("laneService delete teardown + cancellation + streaming", () => { expect(wtStep?.status).toBe("completed"); }); + it("removes residual worktree files before deleting the lane row", async () => { + const events: any[] = []; + const fake = makeFakeServices(); + const { service, db, repoRoot } = await setupWithLane({ teardown: fake, events }); + const childPath = path.join(repoRoot, "child"); + fs.writeFileSync(path.join(childPath, "residual.log"), "left behind by git\n", "utf8"); + + vi.mocked(runGit).mockImplementation(async (args: string[]) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; + if (args[0] === "status") return { exitCode: 0, stdout: "", stderr: "" } as any; + if (args[0] === "show-ref") return { exitCode: 1, stdout: "", stderr: "" } as any; + if (args[0] === "worktree" && args[1] === "remove") { + fake.calls.push("git_worktree_remove"); + return { exitCode: 0, stdout: "", stderr: "" } as any; + } + return { exitCode: 0, stdout: "", stderr: "" } as any; + }); + vi.mocked(runGitOrThrow).mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" } as any); + + await service.delete({ laneId: "lane-child", deleteBranch: false }); + + expect(fs.existsSync(childPath)).toBe(false); + expect(db.get<{ id: string }>("select id from lanes where id = ?", ["lane-child"])).toBeNull(); + expect(vi.mocked(runGitOrThrow).mock.calls.some(([args]) => args[0] === "worktree" && args[1] === "prune")).toBe(true); + const last = events[events.length - 1]; + expect(last.progress.steps.find((s: any) => s.name === "git_worktree_remove")?.detail).toContain("removed residual files"); + }); + it("keeps recent delete progress queryable for remounted renderers", async () => { const events: any[] = []; const fake = makeFakeServices(); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 61f33cf9a..e0ab966f5 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -3750,29 +3750,38 @@ export function createLaneService({ const removeArgs = ["worktree", "remove"]; if (force) removeArgs.push("--force"); removeArgs.push(row.worktree_path); + const removeResidualDirectory = async (detail: string, failurePrefix?: string) => { + try { + await fs.promises.rm(row.worktree_path, { recursive: true, force: true }); + } catch (rmError) { + throw new Error( + `${failurePrefix ? `${failurePrefix}; ` : ""}manual cleanup failed: ${ + rmError instanceof Error ? rmError.message : String(rmError) + }` + ); + } + await runGitOrThrow(["worktree", "prune"], { cwd: projectRoot, timeoutMs: 30_000 }); + return { detail }; + }; // 60s — large worktrees (e.g. with node_modules) can take longer than 15s // to walk; a timeout here mid-remove leaves the worktree in a half-deleted // state that blocks future deletes. const removeRes = await runGit(removeArgs, { cwd: projectRoot, timeoutMs: 60_000 }); if (removeRes.exitCode === 0) { + if (fs.existsSync(row.worktree_path)) { + return removeResidualDirectory(`${row.worktree_path} (removed residual files)`); + } return { detail: row.worktree_path }; } // Recovery path: a previous failed delete (or this one's first attempt) // can leave the worktree dir present without its `.git` pointer file, or // the dir gone with stale metadata still registered. Either way: rm the // dir if any, then prune git's metadata. - try { - await fs.promises.rm(row.worktree_path, { recursive: true, force: true }); - } catch (rmError) { - const original = (removeRes.stderr || removeRes.stdout || "").trim(); - throw new Error( - `git worktree remove failed (${original}); manual cleanup also failed: ${ - rmError instanceof Error ? rmError.message : String(rmError) - }` - ); - } - await runGitOrThrow(["worktree", "prune"], { cwd: projectRoot, timeoutMs: 30_000 }); - return { detail: `${row.worktree_path} (recovered from stale state)` }; + const original = (removeRes.stderr || removeRes.stdout || "").trim(); + return removeResidualDirectory( + `${row.worktree_path} (recovered from stale state)`, + `git worktree remove failed (${original})` + ); }); } diff --git a/apps/desktop/src/main/services/prs/prService.test.ts b/apps/desktop/src/main/services/prs/prService.test.ts index d66e91c2a..28fe8fe3c 100644 --- a/apps/desktop/src/main/services/prs/prService.test.ts +++ b/apps/desktop/src/main/services/prs/prService.test.ts @@ -692,6 +692,146 @@ describe("prService.getGithubSnapshot", () => { ])); }); + it("fetches a targeted same-repo lane branch PR when the repo snapshot window misses it", async () => { + const githubService = makeGithubService({ + getStatus: vi.fn(async () => ({ + tokenStored: true, + repo: REPO, + userLogin: "octocat", + })), + apiRequest: vi.fn(async (args: { path: string; query?: Record }) => { + if (args.path !== `/repos/${REPO.owner}/${REPO.name}/pulls`) { + throw new Error(`Unexpected GitHub API path: ${args.path}`); + } + if (args.query?.head === `${REPO.owner}:feature/missed`) { + return { + data: [ + makeGitHubPull({ + number: 222, + title: "Missed branch PR", + state: "closed", + merged_at: null, + head: { + ref: "feature/missed", + user: { login: REPO.owner }, + repo: { owner: { login: REPO.owner }, name: REPO.name }, + }, + }), + ], + }; + } + return { + data: [ + makeGitHubPull({ + number: 111, + title: "Recent unrelated PR", + head: { + ref: "feature/recent", + user: { login: REPO.owner }, + repo: { owner: { login: REPO.owner }, name: REPO.name }, + }, + }), + ], + }; + }), + }); + const lane = makeFakeLane({ branchRef: "refs/heads/feature/missed" }); + const db = makeMockDb(); + const { service } = buildService({ db, githubService, laneService: makeLaneService([lane]) }); + + const snapshot = await service.getGithubSnapshot({ force: true }); + + expect(githubService.apiRequest).toHaveBeenCalledWith(expect.objectContaining({ + query: expect.objectContaining({ head: `${REPO.owner}:feature/missed` }), + })); + expect(snapshot.repoPullRequests).toEqual(expect.arrayContaining([ + expect.objectContaining({ + githubPrNumber: 222, + title: "Missed branch PR", + state: "closed", + headRepoOwner: REPO.owner, + headRepoName: REPO.name, + }), + ])); + expect(db.run).toHaveBeenCalledWith( + expect.stringContaining("insert into pull_requests("), + expect.arrayContaining([LANE_ID, REPO.owner, REPO.name, 222, "Missed branch PR", "closed", "main", "feature/missed"]), + ); + }); + + it("continues targeted lane branch PR lookups after one branch lookup fails", async () => { + const githubService = makeGithubService({ + getStatus: vi.fn(async () => ({ + tokenStored: true, + repo: REPO, + userLogin: "octocat", + })), + apiRequest: vi.fn(async (args: { path: string; query?: Record }) => { + if (args.path !== `/repos/${REPO.owner}/${REPO.name}/pulls`) { + throw new Error(`Unexpected GitHub API path: ${args.path}`); + } + if (args.query?.head === `${REPO.owner}:feature/flaky`) { + throw new Error("temporary GitHub failure"); + } + if (args.query?.head === `${REPO.owner}:feature/missed`) { + return { + data: [ + makeGitHubPull({ + number: 222, + title: "Missed branch PR", + state: "closed", + merged_at: null, + head: { + ref: "feature/missed", + user: { login: REPO.owner }, + repo: { owner: { login: REPO.owner }, name: REPO.name }, + }, + }), + ], + }; + } + return { + data: [ + makeGitHubPull({ + number: 111, + title: "Recent unrelated PR", + head: { + ref: "feature/recent", + user: { login: REPO.owner }, + repo: { owner: { login: REPO.owner }, name: REPO.name }, + }, + }), + ], + }; + }), + }); + const db = makeMockDb(); + const laneService = makeLaneService([ + makeFakeLane({ id: "lane-flaky", branchRef: "refs/heads/feature/flaky" }), + makeFakeLane({ branchRef: "refs/heads/feature/missed" }), + ]); + const { service } = buildService({ db, githubService, laneService }); + + const snapshot = await service.getGithubSnapshot({ force: true }); + + expect(githubService.apiRequest).toHaveBeenCalledWith(expect.objectContaining({ + query: expect.objectContaining({ head: `${REPO.owner}:feature/flaky` }), + })); + expect(githubService.apiRequest).toHaveBeenCalledWith(expect.objectContaining({ + query: expect.objectContaining({ head: `${REPO.owner}:feature/missed` }), + })); + expect(snapshot.repoPullRequests).toEqual(expect.arrayContaining([ + expect.objectContaining({ + githubPrNumber: 222, + title: "Missed branch PR", + }), + ])); + expect(db.run).toHaveBeenCalledWith( + expect.stringContaining("insert into pull_requests("), + expect.arrayContaining([LANE_ID, REPO.owner, REPO.name, 222, "Missed branch PR", "closed", "main", "feature/missed"]), + ); + }); + it("updates an existing repo PR row during lane PR backfill instead of duplicating it", async () => { const githubService = makeGithubService({ getStatus: vi.fn(async () => ({ diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 65837cbdd..3444d794a 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -1546,6 +1546,19 @@ export function createPrService({ } }; + const rawPullHeadBranch = (rawPr: any): string => normalizeBranchName(asString(rawPr?.head?.ref)); + const rawPullHeadOwner = (rawPr: any): string => asString(rawPr?.head?.repo?.owner?.login) + || asString(rawPr?.head?.user?.login) + || asString(rawPr?.head?.repo?.owner); + const rawPullHeadRepoName = (rawPr: any): string => asString(rawPr?.head?.repo?.name); + const rawPullHasSameRepoHead = (rawPr: any, repo: GitHubRepoRef): boolean => { + const owner = rawPullHeadOwner(rawPr); + const name = rawPullHeadRepoName(rawPr); + if (!owner || owner.toLowerCase() !== repo.owner.toLowerCase()) return false; + if (name && name.toLowerCase() !== repo.name.toLowerCase()) return false; + return true; + }; + const backfillLanePrRowsFromGithubPulls = (rawPulls: any[], repo: GitHubRepoRef, lanes: LaneSummary[]): number => { const activeLaneByBranch = new Map(); for (const lane of lanes) { @@ -1558,13 +1571,11 @@ export function createPrService({ const backfilledIds: string[] = []; for (const rawPr of rawPulls) { - const headBranch = normalizeBranchName(asString(rawPr?.head?.ref)); + const headBranch = rawPullHeadBranch(rawPr); const lane = headBranch ? activeLaneByBranch.get(headBranch) ?? null : null; if (!lane) continue; - const headOwner = asString(rawPr?.head?.repo?.owner?.login) - || asString(rawPr?.head?.user?.login) - || asString(rawPr?.head?.repo?.owner); + const headOwner = rawPullHeadOwner(rawPr); if (!headOwner || headOwner.toLowerCase() !== repo.owner.toLowerCase()) continue; const prNumber = asNumber(rawPr?.number); @@ -1609,6 +1620,68 @@ export function createPrService({ return backfilledIds.length; }; + const fetchMissingSameRepoLanePulls = async ( + rawPulls: any[], + repo: GitHubRepoRef, + lanes: LaneSummary[], + ): Promise => { + const branchHasSameRepoPr = new Set(); + const seenRepoPrNumbers = new Set(); + for (const rawPr of rawPulls) { + const prNumber = asNumber(rawPr?.number); + if (prNumber) seenRepoPrNumbers.add(prNumber); + const branch = rawPullHeadBranch(rawPr); + if (branch && rawPullHasSameRepoHead(rawPr, repo)) branchHasSameRepoPr.add(branch); + } + + const candidateBranches: string[] = []; + const seenBranches = new Set(); + for (const lane of lanes) { + if (lane.archivedAt || lane.laneType === "primary") continue; + const branch = normalizeBranchName(branchNameFromRef(lane.branchRef)); + if (!branch || seenBranches.has(branch) || branchHasSameRepoPr.has(branch)) continue; + seenBranches.add(branch); + candidateBranches.push(branch); + } + + const MAX_TARGETED_LANE_PR_BRANCH_LOOKUPS = 12; + const targetedBranches = candidateBranches.slice(0, MAX_TARGETED_LANE_PR_BRANCH_LOOKUPS); + if (targetedBranches.length === 0) return rawPulls; + + const extras: any[] = []; + for (const branch of targetedBranches) { + let branchPulls: any[]; + try { + branchPulls = await fetchAllPages({ + path: `/repos/${repo.owner}/${repo.name}/pulls`, + query: { + state: "all", + head: `${repo.owner}:${branch}`, + sort: "updated", + direction: "desc", + }, + maxPages: 1, + }); + } catch (error) { + logger.warn("prs.github_snapshot_targeted_lane_pull_lookup_failed", { + owner: repo.owner, + repo: repo.name, + branch, + error: getErrorMessage(error), + }); + continue; + } + for (const rawPr of branchPulls) { + const prNumber = asNumber(rawPr?.number); + if (!prNumber || seenRepoPrNumbers.has(prNumber)) continue; + seenRepoPrNumbers.add(prNumber); + extras.push(rawPr); + } + } + + return extras.length ? [...rawPulls, ...extras] : rawPulls; + }; + const pluralizeCommit = (count: number): string => count === 1 ? "commit" : "commits"; const parseLeftRightCounts = (stdout: string): { left: number; right: number } | null => { @@ -5070,6 +5143,8 @@ export function createPrService({ isDraft: Boolean(rawPr?.draft), baseBranch: asString(rawPr?.base?.ref) || null, headBranch: asString(rawPr?.head?.ref) || null, + headRepoOwner: rawPullHeadOwner(rawPr) || null, + headRepoName: rawPullHeadRepoName(rawPr) || null, author: asString(rawPr?.user?.login) || null, createdAt: asString(rawPr?.created_at) || nowIso(), updatedAt: asString(rawPr?.updated_at) || asString(rawPr?.created_at) || nowIso(), @@ -5090,10 +5165,11 @@ export function createPrService({ }; }; - const repoPullRequestsRaw = await fetchAllPages({ + let repoPullRequestsRaw = await fetchAllPages({ path: `/repos/${repo.owner}/${repo.name}/pulls`, query: { state: "all", sort: "updated", direction: "desc" }, }); + repoPullRequestsRaw = await fetchMissingSameRepoLanePulls(repoPullRequestsRaw, repo, lanes); if (backfillLanePrRowsFromGithubPulls(repoPullRequestsRaw, repo, lanes) > 0) { pullRequestRows = listRows(); linkedPrByRepoKey = new Map( diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts b/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts index 534994b45..8a5486905 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts @@ -430,14 +430,65 @@ describe("selectLaneTabPrTag", () => { }); it("prefers an ADE-mapped PR over an unlinked GitHub branch match", () => { - const mappedPr = makePr({ id: "mapped-pr", state: "closed" }); + const mappedPr = makePr({ id: "mapped-pr", state: "open" }); const githubPr = makeGitHubPr({ id: "github-pr", state: "open" }); + expect(selectLaneTabPrTag(makeLane(), [mappedPr], [githubPr])).toMatchObject({ + source: "ade", + id: "mapped-pr", + linkedPrId: "mapped-pr", + state: "open", + }); + }); + + it("prefers an external GitHub PR over a stale terminal ADE row for the same branch", () => { + const mappedPr = makePr({ + id: "mapped-pr", + state: "closed", + githubPrNumber: 123, + updatedAt: "2026-05-01T00:00:00.000Z", + }); + const githubPr = makeGitHubPr({ + id: "github-pr", + state: "open", + githubPrNumber: 224, + linkedPrId: null, + linkedLaneId: null, + title: "Fresh external PR", + updatedAt: "2026-05-02T00:00:00.000Z", + }); + + expect(selectLaneTabPrTag(makeLane(), [mappedPr], [githubPr])).toMatchObject({ + source: "github", + id: "github-pr", + linkedPrId: null, + state: "open", + title: "Fresh external PR", + }); + }); + + it("keeps a terminal ADE row when the branch-matched GitHub PR is also terminal and unrelated", () => { + const mappedPr = makePr({ + id: "mapped-pr", + state: "closed", + githubPrNumber: 123, + githubUrl: "https://github.com/arul28/ADE/pull/123", + }); + const githubPr = makeGitHubPr({ + id: "github-pr", + state: "closed", + githubPrNumber: 224, + githubUrl: "https://github.com/arul28/ADE/pull/224", + linkedPrId: null, + linkedLaneId: null, + }); + expect(selectLaneTabPrTag(makeLane(), [mappedPr], [githubPr])).toMatchObject({ source: "ade", id: "mapped-pr", linkedPrId: "mapped-pr", state: "closed", + githubPrNumber: 123, }); }); @@ -503,6 +554,19 @@ describe("selectLaneTabPrTag", () => { }); }); + it("does not match fork PRs to same-named local repo branches", () => { + expect( + githubPrMatchesCurrentBranch( + makeLane(), + makeGitHubPr({ + headBranch: "ade/pr-state", + headRepoOwner: "contributor", + headRepoName: "ADE", + }), + ), + ).toBe(false); + }); + it("does not match repo GitHub PRs for primary while primary is on its base branch", () => { expect( githubPrMatchesCurrentBranch( diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 0fdbac1c9..1d81c7e66 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -432,6 +432,13 @@ export function LanesPage() { [laneSnapshots], ); const sortedLanes = useMemo(() => sortLanesForTabs(lanes), [lanes]); + const lanePrBranchSignature = useMemo( + () => sortedLanes + .map((lane) => `${lane.id}:${lane.laneType}:${lane.branchRef ?? ""}:${lane.baseRef ?? ""}`) + .sort() + .join("\0"), + [sortedLanes], + ); const lanesById = useMemo(() => new Map(sortedLanes.map((lane) => [lane.id, lane])), [sortedLanes]); const deletingLaneIds = useMemo(() => { const ids = new Set(); @@ -938,14 +945,14 @@ export function LanesPage() { } const timer = window.setTimeout(() => { void refreshLanePrTags(); - void refreshLaneGithubPrTags(); + void refreshLaneGithubPrTags({ force: true }); }, 160); return () => { lanePrTagsRequestRef.current += 1; laneGithubPrTagsRequestRef.current += 1; window.clearTimeout(timer); }; - }, [refreshLanePrTags, refreshLaneGithubPrTags, project?.rootPath]); + }, [refreshLanePrTags, refreshLaneGithubPrTags, project?.rootPath, lanePrBranchSignature]); useEffect(() => { return window.ade.prs.onEvent((event) => { diff --git a/apps/desktop/src/renderer/components/lanes/lanePageModel.ts b/apps/desktop/src/renderer/components/lanes/lanePageModel.ts index a55f66eb1..850fc9f70 100644 --- a/apps/desktop/src/renderer/components/lanes/lanePageModel.ts +++ b/apps/desktop/src/renderer/components/lanes/lanePageModel.ts @@ -202,11 +202,15 @@ export function selectLanePrTag( export function githubPrMatchesCurrentBranch( lane: Pick, - pr: Pick, + pr: Pick, ): boolean { const laneBranch = normalizeLanePrBranch(lane.branchRef); const prHeadBranch = normalizeLanePrBranch(pr.headBranch); if (!laneBranch || !prHeadBranch || laneBranch !== prHeadBranch) return false; + const headRepoOwner = pr.headRepoOwner?.trim(); + const headRepoName = pr.headRepoName?.trim(); + if (headRepoOwner && pr.repoOwner && headRepoOwner.toLowerCase() !== pr.repoOwner.toLowerCase()) return false; + if (headRepoName && pr.repoName && headRepoName.toLowerCase() !== pr.repoName.toLowerCase()) return false; if (lane.laneType === "primary") { const baseBranch = normalizeLanePrBranch(lane.baseRef); if (laneBranch && baseBranch && laneBranch === baseBranch) return false; @@ -274,18 +278,30 @@ function selectTerminalGithubUpdateForPr( .sort(comparePrTags)[0] ?? null; } +function shouldPreferGithubPrTag( + pr: PrSummary, + githubPr: GitHubPrListItem, +): boolean { + const githubState = githubPr.isDraft ? "draft" : githubPr.state; + if (githubPrMatchesAdePr(pr, githubPr) && githubState !== pr.state) return true; + return isTerminalPrState(pr.state) && !isTerminalPrState(githubState); +} + export function selectLaneTabPrTag( lane: Pick, prs: PrSummary[], githubPrs: GitHubPrListItem[], ): LaneTabPrTag | null { const mappedPr = selectLanePrTag(lane, prs); + const githubPr = selectGithubLanePrTag(lane, githubPrs); if (mappedPr) { const terminalGithubPr = selectTerminalGithubUpdateForPr(mappedPr, githubPrs); if (terminalGithubPr) return toLaneTabPrTagFromGithubItem(terminalGithubPr, lane.id); + if (githubPr && shouldPreferGithubPrTag(mappedPr, githubPr)) { + return toLaneTabPrTagFromGithubItem(githubPr, lane.id); + } return toLaneTabPrTagFromPrSummary(mappedPr); } - const githubPr = selectGithubLanePrTag(lane, githubPrs); return githubPr ? toLaneTabPrTagFromGithubItem(githubPr, lane.id) : null; } diff --git a/apps/desktop/src/shared/types/prs.ts b/apps/desktop/src/shared/types/prs.ts index d4a8949cb..24620b1a7 100644 --- a/apps/desktop/src/shared/types/prs.ts +++ b/apps/desktop/src/shared/types/prs.ts @@ -129,6 +129,8 @@ export type GitHubPrListItem = { isDraft: boolean; baseBranch: string | null; headBranch: string | null; + headRepoOwner?: string | null; + headRepoName?: string | null; author: string | null; createdAt: string; updatedAt: string; diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index 977c057e1..52352d65d 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -59,7 +59,7 @@ Desktop fallback services (`apps/desktop/src/main/services/lanes/`): | File | Responsibility | |------|---------------| -| `laneService.ts` | Lane CRUD, worktree creation/removal, status computation, stack chain traversal, rebase runs, reparent, mission role tagging, startup repair routines, and the multi-step lane teardown pipeline (`getDeleteRisk`, `delete`, `cancelDelete`) that streams `LaneDeleteProgress` events as it stops processes/PTYs/watchers, cancels auto-rebase, runs `git worktree remove` / `git branch -D` / optional `git push --delete origin`, and cleans the pack directory + DB rows. Deletes now run to completion once started, so `cancelDelete` reports that no active delete can be cancelled. `reparent` accepts an optional `stackBaseBranchRef` to pick a specific branch to stack onto (resolved in the project repo with `origin/` preferred); when both the parent link and the resolved base branch are unchanged the call short-circuits without touching git. | +| `laneService.ts` | Lane CRUD, worktree creation/removal, status computation, stack chain traversal, rebase runs, reparent, mission role tagging, startup repair routines, and the multi-step lane teardown pipeline (`getDeleteRisk`, `delete`, `cancelDelete`) that streams `LaneDeleteProgress` events as it stops processes/PTYs/watchers, cancels auto-rebase, runs `git worktree remove` / `git branch -D` / optional `git push --delete origin`, verifies residual worktree files are gone before DB cleanup, and cleans the pack directory + DB rows. Deletes now run to completion once started, so `cancelDelete` reports that no active delete can be cancelled. `reparent` accepts an optional `stackBaseBranchRef` to pick a specific branch to stack onto (resolved in the project repo with `origin/` preferred); when both the parent link and the resolved base branch are unchanged the call short-circuits without touching git. | | `autoRebaseService.ts` | Auto-rebase worker for stacked lanes, attention state, head-change handlers. Consults `resolvePrRebaseMode` to determine whether a lane with a linked PR should auto-rebase (`pr_target` strategy) or only surface manual attention (`lane_base` strategy). `listStatuses({ includeAll: true })` returns stored statuses without recomputing lane git status for PR workflow views. | | `rebaseSuggestionService.ts` | Emits rebase suggestions when a parent lane advances, dismiss/defer lifecycle. Each suggestion may include up to 20 `RebaseTargetCommit` entries showing the behind commits the rebase would pull in. | | `laneEnvironmentService.ts` | Environment init pipeline: env files, docker services, dependencies, mount points, copy paths (Phase 5 W1) | @@ -75,8 +75,8 @@ Renderer components: | File | Responsibility | |------|---------------| -| `renderer/components/lanes/LanesPage.tsx` | 3-pane cockpit, tab management, dialog coordination. Each lane row in the lane list optionally renders a state-aware PR tag (`PR #N` / `DRAFT #N` / `MERGED #N` / `CLOSED #N`) when the lane's current branch matches an existing PR. The pure selectors in `lanePageModel.ts` prefer ADE-linked PR rows, then fall back to `prs.getGitHubSnapshot().repoPullRequests` so merged or externally created PRs stay visible by branch match; linked PRs route to the PR workspace, while unlinked GitHub-only matches open externally. If GitHub reports the same PR as merged/closed before the local ADE row catches up, the lane tag shows the terminal GitHub state instead of a stale open ADE state. Runtime activity refreshes use `refreshLanes({ includeStatus: false, includeSnapshots: true, ... })` so PTY/chat/process buckets update without recomputing git status. Expanding Git Actions suppresses the hidden inline duplicate pane via `shouldMountGitActionsPane` while keeping the fullscreen pane mounted. Lane delete kicks off optimistically: the page subscribes to `lanes.delete.event`, tracks per-lane `LaneDeleteProgress` through `useAppStore().laneDeleteProgressByLaneId`, immediately closes the manage dialog, and excludes deleting lanes from the selectable lane id sets used by keyboard navigation (`selectableFilteredLaneIds`, `sortedSelectableLaneIds`). On mount/project switch it hydrates active backend delete progress when available, but also keeps stored active delete progress long enough to move selection away and queue a refresh if the backend list is missing, stale, or temporarily failed. Batch deletes still run selected child lanes before their selected parents, but each batch deletes one lane at a time and records per-lane failures; a parent remains blocked if a selected descendant fails. Lane tabs for deleting lanes render a non-interactive overlay with a spinning `CircleNotch` and a `Deleting` / `Deleted` / `Deleted with warnings` label; selection / pinning / context menu / split / git-actions surfaces are all suppressed for those rows. `resolveLaneDeleteStartSelection` (also used by tests) computes a fallback selection so the user is moved to the next available lane the moment delete starts, and a top-bar lane action chip surfaces failures and non-fatal cleanup warnings through `laneActionError`. Work-tab action deeplinks scrub `action`, `laneId`, and `laneIds` after handling so modal routing cannot also rewrite split selection state. | -| `renderer/components/lanes/lanePageModel.ts` | Pure lane-page selectors and URL/deletion helpers used by `LanesPage` and unit tests. Owns lane branch/PR matching, ADE-vs-GitHub PR tag precedence, terminal-state GitHub overrides for stale ADE PR rows, deep-link lane selection, action-deeplink query cleanup, create-lane request normalization, delete-start selection fallback, parent-before-child-safe batch delete planning, and `runLaneDeleteBatchSequentially` for serialized per-batch teardown. | +| `renderer/components/lanes/LanesPage.tsx` | 3-pane cockpit, tab management, dialog coordination. Each lane row in the lane list optionally renders a state-aware PR tag (`PR #N` / `DRAFT #N` / `MERGED #N` / `CLOSED #N`) when the lane's current branch matches an existing PR. The pure selectors in `lanePageModel.ts` prefer live same-branch GitHub repo inventory over terminal/stale ADE rows, then fall back to ADE-linked PR rows, so externally created PRs and open-after-closed branch reuse stay visible; linked PRs route to the PR workspace, while unlinked GitHub-only matches open externally. The page forces one GitHub snapshot refresh on project/branch-signature changes and otherwise uses cached snapshot/event refreshes to avoid repeated PR polling from the Lanes tab. Runtime activity refreshes use `refreshLanes({ includeStatus: false, includeSnapshots: true, ... })` so PTY/chat/process buckets update without recomputing git status. Expanding Git Actions suppresses the hidden inline duplicate pane via `shouldMountGitActionsPane` while keeping the fullscreen pane mounted. Lane delete kicks off optimistically: the page subscribes to `lanes.delete.event`, tracks per-lane `LaneDeleteProgress` through `useAppStore().laneDeleteProgressByLaneId`, immediately closes the manage dialog, and excludes deleting lanes from the selectable lane id sets used by keyboard navigation (`selectableFilteredLaneIds`, `sortedSelectableLaneIds`). On mount/project switch it hydrates active backend delete progress when available, but also keeps stored active delete progress long enough to move selection away and queue a refresh if the backend list is missing, stale, or temporarily failed. Batch deletes still run selected child lanes before their selected parents, but each batch deletes one lane at a time and records per-lane failures; a parent remains blocked if a selected descendant fails. Lane tabs for deleting lanes render a non-interactive overlay with a spinning `CircleNotch` and a `Deleting` / `Deleted` / `Deleted with warnings` label; selection / pinning / context menu / split / git-actions surfaces are all suppressed for those rows. `resolveLaneDeleteStartSelection` (also used by tests) computes a fallback selection so the user is moved to the next available lane the moment delete starts, and a top-bar lane action chip surfaces failures and non-fatal cleanup warnings through `laneActionError`. Work-tab action deeplinks scrub `action`, `laneId`, and `laneIds` after handling so modal routing cannot also rewrite split selection state. | +| `renderer/components/lanes/lanePageModel.ts` | Pure lane-page selectors and URL/deletion helpers used by `LanesPage` and unit tests. Owns lane branch/PR matching, same-repo GitHub PR guardrails for fork branch-name collisions, ADE-vs-GitHub PR tag precedence, terminal-state GitHub overrides for stale ADE PR rows, deep-link lane selection, action-deeplink query cleanup, create-lane request normalization, delete-start selection fallback, parent-before-child-safe batch delete planning, and `runLaneDeleteBatchSequentially` for serialized per-batch teardown. | | `renderer/state/appStore.ts` | Shared renderer project/lane state. Stores `laneDeleteProgressByLaneId` so in-flight lane deletion UI survives local `LanesPage` remounts and project metadata updates; the map clears only when the project root changes or the project is closed/reset. | | `renderer/components/lanes/laneUtils.ts` | Pure lane list/filter helpers plus default pane trees, including the work-focused tiling tree used by parallel chat launch deep links. | | `renderer/components/lanes/laneColorPalette.ts` | Curated lane color palette split into `LANE_CLASSIC_COLORS` and `LANE_RAINBOW_COLORS`, then combined as `LANE_COLOR_PALETTE`, plus helpers (`getLaneAccent`, `colorsInUse`, `nextAvailableColor`, `laneColorName`). The first 8 classic hexes form `LANE_FALLBACK_COLORS`, the legacy index-based fallback used for lanes that don't have an explicit color assigned. | @@ -313,10 +313,13 @@ default from the Lanes list (see `isMissionLaneHiddenByDefault` in The pipeline yields cooperatively (`setImmediate`) at the start of each step so a long-running step never blocks the IPC event loop, filesystem cleanup uses `fs.promises.rm` instead of synchronous - `rmSync`, and the `database_cleanup` step now wraps every cascade - delete inside a single `begin immediate` / `commit` transaction so - a partial failure rolls back to a consistent DB state instead of - leaving lane rows half-deleted. Generic ADE action calls + `rmSync`, and `git_worktree_remove` checks the managed worktree path + after a successful git removal so residual files are removed and + `git worktree prune` runs before the lane row disappears. The + `database_cleanup` step wraps every cascade delete inside a single + `begin immediate` / `commit` transaction so a partial failure rolls + back to a consistent DB state instead of leaving lane rows + half-deleted. Generic ADE action calls (`lane.delete` through `ade actions run` / TUI `/ade`) use the same teardown path, including lane-environment cleanup and port lease release. diff --git a/docs/features/lanes/worktree-isolation.md b/docs/features/lanes/worktree-isolation.md index 1195fffbc..30f2e2bb0 100644 --- a/docs/features/lanes/worktree-isolation.md +++ b/docs/features/lanes/worktree-isolation.md @@ -84,7 +84,9 @@ auto-clean it. 1. Fetch the row; reject if `is_edit_protected = 1` (primary). 2. If managed worktree: `git worktree remove --force `. If - attached: skip. + Git reports success but residual files remain, ADE removes the + directory with `fs.promises.rm` and runs `git worktree prune` before + continuing. If attached: skip. 3. If caller requested `deleteBranch`: `git branch -D `. 4. Delete the lane row. Stale state in `key_value`, `operations`, `sessions`, etc. that references the lane is either cascaded diff --git a/docs/features/pull-requests/README.md b/docs/features/pull-requests/README.md index 590e0c9c8..99a5ad4ee 100644 --- a/docs/features/pull-requests/README.md +++ b/docs/features/pull-requests/README.md @@ -56,7 +56,7 @@ Service files (`apps/desktop/src/main/services/prs/`): | File | Responsibility | |------|---------------| -| `prService.ts` | PR CRUD, GitHub sync, merge context, draft descriptions, check/review/comment hydration, cached detail snapshots (`listSnapshots`), commit snapshots (`getCommits`), integration proposals, merge-into-existing-lane adoption, merge bypass, post-merge cleanup, standalone PR branch cleanup (`cleanupBranch`), deployment listing, review-thread reply/resolve/react mutations for the timeline, the aggregate `getMobileSnapshot` that powers the iOS PRs tab, and `listOpenPullRequests` — a paginated `/repos/{owner}/{name}/pulls?state=open` fetch returning `BranchPullRequest[]` for the lane-creation branch picker. `getForLane(laneId)` resolves through `getDisplayRowForCurrentLaneBranch`: it returns the most recently updated PR whose head branch matches the lane's current branch ref, ranked open/draft → merged → closed, so a freshly merged PR still shows in lane-scoped UI instead of disappearing the moment GitHub flips the state. | +| `prService.ts` | PR CRUD, GitHub sync, merge context, draft descriptions, check/review/comment hydration, cached detail snapshots (`listSnapshots`), commit snapshots (`getCommits`), integration proposals, merge-into-existing-lane adoption, merge bypass, post-merge cleanup, standalone PR branch cleanup (`cleanupBranch`), deployment listing, review-thread reply/resolve/react mutations for the timeline, the aggregate `getMobileSnapshot` that powers the iOS PRs tab, and `listOpenPullRequests` — a paginated `/repos/{owner}/{name}/pulls?state=open` fetch returning `BranchPullRequest[]` for the lane-creation branch picker. `getForLane(laneId)` resolves through `getDisplayRowForCurrentLaneBranch`: it returns the most recently updated PR whose head branch matches the lane's current branch ref, ranked open/draft → merged → closed, so a freshly merged PR still shows in lane-scoped UI instead of disappearing the moment GitHub flips the state. `getGitHubSnapshot` fetches repo PRs, backfills same-repo lane PR rows by branch, and performs a capped per-branch fallback (`head=:`) for active lane branches missing from the repo snapshot window so old merged/closed externally-created PRs can still badge lanes. | | `prService.mobileSnapshot.test.ts` | Coverage for the mobile snapshot builder: stack chaining, capability gates, per-lane create eligibility, workflow-card aggregation | | `prService.mergeInto.test.ts` | Coverage for integration proposals that preview or adopt an existing merge target lane, including dirty-worktree handling and drift metadata. | | `prPollingService.ts` | 60 s polling loop, fingerprint-based change detection, notification emission. Writes `last_polled_at` per PR so callers can run delta polls on the next tick | @@ -207,7 +207,9 @@ Caching layers: inside `prService` on the active runtime for remote-bound windows and in the local in-process PR service for local-bound windows. Repeated in-flight snapshot requests are deduplicated. The snapshot - fetches repository PRs only. + fetches repository PRs only, then does at most 12 targeted same-repo + head-branch lookups for active lane branches that were absent from + the repo-wide page window. 2. **Renderer cache** — `PrsContext` holds the last snapshot so revisiting the tab renders immediately. Selected PR detail panes hydrate from `listSnapshots({ prId })` before live status, check,