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:
7
.changeset/fn-7254-git-manager-worktree-commits.md
Normal file
7
.changeset/fn-7254-git-manager-worktree-commits.md
Normal 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.
|
||||
7
.changeset/fn-7254-worktree-commit-target-ui.md
Normal file
7
.changeset/fn-7254-worktree-commit-target-ui.md
Normal 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.
|
||||
7
.changeset/fn-7254-worktree-commits.md
Normal file
7
.changeset/fn-7254-worktree-commits.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## Merge Advance Notice
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user