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}`,