diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index c8bdcec7b..894872072 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -3514,7 +3514,9 @@ export const Dashboard: React.FC = ({ setPromptHistoryFocusId(null); setShowPromptHistory(true); }} - onOpenStash={() => setShowStashModal(true)} + onOpenStash={ + isRemoteActive ? undefined : () => setShowStashModal(true) + } onCreateShellTerminal={() => terminalPaneRef.current?.createShellSession() } diff --git a/src/components/changes-diff-viewer/FileSidebar.tsx b/src/components/changes-diff-viewer/FileSidebar.tsx index 91a5b0a7e..1d66447dc 100644 --- a/src/components/changes-diff-viewer/FileSidebar.tsx +++ b/src/components/changes-diff-viewer/FileSidebar.tsx @@ -2,6 +2,8 @@ import React, { useState } from "react"; import { CheckCircle2, Loader2 } from "lucide-react"; import type { JjFileChange } from "../../lib/api"; import type { ParsedFileChange } from "../../lib/git-utils"; +import { isRemoteRepository } from "../../lib/active-repository"; +import { useActiveRepository } from "../../lib/active-repository-context"; import { setChangeFilesDragData } from "../../lib/change-file-drag"; import { AlertDialog, @@ -117,6 +119,7 @@ export function FileSidebar({ sourceBranch, }: FileSidebarProps) { const [showDiscardAllDialog, setShowDiscardAllDialog] = useState(false); + const isRemote = isRemoteRepository(useActiveRepository()); const handleFileDragStart = (path: string, event: React.DragEvent) => { const files = @@ -215,7 +218,7 @@ export function FileSidebar({ ); }} onDiscardAll={() => setShowDiscardAllDialog(true)} - onStashAll={handleStashAll} + onStashAll={isRemote ? undefined : handleStashAll} onDiscard={handleDiscardFiles} onDeselectAll={() => setSelectedUnstagedFiles(new Set())} onSelectAll={handleSelectAllUnstaged} diff --git a/src/lib/api-extra.ts b/src/lib/api-extra.ts index 4201cb89b..39d22ed6b 100644 --- a/src/lib/api-extra.ts +++ b/src/lib/api-extra.ts @@ -450,40 +450,62 @@ export const getWorkspaceStartingPrompt = ( }); // Stash API — immutable local gist storage for working-copy change sets -export const stashWorkspaceChanges = ( +const STASH = "Stashing"; + +export const stashWorkspaceChanges = async ( repoPath: string, workspaceId: number | null, -): Promise => - invoke("stash_workspace_changes", { repoPath, workspaceId }); +): Promise => { + assertLocalOperation(repoPath, STASH); + return invoke("stash_workspace_changes", { repoPath, workspaceId }); +}; -export const stashCommit = ( +export const stashCommit = async ( repoPath: string, workspaceId: number | null, changeId: string, -): Promise => - invoke("stash_commit", { repoPath, workspaceId, changeId }); +): Promise => { + assertLocalOperation(repoPath, STASH); + return invoke("stash_commit", { repoPath, workspaceId, changeId }); +}; -export const listStashes = (repoPath: string): Promise => - invoke("list_stashes", { repoPath }); +export const listStashes = async (repoPath: string): Promise => { + assertLocalOperation(repoPath, STASH); + return invoke("list_stashes", { repoPath }); +}; -export const deleteStash = (repoPath: string, stashId: number): Promise => - invoke("delete_stash", { repoPath, stashId }); +export const deleteStash = async ( + repoPath: string, + stashId: number, +): Promise => { + assertLocalOperation(repoPath, STASH); + return invoke("delete_stash", { repoPath, stashId }); +}; -export const applyStash = ( +export const applyStash = async ( repoPath: string, stashId: number, targetBranch: string, -): Promise => invoke("apply_stash", { repoPath, stashId, targetBranch }); +): Promise => { + assertLocalOperation(repoPath, STASH); + return invoke("apply_stash", { repoPath, stashId, targetBranch }); +}; -export const getStashDiff = ( +export const getStashDiff = async ( repoPath: string, stashId: number, -): Promise => invoke("get_stash_diff", { repoPath, stashId }); +): Promise => { + assertLocalOperation(repoPath, STASH); + return invoke("get_stash_diff", { repoPath, stashId }); +}; -export const exportStashGitPatch = ( +export const exportStashGitPatch = async ( repoPath: string, stashId: number, -): Promise => invoke("export_stash_git_patch", { repoPath, stashId }); +): Promise => { + assertLocalOperation(repoPath, STASH); + return invoke("export_stash_git_patch", { repoPath, stashId }); +}; export const markFileViewed = ( workspacePath: string, diff --git a/src/lib/api.ts b/src/lib/api.ts index a61822c08..21995f784 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -52,6 +52,7 @@ import { transportListWorkspaceStatuses, } from "./repository-adapter"; import { + transportCheckBranchExists, transportCreateWorkspace, transportDeleteWorkspace, transportMoveWorkspaceChanges, @@ -442,10 +443,12 @@ export const checkBranchExists = ( repoPath: string, branchName: string, ): Promise => - invoke("jj_check_branch_exists", { - repoPath, - branchName, - }); + transportCheckBranchExists(repoPath, branchName, () => + invoke("jj_check_branch_exists", { + repoPath, + branchName, + }), + ); export const getCommitDiff = ( repoPath: string, diff --git a/src/lib/remote-path-guards.test.ts b/src/lib/remote-path-guards.test.ts index 57a67002d..bdf6a051b 100644 --- a/src/lib/remote-path-guards.test.ts +++ b/src/lib/remote-path-guards.test.ts @@ -23,9 +23,6 @@ const NOT_REPOSITORY_PATHS = new Set(["cleanupAgentCliFiles"]); // Local commands that still receive a remote path. Each entry is a gap to // close: guard it, route it, or scope it, then remove it from this list. const KNOWN_UNGUARDED: string[] = [ - "apply_stash", - "delete_stash", - "export_stash_git_patch", "get_cached_pr_ci_status", "get_cached_pr_info", "get_git_remote_url", @@ -33,19 +30,16 @@ const KNOWN_UNGUARDED: string[] = [ "get_pr_checks_via_gh", "get_pr_info_via_gh", "get_repo_logs", - "get_stash_diff", "get_workspace_setup_status", "github_open_or_create_workspace_from_issue", "init_repo", "is_repo_trusted", - "jj_check_branch_exists", "list_cached_pr_ci_statuses", "list_cached_pr_statuses", "list_gitignored_path_suggestions", "list_installed_skills", "list_send_artifacts", "list_skill_catalog", - "list_stashes", "list_workflow_runs", "list_workflows", "load_repo_yaml_config", @@ -59,8 +53,6 @@ const KNOWN_UNGUARDED: string[] = [ "set_git_submodule_synced", "set_window_repo_path", "start_pr_status_polling", - "stash_commit", - "stash_workspace_changes", "stop_pr_status_polling", "trust_repo", "write_send_review_image", diff --git a/src/lib/repository-adapter-mutations.ts b/src/lib/repository-adapter-mutations.ts index 69c6cc671..671b7fcb3 100644 --- a/src/lib/repository-adapter-mutations.ts +++ b/src/lib/repository-adapter-mutations.ts @@ -6,6 +6,7 @@ /* eslint-disable max-params -- each wrapper mirrors its local invoke arity plus the fallback */ import type { + BranchStatus, JjRebaseResult, RenameWorkspaceResult, ResolveCommitResult, @@ -18,6 +19,7 @@ import { remoteMutation, RemoteOperationUnsupportedError, resolveRemoteLocation, + transportListRepoBranches, workspaceArg, } from "./repository-adapter"; import type { ActiveRepository } from "./active-repository"; @@ -371,3 +373,18 @@ export async function transportGetCommitDescription( } return match.description; } + +/** Whether a branch exists, from the typed `ListBranches` read. Remote-tracking refs are not reported yet. */ +export async function transportCheckBranchExists( + repoPath: string, + branchName: string, + local: () => Promise, +): Promise { + if (!activeForPath(repoPath)) return local(); + const branches = await transportListRepoBranches<{ name: string }[]>( + repoPath, + async () => [], + ); + const exists = branches.some(({ name }) => name === branchName); + return { local_exists: exists, remote_exists: false }; +} diff --git a/src/lib/repository-adapter.test.ts b/src/lib/repository-adapter.test.ts index 66254e501..0292ffb70 100644 --- a/src/lib/repository-adapter.test.ts +++ b/src/lib/repository-adapter.test.ts @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { applyAgentReviewSuggestion, + checkBranchExists, createCommit, createWorkspace, getCommitDiff, @@ -170,6 +171,16 @@ describe("remote repository reads", () => { expect(repoStateScope("/home/me/project")).toBe("/home/me/project"); }); + it("checks a remote branch through the typed ListBranches read", async () => { + vi.mocked(dispatch).mockResolvedValue([{ name: "feat/a" }]); + await expect(checkBranchExists(ROOT, "feat/a")).resolves.toEqual({ + local_exists: true, + remote_exists: false, + }); + expect(sentReads()).toEqual([{ kind: "ListBranches", repo: ROOT }]); + expect(invoke).not.toHaveBeenCalled(); + }); + it("reads default repo settings and scopes agent review comments", async () => { vi.mocked(invoke).mockResolvedValue([]); await expect(getRepoSetting(ROOT, "default_agent")).resolves.toBeNull();