From 63801ba2490372d7361e753349992416a429de58 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:36:56 +0800 Subject: [PATCH] fix(github): find pull requests in every git repository of the project (#1084) A project folder that holds several git repositories without being one itself showed "No GitHub repository" in Pull Requests: every gh command ran in the project folder. The GitHub remotes now come from each of the project's git repositories, and each remote remembers its repository. gh commands take the repository to run in (the primary one when none is given), and checkout, diff, worktree, merge, push, suggestions and new pull requests use the connected repository. Fixes #812 --- src-tauri/src/commands/github.rs | 68 +++++++++--- src/bindings.ts | 20 ++-- src/features/github/api/api.test.ts | 2 +- src/features/github/api/client.ts | 18 +-- .../github/create/DirectionSelector.tsx | 4 +- src/features/github/create/store.ts | 36 +++--- src/features/github/data/local.ts | 11 +- src/features/github/data/repository.test.ts | 67 ++++++++++++ src/features/github/data/repository.ts | 103 +++++++++++++----- src/features/github/details/DetailsPanel.tsx | 4 +- src/features/github/details/details.test.tsx | 2 +- src/features/github/details/flow.ts | 7 +- src/features/github/diff/reviewDiff.test.tsx | 4 +- src/features/github/list/list.test.tsx | 4 +- .../github/reviewMode/reviewMode.test.tsx | 4 +- .../github/timeline/comments/suggestion.ts | 11 +- .../github/toolwindow/toolwindow.test.tsx | 10 +- src/features/vcs/branchOps.ts | 5 +- 18 files changed, 278 insertions(+), 102 deletions(-) create mode 100644 src/features/github/data/repository.test.ts diff --git a/src-tauri/src/commands/github.rs b/src-tauri/src/commands/github.rs index 0ff90435a..951d62e6c 100644 --- a/src-tauri/src/commands/github.rs +++ b/src-tauri/src/commands/github.rs @@ -14,8 +14,18 @@ async fn blocking(f: impl FnOnce() -> IdeResult + Send + ' tauri::async_runtime::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))? } -fn root(window: &Window, state: &AppState) -> IdeResult { - Ok(state.project(window.label())?.root.clone()) +/// The working directory of gh: the project repository `repo` names, else the project's +/// primary repository (a folder holding several repositories is not one itself). +fn root(window: &Window, state: &AppState, repo: Option) -> IdeResult { + let project = state.project(window.label())?; + let repos = project.repos(); + match repo { + Some(at) => match repos.at_root(&at) { + Some(r) => Ok(r.root().to_path_buf()), + None => Err(IdeError::unavailable(format!("{} is not a git repository of the project", at.display()))), + }, + None => Ok(repos.primary().map_or_else(|| project.root.clone(), |r| r.root().to_path_buf())), + } } /// Emits `VcsChanged` after operations that move HEAD or refs (checkout, push). @@ -27,8 +37,12 @@ fn vcs_changed(state: &AppState, window: &Window, root: &std::path::Path) { #[tauri::command] #[specta::specta] -pub async fn gh_availability(window: Window, state: State<'_, AppState>) -> IdeResult { - let root = root(&window, &state)?; +pub async fn gh_availability( + window: Window, + state: State<'_, AppState>, + repo: Option, +) -> IdeResult { + let root = root(&window, &state, repo)?; blocking(move || Ok(ide_github::availability(&root))).await } @@ -37,16 +51,22 @@ pub async fn gh_availability(window: Window, state: State<'_, AppState>) -> IdeR pub async fn gh_pr_list( window: Window, state: State<'_, AppState>, + repo: Option, query: PrListQuery, ) -> IdeResult> { - let root = root(&window, &state)?; + let root = root(&window, &state, repo)?; blocking(move || ide_github::list(&root, &query)).await } #[tauri::command] #[specta::specta] -pub async fn gh_pr_view(window: Window, state: State<'_, AppState>, number: u32) -> IdeResult { - let root = root(&window, &state)?; +pub async fn gh_pr_view( + window: Window, + state: State<'_, AppState>, + repo: Option, + number: u32, +) -> IdeResult { + let root = root(&window, &state, repo)?; blocking(move || ide_github::view(&root, number)).await } @@ -55,9 +75,10 @@ pub async fn gh_pr_view(window: Window, state: State<'_, AppState>, number: u32) pub async fn gh_pr_review_comments( window: Window, state: State<'_, AppState>, + repo: Option, number: u32, ) -> IdeResult> { - let root = root(&window, &state)?; + let root = root(&window, &state, repo)?; blocking(move || ide_github::review_comments(&root, number)).await } @@ -66,11 +87,12 @@ pub async fn gh_pr_review_comments( pub async fn gh_pr_checkout( window: Window, state: State<'_, AppState>, + repo: Option, request: RequestId, number: u32, on_output: Channel, ) -> IdeResult<()> { - let root = root(&window, &state)?; + let root = root(&window, &state, repo)?; let cancel = state.begin_request(request); let dir = root.clone(); let result = blocking(move || { @@ -90,11 +112,12 @@ pub async fn gh_pr_checkout( pub async fn gh_pr_create( window: Window, state: State<'_, AppState>, + repo: Option, request: RequestId, pr: CreatePullRequest, on_output: Channel, ) -> IdeResult { - let root = root(&window, &state)?; + let root = root(&window, &state, repo)?; let cancel = state.begin_request(request); let dir = root.clone(); let result = blocking(move || { @@ -113,26 +136,38 @@ pub async fn gh_pr_create( pub async fn gh_pr_review( window: Window, state: State<'_, AppState>, + repo: Option, number: u32, event: ReviewEvent, body: String, ) -> IdeResult<()> { - let root = root(&window, &state)?; + let root = root(&window, &state, repo)?; blocking(move || ide_github::submit_review(&root, number, event, &body)).await } #[tauri::command] #[specta::specta] -pub async fn gh_pr_comment(window: Window, state: State<'_, AppState>, number: u32, body: String) -> IdeResult<()> { - let root = root(&window, &state)?; +pub async fn gh_pr_comment( + window: Window, + state: State<'_, AppState>, + repo: Option, + number: u32, + body: String, +) -> IdeResult<()> { + let root = root(&window, &state, repo)?; blocking(move || ide_github::add_comment(&root, number, &body)).await } /// A GitHub GraphQL / REST call through `gh api`; returns the response body. #[tauri::command] #[specta::specta] -pub async fn gh_api(window: Window, state: State<'_, AppState>, request: GhApiRequest) -> IdeResult { - let root = root(&window, &state)?; +pub async fn gh_api( + window: Window, + state: State<'_, AppState>, + repo: Option, + request: GhApiRequest, +) -> IdeResult { + let root = root(&window, &state, repo)?; blocking(move || ide_github::api(&root, &request)).await } @@ -143,6 +178,7 @@ pub async fn gh_api(window: Window, state: State<'_, AppState>, request: GhApiRe pub async fn gh_pr_prepare_diff( window: Window, state: State<'_, AppState>, + repo: Option, request: RequestId, number: u32, head_oid: String, @@ -151,7 +187,7 @@ pub async fn gh_pr_prepare_diff( name_with_owner: String, on_output: Channel, ) -> IdeResult { - let root = root(&window, &state)?; + let root = root(&window, &state, repo)?; let cancel = state.begin_request(request); let result = blocking(move || { ide_github::prepare_diff(&root, number, &head_oid, &base_oid, &base_ref, &name_with_owner, &cancel, &|line| { diff --git a/src/bindings.ts b/src/bindings.ts index 047982315..597a9ff70 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -945,19 +945,19 @@ export const commands = { * daemon and puts the docker executable on `PATH`. */ dockerRunEnv: (endpoint: DockerEndpoint) => typedError<{ [key in string]: string }, IdeError>(__TAURI_INVOKE("docker_run_env", { endpoint })), - ghAvailability: () => typedError(__TAURI_INVOKE("gh_availability")), + ghAvailability: (repo: string | null) => typedError(__TAURI_INVOKE("gh_availability", { repo })), /** A GitHub GraphQL / REST call through `gh api`; returns the response body. */ - ghApi: (request: GhApiRequest) => typedError(__TAURI_INVOKE("gh_api", { request })), - ghPrList: (query: PrListQuery) => typedError(__TAURI_INVOKE("gh_pr_list", { query })), - ghPrView: (number: number) => typedError(__TAURI_INVOKE("gh_pr_view", { number })), - ghPrReviewComments: (number: number) => typedError(__TAURI_INVOKE("gh_pr_review_comments", { number })), - ghPrCheckout: (request: RequestId, number: number, onOutput: Channel) => typedError(__TAURI_INVOKE("gh_pr_checkout", { request, number, onOutput })), + ghApi: (repo: string | null, request: GhApiRequest) => typedError(__TAURI_INVOKE("gh_api", { repo, request })), + ghPrList: (repo: string | null, query: PrListQuery) => typedError(__TAURI_INVOKE("gh_pr_list", { repo, query })), + ghPrView: (repo: string | null, number: number) => typedError(__TAURI_INVOKE("gh_pr_view", { repo, number })), + ghPrReviewComments: (repo: string | null, number: number) => typedError(__TAURI_INVOKE("gh_pr_review_comments", { repo, number })), + ghPrCheckout: (repo: string | null, request: RequestId, number: number, onOutput: Channel) => typedError(__TAURI_INVOKE("gh_pr_checkout", { repo, request, number, onOutput })), /** Creates a pull request from the current branch (pushing it first when asked); returns its URL. */ - ghPrCreate: (request: RequestId, pr: CreatePullRequest, onOutput: Channel) => typedError(__TAURI_INVOKE("gh_pr_create", { request, pr, onOutput })), - ghPrReview: (number: number, event: ReviewEvent, body: string) => typedError(__TAURI_INVOKE("gh_pr_review", { number, event, body })), - ghPrComment: (number: number, body: string) => typedError(__TAURI_INVOKE("gh_pr_comment", { number, body })), + ghPrCreate: (repo: string | null, request: RequestId, pr: CreatePullRequest, onOutput: Channel) => typedError(__TAURI_INVOKE("gh_pr_create", { repo, request, pr, onOutput })), + ghPrReview: (repo: string | null, number: number, event: ReviewEvent, body: string) => typedError(__TAURI_INVOKE("gh_pr_review", { repo, number, event, body })), + ghPrComment: (repo: string | null, number: number, body: string) => typedError(__TAURI_INVOKE("gh_pr_comment", { repo, number, body })), /** Fetches what is missing to diff a pull request locally; returns the revisions to compare. */ - ghPrPrepareDiff: (request: RequestId, number: number, headOid: string, baseOid: string, baseRef: string, nameWithOwner: string, onOutput: Channel) => typedError(__TAURI_INVOKE("gh_pr_prepare_diff", { request, number, headOid, baseOid, baseRef, nameWithOwner, onOutput })), + ghPrPrepareDiff: (repo: string | null, request: RequestId, number: number, headOid: string, baseOid: string, baseRef: string, nameWithOwner: string, onOutput: Channel) => typedError(__TAURI_INVOKE("gh_pr_prepare_diff", { repo, request, number, headOid, baseOid, baseRef, nameWithOwner, onOutput })), /** The requests of an `.http` file (gutter run icons, Run All). */ httpRequests: (text: string) => __TAURI_INVOKE("http_requests", { text }), /** Environment names from the `http-client(.private).env.json` files that apply to `path`. */ diff --git a/src/features/github/api/api.test.ts b/src/features/github/api/api.test.ts index bb199ecd9..6d583be6d 100644 --- a/src/features/github/api/api.test.ts +++ b/src/features/github/api/api.test.ts @@ -90,7 +90,7 @@ describe("client", () => { .mockResolvedValue({ status: "ok", data: '{"data":{"repository":null}}' } as never); const { graphql } = await import("./client"); await graphql(Q.findRepository, { repoOwner: "o", repoName: "r", cursor: undefined }); - const req = ghApi.mock.calls[0]![0]; + const req = ghApi.mock.calls[0]![1]; expect(req).toMatchObject({ method: "POST", endpoint: "graphql", paginate: false }); const body = JSON.parse(req.body!) as { query: string; variables: Record }; expect(body.variables).toEqual({ repoOwner: "o", repoName: "r" }); diff --git a/src/features/github/api/client.ts b/src/features/github/api/client.ts index bd0d53515..184f45766 100644 --- a/src/features/github/api/client.ts +++ b/src/features/github/api/client.ts @@ -1,6 +1,6 @@ /** - * GitHub API access through gh (`commands.ghApi`, which runs `gh api` in the project root - * with gh's authentication), like IntelliJ's `GithubApiRequestExecutor`: + * GitHub API access through gh (`commands.ghApi`, which runs `gh api` in the connected local + * repository with gh's authentication), like IntelliJ's `GithubApiRequestExecutor`: * * const data = await graphql<{ repository: … }>(findRepository, { repoOwner, repoName }); * const page = await rest("GET", `${repoPath()}/labels?per_page=100`); @@ -39,7 +39,9 @@ export function graphqlData(text: string): T { /** Runs a document from `queries.ts` with `variables` (undefined values are dropped). */ export async function graphql(doc: string, variables: Record = {}): Promise { const body = JSON.stringify({ query: withFragments(doc), variables: definedOnly(variables) }); - const text = await call(commands.ghApi({ method: "POST", endpoint: "graphql", body, paginate: false })); + const text = await call( + commands.ghApi(current?.root ?? null, { method: "POST", endpoint: "graphql", body, paginate: false }), + ); return graphqlData(text); } @@ -59,7 +61,7 @@ export async function rest( opts: { paginate?: boolean } = {}, ): Promise { const text = await call( - commands.ghApi({ + commands.ghApi(current?.root ?? null, { method, endpoint, body: body === undefined ? null : JSON.stringify(body), @@ -80,10 +82,12 @@ export function parseRest(text: string, paginated: boolean): T { // --------------------------------------------------------------------------- // The connected repository -let current: (GHRepositoryCoordinates & { id: string | null }) | null = null; +let current: (GHRepositoryCoordinates & { id: string | null; root?: string | null }) | null = null; -/** Set by `data/repository.ts` when the tool window connects (null: disconnected). */ -export function setCurrentRepository(repo: (GHRepositoryCoordinates & { id: string | null }) | null) { +/** Set by `data/repository.ts` when the tool window connects (null: disconnected); `root`: its local repository. */ +export function setCurrentRepository( + repo: (GHRepositoryCoordinates & { id: string | null; root?: string | null }) | null, +) { current = repo; } diff --git a/src/features/github/create/DirectionSelector.tsx b/src/features/github/create/DirectionSelector.tsx index 8d05e9e30..275dd37d0 100644 --- a/src/features/github/create/DirectionSelector.tsx +++ b/src/features/github/create/DirectionSelector.tsx @@ -7,7 +7,7 @@ import { useMemo, useState, type KeyboardEvent } from "react"; import { HighlightedText, Icon, List, Popup, SearchField, Select, T, fuzzyMatch, listNavigate } from "@/ui"; import { useTx } from "@/platform/i18n"; -import { nameWithOwner, sameRepository, useGhRepository, type GHRemote } from "../data/repository"; +import { NO_REMOTES, nameWithOwner, sameRepository, useGhRepository, type GHRemote } from "../data/repository"; import { ChooserPopup } from "../ui/ChooserPopup"; import { baseText, @@ -91,7 +91,7 @@ type HeadRow = { section: string } | { branch: HeadBranch; text: string; match: /** The head: the repository (several GitHub remotes only), then its branches, local first. */ function HeadPopup(props: { anchor: HTMLElement; initialRepo: GHRemote; onClose: () => void }) { const tx = useTx(); - const remotes = useGhRepository((s) => s.remotes); + const remotes = useGhRepository((s) => s.connection?.remotes ?? NO_REMOTES); const branches = useCreatePr((s) => s.branches); const current = useCreatePr((s) => s.direction?.headBranch ?? null); const [repo, setRepo] = useState(props.initialRepo); diff --git a/src/features/github/create/store.ts b/src/features/github/create/store.ts index 92de9b210..62b148cdd 100644 --- a/src/features/github/create/store.ts +++ b/src/features/github/create/store.ts @@ -23,7 +23,13 @@ import * as api from "../api/requests"; import type { GHLabel, GHPullRequestRequestedReviewer, GHUser } from "../api/types"; import { isTeam } from "../data/model"; import { createPullRequest } from "../data/operations"; -import { loadPullRequestTemplate, requireConnection, sameRepository, useGhRepository } from "../data/repository"; +import { + connectedRemotes, + connectedRoot, + loadPullRequestTemplate, + requireConnection, + sameRepository, +} from "../data/repository"; import type { GHRemote } from "../data/repository"; import { refreshCurrentBranchPullRequest } from "../reviewMode/state"; import { openPullRequest } from "../toolwindow/open"; @@ -121,18 +127,18 @@ let changesSeq = 0; /** The repository's root, which the preview's repository-relative paths are relative to. */ function repoRoot(): string | null { - return useVcs.getState().roots[0] ?? useProject.getState().project?.root ?? null; + return connectedRoot() ?? useVcs.getState().roots[0] ?? useProject.getState().project?.root ?? null; } const ok = (value: T): Loadable => ({ state: "ok", value }); const failed = (e: unknown): Loadable => ({ state: "error", error: errorMessage(e) }); -const remoteNames = () => useGhRepository.getState().remotes.map((r) => r.remote); +const remoteNames = () => connectedRemotes().map((r) => r.remote); /** The connected repository's GitHub remote (the base). */ function baseRemote(): GHRemote { const { coordinates } = requireConnection(); - const remotes = useGhRepository.getState().remotes; + const remotes = connectedRemotes(); return remotes.find((r) => sameRepository(r, coordinates)) ?? { ...coordinates, remote: "origin", url: "" }; } @@ -143,13 +149,13 @@ export async function initCreate(): Promise { try { const { repository } = requireConnection(); const [branches, info] = await Promise.all([ - call(commands.vcsBranches(null)), - call(commands.vcsBranchInfo(null)).catch(() => null), + call(commands.vcsBranches(connectedRoot())), + call(commands.vcsBranchInfo(connectedRoot())).catch(() => null), ]); if (e !== generation) return; const direction = defaultDirection({ baseRepo: baseRemote(), - remotes: useGhRepository.getState().remotes, + remotes: connectedRemotes(), branches, currentBranch: info?.head ?? null, defaultBranch: repository.defaultBranchRef?.name, @@ -190,7 +196,7 @@ export function setHead(repo: GHRemote, branch: HeadBranch) { /** The Refresh of a failed check: reads the branches again and checks them. */ export async function refreshBranchesState(): Promise { - const branches = await call(commands.vcsBranches(null)).catch(() => null); + const branches = await call(commands.vcsBranches(connectedRoot())).catch(() => null); if (branches) useCreatePr.setState({ branches }); reloadBranchesState(); } @@ -201,7 +207,7 @@ const tip = (branches: readonly Branch[], name: string) => branches.find((b) => export async function onRepositoryChanged(): Promise { const s = useCreatePr.getState(); if (!s.ready || !s.direction) return; - const branches = await call(commands.vcsBranches(null)).catch(() => null); + const branches = await call(commands.vcsBranches(connectedRoot())).catch(() => null); if (!branches) return; const { baseBranch, headBranch } = useCreatePr.getState().direction ?? {}; const before = useCreatePr.getState().branches; @@ -245,7 +251,7 @@ function reloadBranchesState() { : Promise.resolve(null); existing.catch(() => undefined); const commits = call( - commands.vcsLog(null, { + commands.vcsLog(connectedRoot(), { branch: null, path: null, author: null, @@ -320,14 +326,14 @@ async function loadChanges() { let changes: CreateChange[]; const commit = s.selectedCommit ? list.find((c) => c.id === s.selectedCommit) : null; if (commit) { - const files = await call(commands.vcsCommitFiles(null, commit.id)); + const files = await call(commands.vcsCommitFiles(connectedRoot(), commit.id)); changes = files.map((f) => ({ ...f, diff: commitFileDiffData({ ...commit, repo: root }, f) })); } else { const head = list[0]!.id; const baseName = remoteBranchFullName(d.baseBranch); - const mergeBase = await call(commands.vcsMergeBase(null, baseName, head)); + const mergeBase = await call(commands.vcsMergeBase(connectedRoot(), baseName, head)); if (!mergeBase) throw new Error(tx`Unrelated branches ${baseName} ${headBranchName(d.headBranch)}`); - const files = await call(commands.vcsDiffRevisions(null, mergeBase, head)); + const files = await call(commands.vcsDiffRevisions(connectedRoot(), mergeBase, head)); const baseSide = { rev: mergeBase, label: `${d.baseBranch.name} (${mergeBase.slice(0, 8)})` }; const headSide = { rev: head, label: `${headBranchName(d.headBranch)} (${head.slice(0, 8)})` }; changes = files.map((f) => ({ ...f, diff: rangeDiffData(f, root, baseSide, headSide) })); @@ -384,7 +390,7 @@ async function pushHead(head: Extract, headRepo: remote: target.remote, }, `${head.name} → ${remoteBranchFullName(target)}`, - [null], + [connectedRoot()], ); if (!pushed) { const error = lastRemoteFailure(); @@ -392,7 +398,7 @@ async function pushHead(head: Extract, headRepo: if (!error) return null; throw new Error(tx`Push failed: ${error}`); } - const after = await call(commands.vcsBranches(null)).catch(() => null); + const after = await call(commands.vcsBranches(connectedRoot())).catch(() => null); if (after) useCreatePr.setState({ branches: after }); } return target.name; diff --git a/src/features/github/data/local.ts b/src/features/github/data/local.ts index 595c0835d..233512a5c 100644 --- a/src/features/github/data/local.ts +++ b/src/features/github/data/local.ts @@ -16,7 +16,7 @@ import { notifications } from "@/workbench/contributions"; import { errorMessage } from "../api/client"; import type { GHCommitFile, GHPullRequest } from "../api/types"; import { loadPullRequestData } from "./pullRequest"; -import { nameWithOwner, requireConnection } from "./repository"; +import { connectedRoot, nameWithOwner, requireConnection } from "./repository"; /** The running operation (checkout, fetch for a diff) with its last output line. */ export const useGhBusy = create<{ busy: { title: string; line: string; request: RequestId } | null }>(() => ({ @@ -67,7 +67,7 @@ export function cancelBusy() { export async function checkoutPullRequest(number: number): Promise { // The command resolves with nothing (`null`), which `withBusy` reports as a failure. const done = await withBusy(`Checking out #${number}…`, async (r, out) => { - await call(commands.ghPrCheckout(r, number, out)); + await call(commands.ghPrCheckout(connectedRoot(), r, number, out)); return true; }); if (done === null) return false; @@ -87,9 +87,10 @@ export async function prepareDiff(pr: GHPullRequest): Promise - call(commands.ghPrPrepareDiff(r, pr.number, pr.headRefOid, pr.baseRefOid, pr.baseRefName, repo, out)), + call(commands.ghPrPrepareDiff(root, r, pr.number, pr.headRefOid, pr.baseRefOid, pr.baseRefName, repo, out)), ); if (revs) diffCache.set(key, revs); return revs; @@ -185,7 +186,7 @@ export async function openInWorktree(number: number): Promise { if (!revs) return; const path = `${String(revs.root).replace(/[\\/]+$/, "")}_PR_${number}`; const added = await withBusy(`Creating worktree for #${number}…`, async () => { - await call(commands.vcsAddWorktree(null, path, pr.headRefOid, null)); + await call(commands.vcsAddWorktree(revs.root, path, pr.headRefOid, null)); return true; }); if (added) await executeAction("file.openInNewWindow", path); diff --git a/src/features/github/data/repository.test.ts b/src/features/github/data/repository.test.ts new file mode 100644 index 000000000..b5bed884e --- /dev/null +++ b/src/features/github/data/repository.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const ghApi = vi.fn(); +const ghAvailability = vi.fn(); +const ok = (data: unknown) => Promise.resolve({ status: "ok", data }); + +vi.mock("@/platform/ipc", () => ({ + isTauri: () => true, + call: async (p: Promise<{ status: string; data?: unknown; error?: unknown }>) => { + const r = await p; + if (r.status !== "ok") throw r.error; + return r.data; + }, + commands: { + vcsRoots: () => ok([{ root: "/w/android" }, { root: "/w/ios" }, { root: "/w/tools" }]), + vcsRemotes: (root: string | null) => + ok( + root === "/w/android" + ? [{ name: "origin", url: "git@github.com:org/android.git" }] + : root === "/w/ios" + ? [{ name: "origin", url: "https://github.com/org/ios.git" }] + : [{ name: "origin", url: "https://gitlab.com/org/tools.git" }], + ), + ghAvailability: (repo: string | null) => ghAvailability(repo), + ghApi: (repo: string | null, req: { endpoint: string }) => ghApi(repo, req), + }, +})); + +import { rest } from "../api/client"; +import { connectedRoot, connectedRemotes, connect, refreshConnection, useGhRepository } from "./repository"; + +afterEach(() => { + ghApi.mockReset(); + ghAvailability.mockReset(); + localStorage.clear(); +}); + +describe("a folder holding several git repositories", () => { + it("offers the GitHub repositories of each and runs gh in the connected one", async () => { + ghAvailability.mockReturnValue( + ok({ kind: "ready", repo: { nameWithOwner: "org/android", url: "https://github.com/org/android" }, login: "me" }), + ); + ghApi.mockImplementation((_repo: string | null, req: { endpoint: string }) => + ok( + req.endpoint === "user" + ? JSON.stringify({ id: 1, node_id: "U1", login: "me", avatar_url: "", html_url: "" }) + : JSON.stringify({ data: { repository: { id: "R1", owner: { login: "org" }, name: "x" } } }), + ), + ); + await refreshConnection(); + // gh is asked in a repository, not in the folder that holds them. + expect(ghAvailability).toHaveBeenCalledWith("/w/android"); + const s = useGhRepository.getState(); + expect(s.remotes.map((r) => [r.owner, r.name, r.root])).toEqual([ + ["org", "android", "/w/android"], + ["org", "ios", "/w/ios"], + ]); + // Several repositories: the selector is shown. + expect(s.connection).toBeNull(); + + expect(await connect(s.remotes[1]!)).toBe(true); + expect(connectedRoot()).toBe("/w/ios"); + expect(connectedRemotes().map((r) => r.name)).toEqual(["ios"]); + await rest("GET", "user"); + expect(ghApi).toHaveBeenLastCalledWith("/w/ios", expect.objectContaining({ endpoint: "user" })); + }); +}); diff --git a/src/features/github/data/repository.ts b/src/features/github/data/repository.ts index eecbc37e0..52a27f21d 100644 --- a/src/features/github/data/repository.ts +++ b/src/features/github/data/repository.ts @@ -7,11 +7,13 @@ * mentionable users. * * Auto-connect: a project with one GitHub remote connects at once; with several, the - * repository chosen last (saved per project) is used, else the selector is shown. + * repository chosen last (saved per project) is used, else the selector is shown. The + * remotes come from every git repository of the project (a folder holding several + * repositories offers each one's), and gh runs in the repository of the connected remote. */ import { useEffect } from "react"; import { create } from "zustand"; -import type { GhAvailability } from "@/platform/ipc"; +import type { GhAvailability, Remote } from "@/platform/ipc"; import { call, commands, isTauri } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import { errorMessage, setCurrentRepository } from "../api/client"; @@ -34,12 +36,18 @@ export interface GHRemote extends GHRepositoryCoordinates { /** Git remote name (`origin`). */ remote: string; url: string; + /** Root of the project's git repository the remote belongs to (null or absent: the primary one). */ + root?: string | null; } export interface GHConnection { coordinates: GHRepositoryCoordinates; repository: GHRepository; currentUser: GHUser; + /** The local repository checkouts, diffs and new pull requests use (null: the primary one). */ + root: string | null; + /** That repository's GitHub remotes. */ + remotes: GHRemote[]; } interface RepositoryState { @@ -96,22 +104,51 @@ export const sameRepository = (a: GHRepositoryCoordinates | null | undefined, b: export const nameWithOwner = (r: GHRepositoryCoordinates) => `${r.owner}/${r.name}`; -/** The GitHub remotes among `remotes`, one per repository. */ -export function githubRemotes(remotes: ReadonlyArray<{ name: string; url: string }>): GHRemote[] { +/** The GitHub remotes among `remotes` of the repository at `root`, one per GitHub repository. */ +export function githubRemotes( + remotes: ReadonlyArray<{ name: string; url: string }>, + root: string | null = null, +): GHRemote[] { const out: GHRemote[] = []; for (const r of remotes) { const c = parseGitHubRemote(r.url); - if (c && !out.some((o) => sameRepository(o, c))) out.push({ ...c, remote: r.name, url: r.url }); + if (c && !out.some((o) => sameRepository(o, c))) out.push({ ...c, remote: r.name, url: r.url, root }); } return out; } +/** The GitHub repositories of several local repositories' remotes, each once (the first local repository's). */ +function uniqueRepositories(remotes: readonly GHRemote[]): GHRemote[] { + return remotes.filter((r, i) => remotes.findIndex((o) => sameRepository(o, r)) === i); +} + +/** The connected local repository's GitHub remotes (none while disconnected). */ +export const NO_REMOTES: readonly GHRemote[] = []; +export const connectedRemotes = (): readonly GHRemote[] => useGhRepository.getState().connection?.remotes ?? NO_REMOTES; + +/** The local repository of the connection, for gh and git commands (null: the primary one). */ +export const connectedRoot = (): string | null => useGhRepository.getState().connection?.root ?? null; + function parseNameWithOwner(s: string | null | undefined): GHRepositoryCoordinates | null { const m = s ? /^([^/\s]+)\/([^/\s]+)$/.exec(s) : null; return m ? { owner: m[1]!, name: m[2]! } : null; } let checkSeq = 0; +/** GitHub remotes of every local repository (a GitHub repository can be a remote of several). */ +let localRemotes: GHRemote[] = []; + +async function projectGitHubRemotes(): Promise<{ remotes: GHRemote[]; all: Map }> { + const roots = await call(commands.vcsRoots()).catch(() => []); + const dirs: Array = roots.length ? roots.map((r) => String(r.root)) : [null]; + const lists = await Promise.all( + dirs.map(async (root) => ({ root, remotes: await call(commands.vcsRemotes(root)).catch(() => [] as Remote[]) })), + ); + return { + remotes: lists.flatMap((l) => githubRemotes(l.remotes, l.root)), + all: new Map(lists.map((l) => [l.root, l.remotes])), + }; +} /** * Checks gh and the remotes, then connects as IntelliJ does on start: the only GitHub @@ -122,36 +159,42 @@ export async function refreshConnection(): Promise { const seq = ++checkSeq; useGhRepository.setState({ checking: true }); let availability: GhAvailability; - let remotes: GHRemote[] = []; - let allRemotes: Array<{ name: string; url: string }> = []; + const found = await projectGitHubRemotes(); + if (seq !== checkSeq) return; + const current = useGhRepository.getState().connection?.coordinates ?? null; + const saved = parseNameWithOwner(loadSelection(projectRoot())); + // gh resolves its repository in a local one; ask in the one likely to be connected. + const probe = + ( + found.remotes.find((r) => sameRepository(r, current)) ?? + found.remotes.find((r) => sameRepository(r, saved)) ?? + found.remotes[0] + )?.root ?? null; try { - const [a, r] = await Promise.all([ - call(commands.ghAvailability()), - call(commands.vcsRemotes(null)).catch(() => []), - ]); - availability = a; - allRemotes = r; - remotes = githubRemotes(r); + availability = await call(commands.ghAvailability(probe)); } catch (e) { availability = { kind: "error", message: errorMessage(e) }; } if (seq !== checkSeq) return; + localRemotes = found.remotes; + const remotes = uniqueRepositories(localRemotes); const defaultRemote = availability.kind === "ready" ? parseNameWithOwner(availability.repo.nameWithOwner) : null; if (defaultRemote && !remotes.some((r) => sameRepository(r, defaultRemote))) { - const own = remoteOfRepository(allRemotes, defaultRemote); - remotes.unshift({ + const own = remoteOfRepository(found.all.get(probe) ?? [], defaultRemote); + const remote = { ...defaultRemote, remote: own?.name ?? "", url: own?.url ?? (availability.kind === "ready" ? availability.repo.url : ""), - }); + root: probe, + }; + remotes.unshift(remote); + localRemotes = [remote, ...localRemotes]; } useGhRepository.setState({ availability, remotes, defaultRemote, checking: false }); if (availability.kind !== "ready") { disconnect({ forget: false }); return; } - const current = useGhRepository.getState().connection?.coordinates ?? null; - const saved = parseNameWithOwner(loadSelection(projectRoot())); const target = remotes.length === 1 ? remotes[0]! @@ -163,20 +206,28 @@ export async function refreshConnection(): Promise { let connectSeq = 0; -/** Connects to `repo` (the selector's "View Pull Requests"), remembering the choice. */ -export async function connect(repo: GHRepositoryCoordinates, opts: { remember?: boolean } = {}): Promise { +/** + * Connects to `repo` (the selector's "View Pull Requests"), remembering the choice; gh and git + * then run in `repo.root`, else in the first local repository with it as a remote. + */ +export async function connect( + repo: GHRepositoryCoordinates & { root?: string | null }, + opts: { remember?: boolean } = {}, +): Promise { const seq = ++connectSeq; const coordinates = { owner: repo.owner, name: repo.name }; + const root = repo.root !== undefined ? repo.root : (localRemotes.find((r) => sameRepository(r, repo))?.root ?? null); + const remotes = localRemotes.filter((r) => (r.root ?? null) === root); useGhRepository.setState({ connecting: true, connectionError: null }); try { const [repository, currentUser] = await Promise.all([api.findRepository(coordinates), api.currentUser()]); if (seq !== connectSeq) return false; if (!repository) throw new Error(`Repository ${nameWithOwner(coordinates)} not found`); - const same = sameRepository(useGhRepository.getState().connection?.coordinates, coordinates); - if (!same) resetRepositoryData(); - setCurrentRepository({ ...coordinates, id: repository.id }); + const previous = useGhRepository.getState().connection; + if (!sameRepository(previous?.coordinates, coordinates) || previous?.root !== root) resetRepositoryData(); + setCurrentRepository({ ...coordinates, id: repository.id, root }); useGhRepository.setState((s) => ({ - connection: { coordinates, repository, currentUser }, + connection: { coordinates, repository, currentUser, root, remotes }, connecting: false, revision: s.revision + 1, })); @@ -237,7 +288,7 @@ export function isCurrentUser(user: { id?: string; login?: string } | null | und export async function reloadRepositoryData(): Promise { const c = useGhRepository.getState().connection; resetRepositoryData(); - if (c) await connect(c.coordinates); + if (c) await connect({ ...c.coordinates, root: c.root }); } // --------------------------------------------------------------------------- diff --git a/src/features/github/details/DetailsPanel.tsx b/src/features/github/details/DetailsPanel.tsx index 368e1cf85..5b6a62df5 100644 --- a/src/features/github/details/DetailsPanel.tsx +++ b/src/features/github/details/DetailsPanel.tsx @@ -14,7 +14,7 @@ import { Spinner, T, showContextMenu, tooltipAttrs, type MenuItem } from "@/ui"; import { hasPermission, reviewersStates } from "../data/model"; import { openInBrowser } from "../data/local"; import { reloadPullRequestData, usePullRequestData } from "../data/pullRequest"; -import { useGhRepository } from "../data/repository"; +import { NO_REMOTES, useGhRepository } from "../data/repository"; import { provideGithubHandler } from "../toolwindow/handlers"; import { openTimeline } from "../toolwindow/timelineInput"; import { StateTag } from "../ui/chips"; @@ -85,7 +85,7 @@ function Details({ number, submitRef }: { number: number; submitRef: RefObject s.connection?.repository.viewerPermission); - const remotes = useGhRepository((s) => s.remotes); + const remotes = useGhRepository((s) => s.connection?.remotes ?? NO_REMOTES); const branch = useVcs((s) => s.branch); const busy = useFlowBusy((s) => s.busy[number] !== undefined); diff --git a/src/features/github/details/details.test.tsx b/src/features/github/details/details.test.tsx index 7d1ad406b..4a43a3596 100644 --- a/src/features/github/details/details.test.tsx +++ b/src/features/github/details/details.test.tsx @@ -76,7 +76,7 @@ function pr(patch: Partial = {}): GHPullRequest { beforeEach(() => { useGhRepository.setState({ - connection: { coordinates: { owner: "org", name: "app" }, repository, currentUser: me }, + connection: { coordinates: { owner: "org", name: "app" }, repository, currentUser: me, root: null, remotes: [] }, }); setPullRequestData(7, "pendingReview", () => null); }); diff --git a/src/features/github/details/flow.ts b/src/features/github/details/flow.ts index 1a7e5aadd..3fe655e3a 100644 --- a/src/features/github/details/flow.ts +++ b/src/features/github/details/flow.ts @@ -17,6 +17,7 @@ import { mergeCommitMessage, splitCommitMessage, type ReviewerWithState } from " import { checkoutPullRequest, fileDiffData, prepareDiff } from "../data/local"; import * as ops from "../data/operations"; import { loadPullRequestData } from "../data/pullRequest"; +import { connectedRoot } from "../data/repository"; import { mergeCommitMessageDialog } from "../ui/dialogs"; import { reviewersDelta, type MergeMethod } from "./model"; @@ -97,7 +98,7 @@ export async function showInGitLog(pr: GHPullRequest, headRemote: string | null) const revs = await prepareDiff(pr); if (!revs) return; const tracked = headRemote ? `${headRemote}/${pr.headRefName}` : null; - const branches = tracked ? await call(commands.vcsBranches(null)).catch(() => []) : []; + const branches = tracked ? await call(commands.vcsBranches(connectedRoot())).catch(() => []) : []; const ref = tracked && branches.some((b) => b.remote && b.name === tracked) ? tracked : revs.head; await executeAction("vcs.showLog", { branch: ref }); } @@ -121,7 +122,7 @@ export function resolveConflictsLocally( // Fetches the base branch as well (updating `/`). if (!(await prepareDiff(pr))) return; if (!isCheckedOut && !(await checkoutPullRequest(number))) return; - await mergeIntoCurrent(`${target.baseRemote}/${target.baseRefName}`); + await mergeIntoCurrent(`${target.baseRemote}/${target.baseRefName}`, connectedRoot()); }); } @@ -129,7 +130,7 @@ export function resolveConflictsLocally( export async function isInCurrentHistory(oid: string): Promise { try { const commits = await call( - commands.vcsLog(null, { + commands.vcsLog(connectedRoot(), { branch: null, path: null, author: null, diff --git a/src/features/github/diff/reviewDiff.test.tsx b/src/features/github/diff/reviewDiff.test.tsx index 0026892f8..02d2e5345 100644 --- a/src/features/github/diff/reviewDiff.test.tsx +++ b/src/features/github/diff/reviewDiff.test.tsx @@ -14,7 +14,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => { ...real.commands, fsProbe: () => Promise.resolve({ status: "error", error: "none" }), vcsFileAt: (_path: string, rev: string) => Promise.resolve({ status: "ok", data: texts[rev] ?? "" }), - ghApi: (req: unknown) => ghApi(req), + ghApi: (_repo: unknown, req: unknown) => ghApi(req), }, }; }); @@ -158,6 +158,8 @@ beforeEach(() => { coordinates: { owner: "o", name: "r" }, repository: { id: "R1", viewerPermission: "READ" } as GHRepository, currentUser: me, + root: null, + remotes: [], }, }); setPullRequestData( diff --git a/src/features/github/list/list.test.tsx b/src/features/github/list/list.test.tsx index df38321b8..cba730bbe 100644 --- a/src/features/github/list/list.test.tsx +++ b/src/features/github/list/list.test.tsx @@ -185,7 +185,7 @@ describe("ListPanel", () => { localStorage.clear(); useProject.setState({ project: { root: "/p", name: "p" } as never }); queries = []; - vi.spyOn(ipc.commands, "ghApi").mockImplementation(async (req) => { + vi.spyOn(ipc.commands, "ghApi").mockImplementation(async (_repo, req) => { const body = JSON.parse(req.body!) as { variables: { query: string } }; queries.push(body.variables.query); const page = pages.shift() ?? []; @@ -205,7 +205,7 @@ describe("ListPanel", () => { useGhRepository.setState({ availability: { kind: "ready", repo: {} as never, login: "me" }, remotes: [{ owner: "org", name: "app", remote: "origin", url: "" }], - connection: { coordinates: { owner: "org", name: "app" }, repository, currentUser: me }, + connection: { coordinates: { owner: "org", name: "app" }, repository, currentUser: me, root: null, remotes: [] }, }); }); afterEach(() => { diff --git a/src/features/github/reviewMode/reviewMode.test.tsx b/src/features/github/reviewMode/reviewMode.test.tsx index 3724079d8..dbf2174bb 100644 --- a/src/features/github/reviewMode/reviewMode.test.tsx +++ b/src/features/github/reviewMode/reviewMode.test.tsx @@ -24,7 +24,7 @@ vi.mock("@/platform/ipc", async (importOriginal) => { ...real, commands: { ...real.commands, - ghApi: (req: unknown) => ghApi(req), + ghApi: (_repo: unknown, req: unknown) => ghApi(req), vcsFileAt: () => ok(HEAD), vcsDiffHunks: (a: string, b: string) => Promise.resolve(diffHunks(a, b)), vcsConfigGet: (_repo: string | null, key: string) => ok(config[key] ?? null), @@ -295,6 +295,8 @@ beforeEach(() => { coordinates: { owner: "o", name: "r" }, repository: { id: "R1", viewerPermission: "READ" } as GHRepository, currentUser: me, + root: null, + remotes: [], }, }); useReviewModeSettings.setState({ enabled: true, threads: "unresolved" }); diff --git a/src/features/github/timeline/comments/suggestion.ts b/src/features/github/timeline/comments/suggestion.ts index b702f8520..15a8d7053 100644 --- a/src/features/github/timeline/comments/suggestion.ts +++ b/src/features/github/timeline/comments/suggestion.ts @@ -13,11 +13,12 @@ import type { GHPullRequest, GHPullRequestReviewThread } from "../../api/types"; import { withBusy } from "../../data/local"; import { resolveThread } from "../../data/operations"; import { usePullRequestData } from "../../data/pullRequest"; +import { connectedRoot } from "../../data/repository"; import { buildSuggestionPatch, type SuggestionTarget } from "../model"; +/** The connected pull requests' local repository (the project's primary one when unknown). */ async function repositoryRoot(): Promise { - const roots = await call(commands.vcsRoots()); - const root = roots[0]?.root; + const root = connectedRoot() ?? (await call(commands.vcsRoots()))[0]?.root; if (!root) throw new Error("The project is not a Git repository"); return String(root).replace(/[\\/]+$/, ""); } @@ -36,7 +37,7 @@ export async function applySuggestion( const abs = `${root}/${target.path}`; const text = await call(commands.fsReadText(abs)); const { patch, start, lines } = buildSuggestionPatch(text, target, suggestion); - const outcome = await call(commands.vcsApplyPatch(null, patch)); + const outcome = await call(commands.vcsApplyPatch(connectedRoot(), patch)); const files = outcome.conflicts.join(", "); if (files) throw new Error(tx`Conflicts in ${files}`); if (commitMessage) { @@ -48,7 +49,7 @@ export async function applySuggestion( .map(({ start, lines, oldStart, oldLines }) => ({ start, lines, oldStart, oldLines })); if (!hunks.length) throw new Error(tx`${target.path}: nothing to commit`); await call( - commands.vcsCommit(null, commitMessage, { + commands.vcsCommit(connectedRoot(), commitMessage, { amend: false, signOff: false, author: null, @@ -75,7 +76,7 @@ function branchInfo(version: number) { branchCache = { at: Date.now(), version, - value: call(commands.vcsBranchInfo(null)).then( + value: call(commands.vcsBranchInfo(connectedRoot())).then( (b) => ({ head: b.head, upstream: b.upstream, commit: b.headCommit }), () => ({ head: null, upstream: null, commit: null }), ), diff --git a/src/features/github/toolwindow/toolwindow.test.tsx b/src/features/github/toolwindow/toolwindow.test.tsx index 0e8482990..269dee84f 100644 --- a/src/features/github/toolwindow/toolwindow.test.tsx +++ b/src/features/github/toolwindow/toolwindow.test.tsx @@ -150,7 +150,13 @@ describe("tool window", () => { useGhRepository.setState({ availability: { kind: "ready", repo: {} as never, login: "me" }, remotes: [{ owner: "org", name: "app", remote: "origin", url: "" }], - connection: { coordinates: { owner: "org", name: "app" }, repository, currentUser: me }, + connection: { + coordinates: { owner: "org", name: "app" }, + repository, + currentUser: me, + root: null, + remotes: [], + }, }); render( <> @@ -161,7 +167,7 @@ describe("tool window", () => { }); expect(screen.getByRole("tab", { name: /app/ })).toBeTruthy(); expect(screen.getByRole("textbox", { name: "Search pull requests" })).toBeTruthy(); - const body = JSON.parse(ghApi.mock.calls[0]![0].body!) as { variables: { query: string; pageSize: number } }; + const body = JSON.parse(ghApi.mock.calls[0]![1].body!) as { variables: { query: string; pageSize: number } }; // First run: every open pull request. expect(body.variables).toMatchObject({ query: "type:pr repo:org/app is:open", pageSize: 50 }); await act(async () => openPullRequestTab(4)); diff --git a/src/features/vcs/branchOps.ts b/src/features/vcs/branchOps.ts index b26dc2518..b14a911b3 100644 --- a/src/features/vcs/branchOps.ts +++ b/src/features/vcs/branchOps.ts @@ -198,9 +198,8 @@ export async function openConflictsInstead(): Promise { return true; } -export async function mergeIntoCurrent(branch: string) { - const repo = currentRepo(); - const current = useVcs.getState().branch?.head ?? "HEAD"; +export async function mergeIntoCurrent(branch: string, repo: string | null = currentRepo()) { + const current = (repo === currentRepo() ? useVcs.getState().branch?.head : null) ?? "HEAD"; await saveAll(); await runOp(`Merge ${branch} into ${current}`, () => call(commands.vcsMerge(repo, branch)), { success: `Merged ${branch} into ${current}`,