From 480d4d03fda934893522800f17f14e065fe0a446 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 29 Jun 2026 20:24:24 -0700 Subject: [PATCH] FN-7254: add worktree commit history targets Allow Git Manager users to inspect commits and diffs from registered worktrees without changing mutation targets. - Add read-only worktreePath targeting to Git commit list and commit diff APIs with registered-worktree validation. - Add a Commits history target selector and Worktrees "View commits" shortcuts that clear stale diff state when targets change. - Document the read-only scope and cover API, UI, responsive layout, and mutation-target invariants with tests. Files changed: .changeset/fn-7254-git-manager-worktree-commits.md | 7 + .changeset/fn-7254-worktree-commit-target-ui.md | 7 + .changeset/fn-7254-worktree-commits.md | 7 + docs/dashboard-guide.md | 6 +- packages/dashboard/app/api/legacy.ts | 15 +- .../dashboard/app/components/GitManagerModal.tsx | 133 ++++++++++++++-- packages/dashboard/app/components/ScriptsModal.css | 113 ++++++++++++++ .../components/__tests__/GitManagerModal.test.tsx | 167 +++++++++++++++++++++ .../dashboard/src/__tests__/routes-git.test.ts | 34 +++++ .../dashboard/src/routes/register-git-github.ts | 31 +++- 10 files changed, 497 insertions(+), 23 deletions(-) Fusion-Task-Id: FN-7254 Fusion-Task-Lineage: 37314175-fef4-4f8d-8268-cf27fee09857 Co-authored-by: Fusion (runfusion.ai) --- .../fn-7254-git-manager-worktree-commits.md | 7 + .../fn-7254-worktree-commit-target-ui.md | 7 + .changeset/fn-7254-worktree-commits.md | 7 + docs/dashboard-guide.md | 6 +- packages/dashboard/app/api/legacy.ts | 15 +- .../app/components/GitManagerModal.tsx | 133 ++++++++++++-- .../dashboard/app/components/ScriptsModal.css | 113 ++++++++++++ .../__tests__/GitManagerModal.test.tsx | 167 ++++++++++++++++++ .../src/__tests__/routes-git.test.ts | 34 ++++ .../src/routes/register-git-github.ts | 31 +++- 10 files changed, 497 insertions(+), 23 deletions(-) create mode 100644 .changeset/fn-7254-git-manager-worktree-commits.md create mode 100644 .changeset/fn-7254-worktree-commit-target-ui.md create mode 100644 .changeset/fn-7254-worktree-commits.md diff --git a/.changeset/fn-7254-git-manager-worktree-commits.md b/.changeset/fn-7254-git-manager-worktree-commits.md new file mode 100644 index 0000000000..eaeb183c30 --- /dev/null +++ b/.changeset/fn-7254-git-manager-worktree-commits.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Let Git Manager inspect commit history from known worktrees. +category: feature +dev: Adds read-only Commits history targeting for Git-listed worktrees; mutating actions remain scoped to the current repository target. diff --git a/.changeset/fn-7254-worktree-commit-target-ui.md b/.changeset/fn-7254-worktree-commit-target-ui.md new file mode 100644 index 0000000000..c752acf961 --- /dev/null +++ b/.changeset/fn-7254-worktree-commit-target-ui.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Let Git Manager jump from worktrees to their read-only commit history. +category: feature +dev: Adds Git Manager worktree commit-target UI and responsive styling. diff --git a/.changeset/fn-7254-worktree-commits.md b/.changeset/fn-7254-worktree-commits.md new file mode 100644 index 0000000000..35c4ca0ba7 --- /dev/null +++ b/.changeset/fn-7254-worktree-commits.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Let Git Manager inspect commit history from known git worktrees. +category: feature +dev: Adds read-only worktreePath targeting for commit list and diff endpoints. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index fc64c0da8d..bccbbdf6f8 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -522,10 +522,12 @@ Use Git Manager: 4. Select **Status**, **Changes**, **Commits**, **Branches**, **Worktrees**, **Stashes**, **Recovery**, or **Remotes**. Expected outcome: the corresponding section panel replaces the previous section while preserving the same Git Manager session. + Features: - Branch/worktree visibility -- Commit and diff browsing +- Commit and diff browsing, including a read-only **History target** selector for Git-reported worktrees in the Commits panel and **View commits** shortcuts from populated Worktrees rows. Changing this target affects only the Commits list and diff viewer, and the API accepts only worktrees already reported by `git worktree list` for the current repository target. - Push/pull/fetch actions - Pull with rebase option (split-button chooses between `git pull` and `git pull --rebase`) - One-click **Sync** action in Remotes (`git pull --rebase` followed by push; it stops and surfaces an error instead of pushing when the pull conflicts or fails) @@ -534,6 +536,8 @@ Features: - **Recovery** tab for orphaned merger-autostashes; orphan counts appear on Git Manager entry points - Remotes tab keeps "Recent commits on {remote}" in sync immediately after successful push/pull actions +Mutating actions such as staging, committing, checkout, stash, pull, push, fetch, sync, and remote edits still operate on the current repository or the active section's existing target. Use the Commits **History target** selector only for read-only history/diff inspection of another known worktree. + ![Git Manager](./screenshots/git-manager.png) ## Merge Advance Notice diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index fdf81e7ff3..ec51ebdd1a 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -3100,15 +3100,22 @@ export function fetchGitStatus(projectId?: string, opts?: { extended?: boolean } return api(`${base}${sep}extended=1`); } +/** Append the read-only commit worktree target query param used only by commit list/diff endpoints. */ +function withCommitWorktreePath(path: string, worktreePath?: string): string { + if (!worktreePath) return path; + const separator = path.includes("?") ? "&" : "?"; + return `${path}${separator}worktreePath=${encodeURIComponent(worktreePath)}`; +} + /** Fetch recent commits */ -export function fetchGitCommits(limit?: number, projectId?: string, repoPath?: string): Promise { +export function fetchGitCommits(limit?: number, projectId?: string, repoPath?: string, worktreePath?: string): Promise { const query = limit ? `?limit=${limit}` : ""; - return api(withRepoPath(withProjectId(`/git/commits${query}`, projectId), repoPath)); + return api(withCommitWorktreePath(withRepoPath(withProjectId(`/git/commits${query}`, projectId), repoPath), worktreePath)); } /** Fetch diff for a specific commit */ -export function fetchCommitDiff(hash: string, projectId?: string, repoPath?: string): Promise<{ stat: string; patch: string }> { - return api<{ stat: string; patch: string }>(withRepoPath(withProjectId(`/git/commits/${hash}/diff`, projectId), repoPath)); +export function fetchCommitDiff(hash: string, projectId?: string, repoPath?: string, worktreePath?: string): Promise<{ stat: string; patch: string }> { + return api<{ stat: string; patch: string }>(withCommitWorktreePath(withRepoPath(withProjectId(`/git/commits/${hash}/diff`, projectId), repoPath), worktreePath)); } /** Fetch local commits ahead of the upstream tracking branch (commits to push) */ diff --git a/packages/dashboard/app/components/GitManagerModal.tsx b/packages/dashboard/app/components/GitManagerModal.tsx index 1771c5a851..6ba26484e4 100644 --- a/packages/dashboard/app/components/GitManagerModal.tsx +++ b/packages/dashboard/app/components/GitManagerModal.tsx @@ -314,6 +314,14 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj const [loadingDiff, setLoadingDiff] = useState(false); const [commitsLimit, setCommitsLimit] = useState(20); const [commitSearch, setCommitSearch] = useState(""); + /* + FNXC:GitManager 2026-06-29-00:00: + The workspace repository selector remains the mutation/status target for Git Manager. The Commits panel has a separate read-only target: null means the currently selected repo checkout, and a path means one Git-reported worktree from fetchGitWorktrees; switching it must clear expanded commit/diff state before the new history loads. + + FNXC:GitManager 2026-06-29-20:05: + The Worktrees panel can jump to read-only commit history for a listed worktree. Keep that affordance wired to the same commit target state so it never broadens staging/checkout/stash/push/pull mutations beyond the selected repository. + */ + const [commitWorktreePath, setCommitWorktreePath] = useState(null); // ── Branches state const [branches, setBranches] = useState([]); @@ -346,6 +354,12 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj // ── Data Fetching ─────────────────────────────────────────────── + const resetCommitInspectionState = useCallback(() => { + setSelectedCommit(null); + setCommitDiff(null); + setLoadingDiff(false); + }, []); + const fetchSectionData = useCallback(async () => { if (!isOpen) return; setLoading(true); @@ -369,8 +383,16 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj break; } case "commits": { - const commitsData = await fetchGitCommits(commitsLimit, projectId, gitRepoPath); + const [commitsData, worktreesData] = await Promise.all([ + fetchGitCommits(commitsLimit, projectId, gitRepoPath, commitWorktreePath ?? undefined), + fetchGitWorktrees(projectId, gitRepoPath), + ]); setCommits(commitsData); + setWorktrees(worktreesData); + if (commitWorktreePath && !worktreesData.some((worktree) => worktree.path === commitWorktreePath)) { + setCommitWorktreePath(null); + resetCommitInspectionState(); + } break; } case "branches": { @@ -431,7 +453,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj } finally { setLoading(false); } - }, [activeSection, isOpen, commitsLimit, addToast, projectId, gitRepoPath]); + }, [activeSection, isOpen, commitsLimit, addToast, projectId, gitRepoPath, commitWorktreePath, resetCommitInspectionState]); useEffect(() => { if (isOpen) { @@ -613,9 +635,10 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj return; } setSelectedCommit(hash); + setCommitDiff(null); setLoadingDiff(true); try { - const diff = await fetchCommitDiff(hash, projectId, gitRepoPath); + const diff = await fetchCommitDiff(hash, projectId, gitRepoPath, commitWorktreePath ?? undefined); setCommitDiff(diff); } catch (err) { addToast(getErrorMessage(err) || t("git.failedToLoadDiff", "Failed to load diff"), "error"); @@ -623,7 +646,13 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj } finally { setLoadingDiff(false); } - }, [selectedCommit, addToast, projectId]); + }, [selectedCommit, addToast, t, projectId, gitRepoPath, commitWorktreePath]); + + const handleCommitTargetChange = useCallback((nextPath: string | null) => { + resetCommitInspectionState(); + setCommits([]); + setCommitWorktreePath(nextPath); + }, [resetCommitInspectionState]); const handleLoadMoreCommits = useCallback(() => { setCommitsLimit((prev) => Math.min(prev + 20, 100)); @@ -1060,6 +1089,7 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj value={selectedRepo ?? ""} onChange={(e) => { setSelectedRepo(e.target.value || null); + handleCommitTargetChange(null); }} title={t("git.selectRepo", "Select repository")} aria-label={t("git.selectRepo", "Select repository")} @@ -1178,6 +1208,9 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj selectedCommit={selectedCommit} commitDiff={commitDiff} loadingDiff={loadingDiff} + worktrees={worktrees} + commitWorktreePath={commitWorktreePath} + onCommitTargetChange={handleCommitTargetChange} onCommitClick={handleCommitClick} onLoadMore={handleLoadMoreCommits} canLoadMore={commits.length >= commitsLimit && commitsLimit < 100} @@ -1214,7 +1247,13 @@ export function GitManagerModal({ isOpen, onClose, tasks: _tasks, addToast, proj {/* ── Worktrees Panel ── */} {activeSection === "worktrees" && !loading && ( - + { + handleCommitTargetChange(path); + setActiveSection("commits"); + }} + /> )} {/* ── Stashes Panel ── */} @@ -1998,6 +2037,9 @@ function CommitsPanel({ selectedCommit, commitDiff, loadingDiff, + worktrees, + commitWorktreePath, + onCommitTargetChange, onCommitClick, onLoadMore, canLoadMore, @@ -2009,24 +2051,65 @@ function CommitsPanel({ selectedCommit: string | null; commitDiff: { stat: string; patch: string } | null; loadingDiff: boolean; + worktrees: GitWorktree[]; + commitWorktreePath: string | null; + onCommitTargetChange: (path: string | null) => void; onCommitClick: (hash: string) => void; onLoadMore: () => void; canLoadMore: boolean; copyToClipboard: (text: string, label?: string) => void; }) { const { t } = useTranslation("app"); + const commitTargetWorktrees = useMemo(() => { + const seen = new Set(); + return worktrees.filter((worktree) => { + if (!worktree.path || seen.has(worktree.path)) return false; + seen.add(worktree.path); + return true; + }); + }, [worktrees]); + const formatWorktreeTargetLabel = (worktree: GitWorktree) => { + const branchLabel = worktree.isMain ? t("git.worktreeTargetMain", "Main") : (worktree.branch ?? t("git.detached", "Detached")); + const taskLabel = worktree.taskId ? ` — ${worktree.taskId}` : ""; + return `${branchLabel}${taskLabel} — ${worktree.path}`; + }; + const selectedTargetTitle = commitWorktreePath + ? commitTargetWorktrees.find((worktree) => worktree.path === commitWorktreePath)?.path ?? commitWorktreePath + : t("git.currentCheckout", "Current checkout"); return (
-
+

