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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-29 20:24:24 -07:00
parent 45dd8082b3
commit 480d4d03fd
10 changed files with 497 additions and 23 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.
<!-- FNXC:GitManagerDocs 2026-06-29-00:00: The Commits panel may read history from Git-listed worktrees, but mutating Git actions must remain scoped to the current repository/section target so history inspection does not imply cross-worktree writes.
FNXC:GitManagerDocs 2026-06-30-03:15: The worktree history target is a security-bounded read surface: only Git-reported worktrees from the current repository target are valid, and arbitrary absolute filesystem paths must stay rejected by the API. -->
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

View File

@@ -3100,15 +3100,22 @@ export function fetchGitStatus(projectId?: string, opts?: { extended?: boolean }
return api<GitStatus>(`${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<GitCommit[]> {
export function fetchGitCommits(limit?: number, projectId?: string, repoPath?: string, worktreePath?: string): Promise<GitCommit[]> {
const query = limit ? `?limit=${limit}` : "";
return api<GitCommit[]>(withRepoPath(withProjectId(`/git/commits${query}`, projectId), repoPath));
return api<GitCommit[]>(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) */

View File

@@ -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<string | null>(null);
// ── Branches state
const [branches, setBranches] = useState<GitBranch[]>([]);
@@ -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 && (
<WorktreesPanel worktrees={worktrees} />
<WorktreesPanel
worktrees={worktrees}
onViewCommits={(path) => {
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<string>();
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 (
<div className="gm-panel" data-testid="commits-panel">
<div className="gm-panel-header">
<div className="gm-panel-header gm-commits-header">
<h4>{t("git.sectionCommits", "Commits")}</h4>
<div className="gm-search-box">
<Search size={14} />
<input
type="text"
placeholder={t("git.searchCommits", "Search commits...")}
value={commitSearch}
onChange={(e) => setCommitSearch(e.target.value)}
/>
<div className="gm-commit-controls">
{commitTargetWorktrees.length > 0 && (
<label className="gm-commit-target" htmlFor="gm-commit-target-select">
<FolderGit2 size={14} />
<span>{t("git.commitTarget", "History target")}</span>
<select
id="gm-commit-target-select"
value={commitWorktreePath ?? ""}
onChange={(event) => onCommitTargetChange(event.target.value || null)}
aria-label={t("git.selectCommitHistoryTarget", "Select commit history target")}
title={selectedTargetTitle}
>
<option value="">{t("git.currentCheckout", "Current checkout")}</option>
{commitTargetWorktrees.map((worktree) => (
<option key={worktree.path} value={worktree.path} title={worktree.path}>
{formatWorktreeTargetLabel(worktree)}
</option>
))}
</select>
</label>
)}
<div className="gm-search-box">
<Search size={14} />
<input
type="text"
placeholder={t("git.searchCommits", "Search commits...")}
value={commitSearch}
onChange={(e) => setCommitSearch(e.target.value)}
/>
</div>
</div>
</div>
<div className="gm-commits-list">
@@ -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 (
<div className="gm-panel" data-testid="worktrees-panel">
@@ -2340,7 +2423,9 @@ function WorktreesPanel({ worktrees }: { worktrees: GitWorktree[] }) {
</div>
</div>
<div className="gm-worktrees-list">
{worktrees.map((worktree) => (
{worktrees.length === 0 ? (
<div className="gm-empty">{t("git.noWorktreesFound", "No worktrees found")}</div>
) : worktrees.map((worktree) => (
<div
key={worktree.path}
className={`gm-worktree-item${worktree.isMain ? " main" : ""}`}
@@ -2365,6 +2450,22 @@ function WorktreesPanel({ worktrees }: { worktrees: GitWorktree[] }) {
)}
</div>
</div>
<div className="gm-worktree-actions">
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => onViewCommits(worktree.path)}
title={t("git.viewWorktreeCommitsTitle", "View commits for {{path}}", { path: worktree.path })}
aria-label={t("git.viewWorktreeCommitsLabel", "View commits for {{branch}} {{task}} {{path}}", {
branch: worktree.branch ?? (worktree.isMain ? t("git.worktreeTargetMain", "Main") : t("git.detached", "Detached")),
task: worktree.taskId ?? "",
path: worktree.path,
})}
>
<GitCommitIcon size={14} />
{t("git.viewCommits", "View commits")}
</button>
</div>
</div>
))}
</div>

View File

@@ -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;
}

View File

@@ -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(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
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(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
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(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
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(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
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(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
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(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
);
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(<GitManagerModal isOpen={false} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} presentation="embedded" />);
rerender(<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} presentation="embedded" />);
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(
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} projectId="proj-1" />
);
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");

View File

@@ -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", () => {

View File

@@ -2515,6 +2515,33 @@ export function registerGitGitHubRoutes(ctx: ApiRoutesContext): void {
return projectRoot;
}
async function resolveReadOnlyCommitGitDir(req: Request, projectRoot: string): Promise<string> {
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");
}