{t("git.sectionCommits", "Commits")}

-
- - setCommitSearch(e.target.value)} - /> +
+ {commitTargetWorktrees.length > 0 && ( + + )} +
+ + setCommitSearch(e.target.value)} + /> +
@@ -2327,7 +2410,7 @@ function BranchesPanel({ } /** Worktrees panel */ -function WorktreesPanel({ worktrees }: { worktrees: GitWorktree[] }) { +function WorktreesPanel({ worktrees, onViewCommits }: { worktrees: GitWorktree[]; onViewCommits: (path: string) => void }) { const { t } = useTranslation("app"); return (
@@ -2340,7 +2423,9 @@ function WorktreesPanel({ worktrees }: { worktrees: GitWorktree[] }) {
- {worktrees.map((worktree) => ( + {worktrees.length === 0 ? ( +
{t("git.noWorktreesFound", "No worktrees found")}
+ ) : worktrees.map((worktree) => (
+
+ +
))}
diff --git a/packages/dashboard/app/components/ScriptsModal.css b/packages/dashboard/app/components/ScriptsModal.css index 52aaf0c317..375a3d816c 100644 --- a/packages/dashboard/app/components/ScriptsModal.css +++ b/packages/dashboard/app/components/ScriptsModal.css @@ -2292,10 +2292,37 @@ The previous bespoke rules here hid the tab labels (icon-only) and used a crampe padding: var(--space-md); } + .gm-modal--embedded .gm-commits-header, + .gm-modal--embedded .gm-commit-controls { + align-items: stretch; + flex-direction: column; + } + + .gm-modal--embedded .gm-commit-target, + .gm-modal--embedded .gm-commit-target select, + .gm-modal--embedded .gm-commit-controls .gm-search-box { + width: 100%; + } + + .gm-modal--embedded .gm-commit-target select { + max-width: none; + font-size: 16px; + } + .gm-modal--embedded .gm-search-box input { font-size: 16px; } + .gm-modal--embedded .gm-worktree-item { + align-items: stretch; + flex-direction: column; + } + + .gm-modal--embedded .gm-worktree-actions, + .gm-modal--embedded .gm-worktree-actions .btn { + width: 100%; + } + .gm-modal--embedded .gm-status-grid { grid-template-columns: 1fr; } @@ -3311,6 +3338,42 @@ Refresh button pinned at the end of the section nav strip (replaces the removed /* ── Commits Panel ── */ +.gm-commits-header { + align-items: flex-start; +} + +.gm-commit-controls { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-sm); + flex-wrap: wrap; +} + +.gm-commit-target { + display: flex; + align-items: center; + gap: var(--space-xs); + min-width: 0; + padding: var(--space-xs) var(--space-sm); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--card); + color: var(--text-muted); + font-size: 12px; +} + +.gm-commit-target select { + min-width: 160px; + max-width: 260px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + font-size: 12px; + padding: calc(var(--space-xs) / 2) var(--space-xs); +} + .gm-commits-list { display: flex; flex-direction: column; @@ -3635,6 +3698,11 @@ Refresh button pinned at the end of the section nav strip (replaces the removed } .gm-worktree-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + min-width: 0; padding: var(--space-sm) var(--space-md); background: var(--card); border: 1px solid var(--border); @@ -3652,6 +3720,8 @@ Refresh button pinned at the end of the section nav strip (replaces the removed .gm-worktree-info { display: flex; + flex: 1 1 auto; + min-width: 0; flex-direction: column; gap: var(--space-xs); } @@ -3691,6 +3761,22 @@ Refresh button pinned at the end of the section nav strip (replaces the removed color: var(--todo); } +/* +FNXC:GitManager 2026-06-29-20:05: +Worktree commit-history jumps are read-only navigation affordances. Keep the actions responsive and content-sized so empty worktree states do not leave orphaned button shells and narrow right-dock/mobile layouts do not overflow horizontally. +*/ +.gm-worktree-actions { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: flex-end; + min-width: 0; +} + +.gm-worktree-actions .btn { + white-space: nowrap; +} + .gm-badge { font-size: 10px; font-weight: 700; @@ -4530,6 +4616,33 @@ Refresh button pinned at the end of the section nav strip (replaces the removed padding: var(--space-md); } + .gm-commits-header, + .gm-commit-controls { + align-items: stretch; + flex-direction: column; + } + + .gm-commit-target, + .gm-commit-target select, + .gm-commit-controls .gm-search-box { + width: 100%; + } + + .gm-commit-target select { + max-width: none; + font-size: 16px; + } + + .gm-worktree-item { + align-items: stretch; + flex-direction: column; + } + + .gm-worktree-actions, + .gm-worktree-actions .btn { + width: 100%; + } + .gm-search-box input { font-size: 16px; } diff --git a/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx b/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx index aba6522748..46f9dd64e3 100644 --- a/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx @@ -956,6 +956,153 @@ describe("GitManagerModal", () => { }); }); + it("targets current checkout commits by default and registered worktree commits when selected", async () => { + const user = userEvent.setup(); + (fetchGitCommits as any).mockImplementation(async (_limit: number, _projectId?: string, _repoPath?: string, worktreePath?: string) => [ + { + hash: worktreePath ? "def5678" : "abc1234", + shortHash: worktreePath ? "def5678" : "abc1234", + message: worktreePath ? "Worktree commit" : "Current checkout commit", + author: "User", + date: "2026-01-01T00:00:00Z", + parents: [], + }, + ]); + + render( + + ); + fireEvent.click(screen.getByRole("tab", { name: /commits/i })); + + await waitFor(() => expect(screen.getByText("Current checkout commit")).toBeInTheDocument()); + expectLatestCallStartsWith(fetchGitCommits as any, 20, undefined, undefined); + expect((fetchGitCommits as any).mock.calls.at(-1)?.[3]).toBeUndefined(); + + await user.selectOptions(screen.getByLabelText("Select commit history target"), "/worktrees/kb-001"); + + await waitFor(() => expect(screen.getByText("Worktree commit")).toBeInTheDocument()); + expectLatestCallStartsWith(fetchGitCommits as any, 20, undefined, undefined, "/worktrees/kb-001"); + + fireEvent.click(screen.getByText("Worktree commit")); + await waitFor(() => expect(fetchCommitDiff).toHaveBeenCalledWith("def5678", undefined, undefined, "/worktrees/kb-001")); + }); + + it("labels duplicate basename worktree commit targets with branch task and full path", async () => { + (fetchGitWorktrees as any).mockResolvedValue([ + { path: "/tmp/a/kb", branch: "fusion/fn-111", isMain: false, isBare: false, taskId: "FN-111" }, + { path: "/tmp/b/kb", branch: "fusion/fn-222", isMain: false, isBare: false, taskId: "FN-222" }, + ]); + + render( + + ); + fireEvent.click(screen.getByRole("tab", { name: /commits/i })); + + const select = await screen.findByLabelText("Select commit history target"); + const optionLabels = within(select).getAllByRole("option").map((option) => option.textContent); + expect(optionLabels).toContain("fusion/fn-111 — FN-111 — /tmp/a/kb"); + expect(optionLabels).toContain("fusion/fn-222 — FN-222 — /tmp/b/kb"); + }); + + it("switches from populated worktrees to their commit history without empty action shells", async () => { + const user = userEvent.setup(); + + render( + + ); + fireEvent.click(screen.getByRole("tab", { name: /worktrees/i })); + + const worktreesPanel = await screen.findByTestId("worktrees-panel"); + expect(within(worktreesPanel).getAllByRole("button", { name: /view commits/i })).toHaveLength(2); + await user.click(within(worktreesPanel).getByRole("button", { name: /View commits for fusion\/fn-001 FN-001 \/worktrees\/kb-001/i })); + + await screen.findByTestId("commits-panel"); + expectLatestCallStartsWith(fetchGitCommits as any, 20, undefined, undefined, "/worktrees/kb-001"); + }); + + it("renders empty worktrees without orphaned View commits buttons", async () => { + (fetchGitWorktrees as any).mockResolvedValue([]); + + render( + + ); + fireEvent.click(screen.getByRole("tab", { name: /worktrees/i })); + + const worktreesPanel = await screen.findByTestId("worktrees-panel"); + expect(within(worktreesPanel).getByText("No worktrees found")).toBeInTheDocument(); + expect(within(worktreesPanel).queryByRole("button", { name: /view commits/i })).not.toBeInTheDocument(); + }); + + it("clears expanded commit diff state before fetching a new worktree target", async () => { + const user = userEvent.setup(); + (fetchGitCommits as any).mockResolvedValue([ + { hash: "abc1234", shortHash: "abc1234", message: "Current checkout commit", author: "User", date: "2026-01-01T00:00:00Z", parents: [], body: "Current body" }, + ]); + + render( + + ); + fireEvent.click(screen.getByRole("tab", { name: /commits/i })); + await waitFor(() => expect(screen.getByText("Current checkout commit")).toBeInTheDocument()); + + fireEvent.click(screen.getByText("Current checkout commit")); + await waitFor(() => expect(screen.getByText(/diff --git/)).toBeInTheDocument()); + + await user.selectOptions(screen.getByLabelText("Select commit history target"), "/worktrees/kb-001"); + expect(screen.queryByText(/diff --git/)).not.toBeInTheDocument(); + }); + + it("omits empty worktree selector shells but dedupes populated commit targets in embedded mobile layout", async () => { + const user = userEvent.setup(); + (fetchGitWorktrees as any).mockResolvedValueOnce([]).mockResolvedValueOnce([ + { path: "/worktrees/kb-001", branch: "fusion/fn-001", isMain: false, isBare: false, taskId: "FN-001" }, + { path: "/worktrees/kb-001", branch: "fusion/fn-001", isMain: false, isBare: false, taskId: "FN-001" }, + { path: "/repo", branch: "main", isMain: true, isBare: false }, + ]); + + const { rerender } = render( + + ); + fireEvent.click(screen.getByRole("tab", { name: /commits/i })); + await waitFor(() => expect(fetchGitCommits).toHaveBeenCalled()); + expect(screen.queryByLabelText("Select commit history target")).not.toBeInTheDocument(); + + mockUseViewportMode.mockReturnValue("mobile"); + rerender(); + rerender(); + fireEvent.click(screen.getByRole("tab", { name: /commits/i })); + + const select = await screen.findByLabelText("Select commit history target"); + expect(screen.getByTestId("commits-panel").closest(".gm-modal--embedded")).toBeTruthy(); + expect(within(select).getAllByRole("option")).toHaveLength(3); + expect(within(select).getByRole("option", { name: "fusion/fn-001 — FN-001 — /worktrees/kb-001" })).toBeInTheDocument(); + expect(within(select).getByRole("option", { name: "Main — /repo" })).toBeInTheDocument(); + await user.selectOptions(select, "/repo"); + expectLatestCallStartsWith(fetchGitCommits as any, 20, undefined, undefined, "/repo"); + }); + + it("keeps mutation routes scoped to the selected repository after changing the commits worktree target", async () => { + const user = userEvent.setup(); + (fetchWorkspaceRepos as any).mockResolvedValue({ repos: ["packages/app"] }); + + render( + + ); + fireEvent.click(screen.getByRole("tab", { name: /commits/i })); + await screen.findByLabelText("Select commit history target"); + await waitFor(() => expectLatestCallStartsWith(fetchGitCommits as any, 20, "proj-1", "packages/app")); + await user.selectOptions(screen.getByLabelText("Select commit history target"), "/worktrees/kb-001"); + + fireEvent.click(screen.getByRole("tab", { name: /changes/i })); + await waitFor(() => expect(screen.getByText("src/app.ts")).toBeInTheDocument()); + await user.click(screen.getByRole("button", { name: "Stage file" })); + + await waitFor(() => expect(stageFiles).toHaveBeenCalled()); + expectLatestCallStartsWith(stageFiles as any, ["src/app.ts"], "proj-1"); + expect((stageFiles as any).mock.calls.at(-1)).toHaveLength(3); + expect((stageFiles as any).mock.calls.at(-1)?.[2]).not.toBe("/worktrees/kb-001"); + }); + it("does not render full message block for commits without body", async () => { (fetchGitCommits as any).mockResolvedValue([ { @@ -3496,6 +3643,26 @@ describe("GitManagerModal", () => { expect(css).toMatch(/@media[^{]*\(max-width: 768px\)[^{]*\{[\s\S]*?\.gm-file-section\s*\{[\s\S]*?max-width:\s*100%;/); }); + it("includes commit target and worktree actions in modal mobile and embedded narrow layouts", () => { + const css = loadAllAppCss(); + const mobile768 = getMediaBlocks(css, /@media[^{]*\(max-width:\s*768px\)[^{]*\{/g).join("\n"); + const embeddedNarrow = getMediaBlocks(css, /@container\s+gm-embedded\s+\(max-width:\s*560px\)\s*\{/g).join("\n"); + + expect(css).toContain(".gm-commit-target"); + expect(css).toContain(".gm-worktree-actions"); + expect(mobile768).toContain(".gm-commit-target select"); + expect(mobile768).toContain(".gm-worktree-actions .btn"); + expect(embeddedNarrow).toContain(".gm-modal--embedded .gm-commit-target select"); + expect(embeddedNarrow).toContain(".gm-modal--embedded .gm-worktree-actions .btn"); + + const mobileTargetRules = getRuleBlocks(mobile768, ".gm-commit-target,\n .gm-commit-target select,\n .gm-commit-controls .gm-search-box"); + expect(mobileTargetRules).toHaveLength(1); + expect(mobileTargetRules[0]).toContain("width: 100%;"); + const embeddedTargetRules = getRuleBlocks(embeddedNarrow, ".gm-modal--embedded .gm-commit-target,\n .gm-modal--embedded .gm-commit-target select,\n .gm-modal--embedded .gm-commit-controls .gm-search-box"); + expect(embeddedTargetRules).toHaveLength(1); + expect(embeddedTargetRules[0]).toContain("width: 100%;"); + }); + it("keeps the mobile Git Manager tab strip non-shrinking at 768px and 720px breakpoints", () => { const css = loadAllAppCss(); const mobile768 = getMediaBlocks(css, /@media[^{]*\(max-width:\s*768px\)[^{]*\{/g).join("\n"); diff --git a/packages/dashboard/src/__tests__/routes-git.test.ts b/packages/dashboard/src/__tests__/routes-git.test.ts index d05acf901a..a7faa3f3bf 100644 --- a/packages/dashboard/src/__tests__/routes-git.test.ts +++ b/packages/dashboard/src/__tests__/routes-git.test.ts @@ -438,6 +438,23 @@ describe("Git Management endpoints", () => { expect(Array.isArray(res.body)).toBe(true); expect(res.body.length).toBeLessThanOrEqual(100); }); + + it("allows read-only commit history for a registered worktree outside repoPath and rejects unregistered absolute paths", async () => { + const worktreePath = join(getSharedGitTestRepo().root, "registered-worktree"); + execFileSync("git", ["-C", gitRepoDir, "worktree", "add", "-B", "fn-7254-worktree", worktreePath, "HEAD"], { stdio: "pipe" }); + try { + const res = await GET(buildApp(), `/api/git/commits?worktreePath=${encodeURIComponent(worktreePath)}&limit=1`); + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBeLessThanOrEqual(1); + + const rejected = await GET(buildApp(), `/api/git/commits?worktreePath=${encodeURIComponent(join(getSharedGitTestRepo().root, "not-registered"))}`); + expect(rejected.status).toBe(400); + expect(rejected.body.error).toContain("registered git worktree"); + } finally { + execFileSync("git", ["-C", gitRepoDir, "worktree", "remove", "--force", worktreePath], { stdio: "pipe" }); + } + }); }); describe("GET /git/commits/:hash/diff", () => { @@ -468,6 +485,23 @@ describe("Git Management endpoints", () => { expect(res.body).toHaveProperty("patch"); } }); + + it("allows read-only commit diffs for a registered worktree and rejects unregistered paths", async () => { + const worktreePath = join(getSharedGitTestRepo().root, "registered-diff-worktree"); + execFileSync("git", ["-C", gitRepoDir, "worktree", "add", "-B", "fn-7254-diff-worktree", worktreePath, "HEAD"], { stdio: "pipe" }); + try { + const headHash = git(worktreePath, "rev-parse", "HEAD"); + const res = await GET(buildApp(), `/api/git/commits/${headHash}/diff?worktreePath=${encodeURIComponent(worktreePath)}`); + expect(res.status).toBe(200); + expect(res.body).toHaveProperty("patch"); + + const rejected = await GET(buildApp(), `/api/git/commits/${headHash}/diff?worktreePath=${encodeURIComponent(join(getSharedGitTestRepo().root, "not-registered-diff"))}`); + expect(rejected.status).toBe(400); + expect(rejected.body.error).toContain("registered git worktree"); + } finally { + execFileSync("git", ["-C", gitRepoDir, "worktree", "remove", "--force", worktreePath], { stdio: "pipe" }); + } + }); }); describe("GET /git/stashes/:index/diff", () => { diff --git a/packages/dashboard/src/routes/register-git-github.ts b/packages/dashboard/src/routes/register-git-github.ts index bef58e373b..db56cd5e0e 100644 --- a/packages/dashboard/src/routes/register-git-github.ts +++ b/packages/dashboard/src/routes/register-git-github.ts @@ -2515,6 +2515,33 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { return projectRoot; } + async function resolveReadOnlyCommitGitDir(req: Request, projectRoot: string): Promise { + const baseGitDir = resolveGitDir(req, projectRoot); + const worktreePath = req.query.worktreePath; + if (worktreePath === undefined || worktreePath === null || worktreePath === "") { + return baseGitDir; + } + if (typeof worktreePath !== "string" || !isAbsolute(worktreePath)) { + throw badRequest("worktreePath must be an absolute path"); + } + const resolved = resolve(worktreePath); + if (resolved !== worktreePath) { + throw badRequest("worktreePath must be normalized"); + } + + /* + FNXC:GitManager 2026-06-29-00:00: + The Commits panel may inspect commit history and diffs for a Git-reported worktree of the currently selected repository checkout, but mutation routes must continue to target only resolveGitDir(repoPath). Validate this read-only override against `git worktree list` for the current repo instead of treating `repoPath` or `worktreePath` as arbitrary absolute filesystem access. + */ + const registeredWorktrees = await listRegisteredWorktreePaths(baseGitDir); + const canonicalResolved = canonicalForCompare(resolved); + const registered = registeredWorktrees.find((candidate) => canonicalForCompare(candidate) === canonicalResolved); + if (!registered) { + throw badRequest("worktreePath is not a registered git worktree for this repository"); + } + return registered; + } + /** * GET /api/git/workspace-repos * Returns the list of sub-repos for a workspace-mode project. @@ -2942,7 +2969,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { router.get("/git/commits", async (req, res) => { try { const { store: scopedStore } = await getProjectContext(req); - const rootDir = resolveGitDir(req, scopedStore.getRootDir()); + const rootDir = await resolveReadOnlyCommitGitDir(req, scopedStore.getRootDir()); if (!(await isGitRepo(rootDir))) { throw badRequest("Not a git repository"); } @@ -2965,7 +2992,7 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void { router.get("/git/commits/:hash/diff", async (req, res) => { try { const { store: scopedStore } = await getProjectContext(req); - const rootDir = resolveGitDir(req, scopedStore.getRootDir()); + const rootDir = await resolveReadOnlyCommitGitDir(req, scopedStore.getRootDir()); if (!(await isGitRepo(rootDir))) { throw badRequest("Not a git repository"); }