feat(FN-803): add ahead/behind commit lists to Git Manager Remotes tab
- Add backend API routes for remote commit lists (local-ahead, remote-ahead, all commits by branch) - Surface ahead/behind commit counts and expandable commit lists in Remotes tab UI - Add expand/collapse rows showing commit hash, message, author, and relative date - Style commit list rows with alternating backgrounds and monospace hashes - Add comprehensive tests for API routes and GitManagerModal component - Update README documentation for Git Manager Remotes tab
This commit is contained in:
@@ -379,6 +379,7 @@ Built-in Git repository visualization and management:
|
||||
- Manage branches
|
||||
- See worktree/task associations
|
||||
- Perform fetch/pull/push operations
|
||||
- View pending-push commits and inspect recent commit history per remote
|
||||
|
||||
### Activity Log
|
||||
|
||||
|
||||
@@ -193,11 +193,14 @@ The Git Manager provides comprehensive repository visualization and management d
|
||||
- Identify main vs linked worktrees
|
||||
- Track free/used worktree count
|
||||
|
||||
**Remotes Tab**: Perform remote operations:
|
||||
**Remotes Tab**: Perform remote operations with commit visibility:
|
||||
- Fetch from origin
|
||||
- Pull latest changes
|
||||
- Push current branch
|
||||
- View operation results and error states
|
||||
- **Commits to Push**: See which local commits are ahead of the upstream tracking branch (pending push) with short hash, message, author, and relative date
|
||||
- **Remote Commit Inspection**: Click any remote to view its recent commit history — useful for checking what's on a remote without switching to the terminal
|
||||
- Auto-selects the first remote and loads its recent commits on mount
|
||||
|
||||
### File Browser
|
||||
Browse and edit task worktree files directly from the task detail modal:
|
||||
@@ -403,6 +406,8 @@ The dashboard server exposes a REST API at `/api`:
|
||||
|
||||
### GitHub Integration
|
||||
- `GET /api/git/remotes` - List GitHub remotes
|
||||
- `GET /api/git/commits/ahead` - List local commits ahead of upstream tracking branch (commits pending push)
|
||||
- `GET /api/git/remotes/:name/commits?ref=&limit=` - Recent commits for a remote tracking ref (default: remote's HEAD branch, limit: 10, max: 50)
|
||||
- `POST /api/github/issues/fetch` - Fetch issues (`{ owner, repo, limit?, labels? }`)
|
||||
- `POST /api/github/issues/import` - Import issue (`{ owner, repo, issueNumber }`)
|
||||
- `POST /api/github/webhooks` - GitHub App webhook endpoint for badge updates (see GitHub App Setup below)
|
||||
|
||||
@@ -909,6 +909,8 @@ import {
|
||||
fetchGitStatus,
|
||||
fetchGitCommits,
|
||||
fetchCommitDiff,
|
||||
fetchAheadCommits,
|
||||
fetchRemoteCommits,
|
||||
fetchGitBranches,
|
||||
fetchGitWorktrees,
|
||||
createBranch,
|
||||
@@ -994,6 +996,66 @@ describe("Git Management API", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchAheadCommits", () => {
|
||||
it("returns commits ahead of upstream", async () => {
|
||||
const commits = [
|
||||
{ hash: "abc123", shortHash: "abc", message: "Fix bug", author: "User", date: "2026-01-01", parents: [] },
|
||||
];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, commits));
|
||||
|
||||
const result = await fetchAheadCommits();
|
||||
|
||||
expect(result).toEqual(commits);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/commits/ahead", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty array when no upstream", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
const result = await fetchAheadCommits();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchRemoteCommits", () => {
|
||||
it("fetches commits for a remote with default params", async () => {
|
||||
const commits = [
|
||||
{ hash: "def456", shortHash: "def", message: "Remote commit", author: "User", date: "2026-01-01", parents: [] },
|
||||
];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, commits));
|
||||
|
||||
const result = await fetchRemoteCommits("origin");
|
||||
|
||||
expect(result).toEqual(commits);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/remotes/origin/commits", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
it("includes ref and limit in query", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
await fetchRemoteCommits("origin", "main", 5);
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/remotes/origin/commits?ref=main&limit=5", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
it("encodes remote name in URL", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
await fetchRemoteCommits("my-remote");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/remotes/my-remote/commits", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGitBranches", () => {
|
||||
it("returns branches array", async () => {
|
||||
const branches = [{ name: "main", isCurrent: true, remote: "origin/main" }];
|
||||
|
||||
@@ -822,6 +822,20 @@ export function fetchCommitDiff(hash: string): Promise<{ stat: string; patch: st
|
||||
return api<{ stat: string; patch: string }>(`/git/commits/${hash}/diff`);
|
||||
}
|
||||
|
||||
/** Fetch local commits ahead of the upstream tracking branch (commits to push) */
|
||||
export function fetchAheadCommits(): Promise<GitCommit[]> {
|
||||
return api<GitCommit[]>("/git/commits/ahead");
|
||||
}
|
||||
|
||||
/** Fetch recent commits for a specific remote */
|
||||
export function fetchRemoteCommits(remote: string, ref?: string, limit?: number): Promise<GitCommit[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (ref) params.set("ref", ref);
|
||||
if (limit) params.set("limit", String(limit));
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return api<GitCommit[]>(`/git/remotes/${encodeURIComponent(remote)}/commits${query}`);
|
||||
}
|
||||
|
||||
/** Fetch all local branches */
|
||||
export function fetchGitBranches(): Promise<GitBranch[]> {
|
||||
return api<GitBranch[]>("/git/branches");
|
||||
|
||||
@@ -40,6 +40,8 @@ import {
|
||||
removeGitRemote,
|
||||
renameGitRemote,
|
||||
updateGitRemoteUrl,
|
||||
fetchAheadCommits,
|
||||
fetchRemoteCommits,
|
||||
} from "../api";
|
||||
import {
|
||||
GitBranch as GitBranchIcon,
|
||||
@@ -1498,11 +1500,46 @@ function RemotesPanel({
|
||||
const [editNameValue, setEditNameValue] = useState("");
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
|
||||
// Fetch remotes when panel mounts
|
||||
// Ahead commits (local commits to push)
|
||||
const [aheadCommits, setAheadCommits] = useState<GitCommit[]>([]);
|
||||
const [loadingAhead, setLoadingAhead] = useState(false);
|
||||
|
||||
// Selected remote and its recent commits
|
||||
const [selectedRemote, setSelectedRemote] = useState<string | null>(null);
|
||||
const [remoteCommits, setRemoteCommits] = useState<GitCommit[]>([]);
|
||||
const [loadingRemoteCommits, setLoadingRemoteCommits] = useState(false);
|
||||
const [remoteCommitsError, setRemoteCommitsError] = useState<string | null>(null);
|
||||
|
||||
// Fetch remotes and ahead commits when panel mounts
|
||||
useEffect(() => {
|
||||
loadRemotes();
|
||||
loadAheadCommits();
|
||||
}, []);
|
||||
|
||||
// Auto-select first remote when remotes load
|
||||
useEffect(() => {
|
||||
if (remotes.length > 0 && !selectedRemote) {
|
||||
setSelectedRemote(remotes[0].name);
|
||||
}
|
||||
}, [remotes]);
|
||||
|
||||
// Load commits for selected remote
|
||||
useEffect(() => {
|
||||
if (selectedRemote) {
|
||||
loadRemoteCommits(selectedRemote);
|
||||
} else {
|
||||
setRemoteCommits([]);
|
||||
setRemoteCommitsError(null);
|
||||
}
|
||||
}, [selectedRemote]);
|
||||
|
||||
// Clear selected remote if it was removed from the list
|
||||
useEffect(() => {
|
||||
if (selectedRemote && !remotes.find((r) => r.name === selectedRemote)) {
|
||||
setSelectedRemote(remotes.length > 0 ? remotes[0].name : null);
|
||||
}
|
||||
}, [remotes, selectedRemote]);
|
||||
|
||||
const loadRemotes = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -1515,6 +1552,33 @@ function RemotesPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const loadAheadCommits = async () => {
|
||||
setLoadingAhead(true);
|
||||
try {
|
||||
const commits = await fetchAheadCommits();
|
||||
setAheadCommits(commits);
|
||||
} catch {
|
||||
// Silently ignore — ahead commits are a nice-to-have
|
||||
setAheadCommits([]);
|
||||
} finally {
|
||||
setLoadingAhead(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadRemoteCommits = async (remoteName: string) => {
|
||||
setLoadingRemoteCommits(true);
|
||||
setRemoteCommitsError(null);
|
||||
try {
|
||||
const commits = await fetchRemoteCommits(remoteName, undefined, 10);
|
||||
setRemoteCommits(commits);
|
||||
} catch (err: any) {
|
||||
setRemoteCommitsError(err.message || "Failed to load remote commits");
|
||||
setRemoteCommits([]);
|
||||
} finally {
|
||||
setLoadingRemoteCommits(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRemote = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newRemoteName.trim() || !newRemoteUrl.trim()) return;
|
||||
@@ -1645,6 +1709,49 @@ function RemotesPanel({
|
||||
|
||||
{/* Remote Operations (Fetch/Pull/Push) */}
|
||||
<div className="gm-remote-operations">
|
||||
{/* Commits to Push */}
|
||||
{status && status.ahead > 0 && (
|
||||
<div className="gm-commits-to-push" data-testid="commits-to-push">
|
||||
<div className="gm-section-subheader">
|
||||
<h5>
|
||||
<ArrowUp size={14} />
|
||||
Commits to Push ({status.ahead})
|
||||
</h5>
|
||||
</div>
|
||||
{loadingAhead ? (
|
||||
<div className="gm-loading">
|
||||
<Loader2 size={14} className="spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : aheadCommits.length > 0 ? (
|
||||
<div className="gm-ahead-commits-list" data-testid="ahead-commits-list">
|
||||
{aheadCommits.map((commit) => (
|
||||
<div key={commit.hash} className="gm-commit-item-compact">
|
||||
<div className="gm-commit-compact-hash">
|
||||
<code className="gm-hash">{commit.shortHash}</code>
|
||||
</div>
|
||||
<div className="gm-commit-compact-info">
|
||||
<span className="gm-commit-message" title={commit.message}>
|
||||
{commit.message}
|
||||
</span>
|
||||
<span className="gm-commit-meta">
|
||||
<span>{commit.author}</span>
|
||||
<span>•</span>
|
||||
<span>{relativeDate(commit.date)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="gm-empty">
|
||||
No ahead commits found (may need to fetch first)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ahead/Behind indicators */}
|
||||
{status && (status.ahead > 0 || status.behind > 0) && (
|
||||
<div className="gm-remote-status">
|
||||
{status.ahead > 0 && (
|
||||
@@ -1713,7 +1820,13 @@ function RemotesPanel({
|
||||
<div className="gm-empty">No remotes configured</div>
|
||||
) : (
|
||||
remotes.map((remote) => (
|
||||
<div key={remote.name} className="gm-remote-item">
|
||||
<div
|
||||
key={remote.name}
|
||||
className={`gm-remote-item${selectedRemote === remote.name ? " selected" : ""}`}
|
||||
onClick={() => setSelectedRemote(remote.name)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="gm-remote-info">
|
||||
{editingRemote === `name-${remote.name}` ? (
|
||||
<div className="gm-remote-edit">
|
||||
@@ -1750,7 +1863,7 @@ function RemotesPanel({
|
||||
<span className="gm-remote-name">{remote.name}</span>
|
||||
<button
|
||||
className="btn btn-icon"
|
||||
onClick={() => startEditingName(remote)}
|
||||
onClick={(e) => { e.stopPropagation(); startEditingName(remote); }}
|
||||
disabled={remoteActionLoading !== null}
|
||||
title="Rename remote"
|
||||
>
|
||||
@@ -1804,7 +1917,7 @@ function RemotesPanel({
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-icon"
|
||||
onClick={() => startEditingUrl(remote)}
|
||||
onClick={(e) => { e.stopPropagation(); startEditingUrl(remote); }}
|
||||
disabled={remoteActionLoading !== null}
|
||||
title="Edit URL"
|
||||
>
|
||||
@@ -1818,7 +1931,7 @@ function RemotesPanel({
|
||||
<div className="gm-remote-actions-inline">
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleRemoveRemote(remote.name)}
|
||||
onClick={(e) => { e.stopPropagation(); handleRemoveRemote(remote.name); }}
|
||||
disabled={remoteActionLoading !== null}
|
||||
title="Remove remote"
|
||||
>
|
||||
@@ -1834,6 +1947,53 @@ function RemotesPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected Remote Commits */}
|
||||
{selectedRemote && (
|
||||
<div className="gm-remote-commits-section" data-testid="remote-commits-section">
|
||||
<div className="gm-section-subheader">
|
||||
<h5>
|
||||
<Radio size={14} />
|
||||
Recent commits on {selectedRemote}
|
||||
</h5>
|
||||
</div>
|
||||
{loadingRemoteCommits ? (
|
||||
<div className="gm-loading">
|
||||
<Loader2 size={14} className="spin" />
|
||||
Loading commits...
|
||||
</div>
|
||||
) : remoteCommitsError ? (
|
||||
<div className="gm-error">
|
||||
<AlertCircle size={14} />
|
||||
{remoteCommitsError}
|
||||
</div>
|
||||
) : remoteCommits.length === 0 ? (
|
||||
<div className="gm-empty">
|
||||
No commits found on {selectedRemote}. Try fetching first.
|
||||
</div>
|
||||
) : (
|
||||
<div className="gm-remote-commits-list" data-testid="remote-commits-list">
|
||||
{remoteCommits.map((commit) => (
|
||||
<div key={commit.hash} className="gm-commit-item-compact">
|
||||
<div className="gm-commit-compact-hash">
|
||||
<code className="gm-hash">{commit.shortHash}</code>
|
||||
</div>
|
||||
<div className="gm-commit-compact-info">
|
||||
<span className="gm-commit-message" title={commit.message}>
|
||||
{commit.message}
|
||||
</span>
|
||||
<span className="gm-commit-meta">
|
||||
<span>{commit.author}</span>
|
||||
<span>•</span>
|
||||
<span>{relativeDate(commit.date)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lastRemoteResult && (
|
||||
<div className="gm-remote-result">
|
||||
{lastRemoteResult.message}
|
||||
|
||||
@@ -33,6 +33,8 @@ vi.mock("../../api", async () => {
|
||||
removeGitRemote: vi.fn(),
|
||||
renameGitRemote: vi.fn(),
|
||||
updateGitRemoteUrl: vi.fn(),
|
||||
fetchAheadCommits: vi.fn(),
|
||||
fetchRemoteCommits: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -63,6 +65,8 @@ import {
|
||||
removeGitRemote,
|
||||
renameGitRemote,
|
||||
updateGitRemoteUrl,
|
||||
fetchAheadCommits,
|
||||
fetchRemoteCommits,
|
||||
} from "../../api";
|
||||
|
||||
const mockAddToast = vi.fn();
|
||||
@@ -169,6 +173,8 @@ describe("GitManagerModal", () => {
|
||||
(removeGitRemote as any).mockResolvedValue(undefined);
|
||||
(renameGitRemote as any).mockResolvedValue(undefined);
|
||||
(updateGitRemoteUrl as any).mockResolvedValue(undefined);
|
||||
(fetchAheadCommits as any).mockResolvedValue([]);
|
||||
(fetchRemoteCommits as any).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
// ── Basic Rendering ─────────────────────────────────────────
|
||||
@@ -1086,6 +1092,152 @@ describe("GitManagerModal", () => {
|
||||
expect(screen.getByText("Loading...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Commits to Push Section ───────────────────────────────────
|
||||
|
||||
it("shows commits to push section when ahead > 0", async () => {
|
||||
(fetchGitStatus as any).mockResolvedValue({
|
||||
branch: "main",
|
||||
commit: "abc1234",
|
||||
isDirty: false,
|
||||
ahead: 2,
|
||||
behind: 0,
|
||||
});
|
||||
(fetchAheadCommits as any).mockResolvedValue([
|
||||
{ hash: "aaa1111", shortHash: "aaa1", message: "First ahead commit", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
|
||||
{ hash: "bbb2222", shortHash: "bbb2", message: "Second ahead commit", author: "Dev", date: "2026-01-02T00:00:00Z", parents: [] },
|
||||
]);
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("commits-to-push")).toBeInTheDocument();
|
||||
expect(screen.getByText("First ahead commit")).toBeInTheDocument();
|
||||
expect(screen.getByText("Second ahead commit")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows empty state when ahead > 0 but no ahead commits returned", async () => {
|
||||
(fetchGitStatus as any).mockResolvedValue({
|
||||
branch: "main",
|
||||
commit: "abc1234",
|
||||
isDirty: false,
|
||||
ahead: 1,
|
||||
behind: 0,
|
||||
});
|
||||
(fetchAheadCommits as any).mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("commits-to-push")).toBeInTheDocument();
|
||||
expect(screen.getByText(/No ahead commits found/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show commits to push when ahead === 0", async () => {
|
||||
(fetchGitStatus as any).mockResolvedValue({
|
||||
branch: "main",
|
||||
commit: "abc1234",
|
||||
isDirty: false,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
});
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("origin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("commits-to-push")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Remote Selection & Recent Commits ──────────────────────────
|
||||
|
||||
it("shows recent commits section for auto-selected remote", async () => {
|
||||
(fetchRemoteCommits as any).mockResolvedValue([
|
||||
{ hash: "rc1", shortHash: "rc1", message: "Remote commit 1", author: "Dev", date: "2026-01-01T00:00:00Z", parents: [] },
|
||||
]);
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("remote-commits-section")).toBeInTheDocument();
|
||||
expect(screen.getByText("Remote commit 1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows empty state when remote has no commits", async () => {
|
||||
(fetchRemoteCommits as any).mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No commits found on origin/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error state when remote commits fetch fails", async () => {
|
||||
(fetchRemoteCommits as any).mockRejectedValue(new Error("Network failure"));
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Network failure")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show remote commits section when no remotes configured", async () => {
|
||||
(fetchGitRemotesDetailed as any).mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No remotes configured")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("remote-commits-section")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights selected remote in the list", async () => {
|
||||
(fetchGitRemotesDetailed as any).mockResolvedValue([
|
||||
{ name: "origin", fetchUrl: "https://github.com/a/b.git", pushUrl: "https://github.com/a/b.git" },
|
||||
{ name: "upstream", fetchUrl: "https://github.com/c/d.git", pushUrl: "https://github.com/c/d.git" },
|
||||
]);
|
||||
|
||||
render(
|
||||
<GitManagerModal isOpen={true} onClose={vi.fn()} tasks={mockTasks} addToast={mockAddToast} />
|
||||
);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /remotes/i }));
|
||||
|
||||
// First remote (origin) should be auto-selected
|
||||
await waitFor(() => {
|
||||
const originItem = screen.getByText("origin").closest(".gm-remote-item");
|
||||
expect(originItem?.classList.contains("selected")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Refresh Button ─────────────────────────────────────────
|
||||
|
||||
it("refreshes data when refresh button is clicked", async () => {
|
||||
|
||||
@@ -13445,6 +13445,111 @@ html .column.drag-over * {
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
|
||||
.gm-remote-item.selected {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary-bg, color-mix(in srgb, var(--primary) 8%, transparent));
|
||||
}
|
||||
|
||||
.gm-commits-to-push {
|
||||
padding: var(--space-sm) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.gm-commits-to-push h5 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gm-commits-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.gm-commits-empty {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
padding: var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.gm-commit-item-compact {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.gm-commit-item-compact:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.gm-commit-compact-hash {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
padding-top: 1px;
|
||||
}
|
||||
|
||||
.gm-commit-compact-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.gm-commit-compact-info .gm-commit-message {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.gm-commit-compact-info .gm-commit-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.gm-remote-commits-section {
|
||||
margin-top: var(--space-md);
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: var(--space-md);
|
||||
}
|
||||
|
||||
.gm-remote-commits-section .gm-section-header {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.gm-remote-commits-section h5 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0 0 var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.gm-remote-commits-list {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
|
||||
@@ -3874,6 +3874,116 @@ describe("Git Management endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /git/commits/ahead", () => {
|
||||
it("returns commits ahead of upstream", async () => {
|
||||
const res = await GET(buildApp(), "/api/git/commits/ahead");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
// Each commit should have the standard GitCommit shape
|
||||
for (const commit of res.body) {
|
||||
expect(commit).toHaveProperty("hash");
|
||||
expect(commit).toHaveProperty("shortHash");
|
||||
expect(commit).toHaveProperty("message");
|
||||
expect(commit).toHaveProperty("author");
|
||||
expect(commit).toHaveProperty("date");
|
||||
expect(commit).toHaveProperty("parents");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns empty array when no upstream is configured", async () => {
|
||||
// In a worktree without upstream tracking, this should return []
|
||||
const res = await GET(buildApp(), "/api/git/commits/ahead");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 400 when not a git repository", async () => {
|
||||
const nonGitStore = createMockStore({
|
||||
getRootDir: vi.fn().mockReturnValue("/tmp/nonexistent-git-dir-for-test"),
|
||||
});
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(nonGitStore));
|
||||
|
||||
const res = await GET(app, "/api/git/commits/ahead");
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Not a git repository");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /git/remotes/:name/commits", () => {
|
||||
it("returns commits for a valid remote", async () => {
|
||||
// First, get remotes to find a valid name
|
||||
const remotesRes = await GET(buildApp(), "/api/git/remotes/detailed");
|
||||
if (remotesRes.status === 200 && remotesRes.body.length > 0) {
|
||||
const remoteName = remotesRes.body[0].name;
|
||||
const res = await GET(buildApp(), `/api/git/remotes/${remoteName}/commits`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
for (const commit of res.body) {
|
||||
expect(commit).toHaveProperty("hash");
|
||||
expect(commit).toHaveProperty("shortHash");
|
||||
expect(commit).toHaveProperty("message");
|
||||
expect(commit).toHaveProperty("author");
|
||||
expect(commit).toHaveProperty("date");
|
||||
expect(commit).toHaveProperty("parents");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 400 for invalid remote name", async () => {
|
||||
const res = await GET(buildApp(), "/api/git/remotes/invalid;rm%20-rf%20/commits");
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Invalid remote name");
|
||||
});
|
||||
|
||||
it("returns 400 for invalid ref parameter", async () => {
|
||||
const res = await GET(buildApp(), "/api/git/remotes/origin/commits?ref=main;rm%20-rf");
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Invalid ref name");
|
||||
});
|
||||
|
||||
it("respects limit parameter", async () => {
|
||||
const remotesRes = await GET(buildApp(), "/api/git/remotes/detailed");
|
||||
if (remotesRes.status === 200 && remotesRes.body.length > 0) {
|
||||
const remoteName = remotesRes.body[0].name;
|
||||
const res = await GET(buildApp(), `/api/git/remotes/${remoteName}/commits?limit=3`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
expect(res.body.length).toBeLessThanOrEqual(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns empty array for non-existent remote", async () => {
|
||||
const res = await GET(buildApp(), "/api/git/remotes/nonexistent-remote-xyz/commits");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
expect(res.body).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns 400 when not a git repository", async () => {
|
||||
const nonGitStore = createMockStore({
|
||||
getRootDir: vi.fn().mockReturnValue("/tmp/nonexistent-git-dir-for-test"),
|
||||
});
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(nonGitStore));
|
||||
|
||||
const res = await GET(app, "/api/git/remotes/origin/commits");
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Not a git repository");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /git/branches", () => {
|
||||
it("returns branches array", async () => {
|
||||
const res = await GET(buildApp(), "/api/git/branches");
|
||||
|
||||
@@ -366,6 +366,123 @@ function getGitCommits(limit: number = 20, cwd?: string): GitCommit[] {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a git ref name to prevent command injection.
|
||||
* Refs include branch names, remote tracking branches (remote/branch), and tags.
|
||||
* Must not contain shell metacharacters or start with dashes.
|
||||
*/
|
||||
function isValidGitRef(ref: string): boolean {
|
||||
if (!ref || ref.length === 0) return false;
|
||||
if (ref.startsWith("-")) return false;
|
||||
if (/[;<>&|`$(){}[\]\r\n]/.test(ref)) return false;
|
||||
if (/\s/.test(ref)) return false;
|
||||
// Allow slashes for remote/branch format, dots, hyphens, underscores, alphanumerics
|
||||
if (!/^[a-zA-Z0-9/_.@-]+$/.test(ref)) return false;
|
||||
if (ref.includes("..")) return false;
|
||||
if (ref.includes("~")) return false;
|
||||
if (ref.includes("^")) return false;
|
||||
if (ref.includes(":")) return false;
|
||||
// Must not look like an option
|
||||
if (ref.startsWith("--")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get commits ahead of the upstream tracking branch (commits that would be pushed).
|
||||
* Returns the list of local commits not yet present on the upstream.
|
||||
* Returns an empty array if there is no upstream configured.
|
||||
*/
|
||||
function getAheadCommits(cwd?: string): GitCommit[] {
|
||||
try {
|
||||
const execOptions = { encoding: "utf-8" as const, timeout: 10000, cwd };
|
||||
// Check if an upstream is configured
|
||||
try {
|
||||
execSync("git rev-parse --abbrev-ref @{u}", execOptions);
|
||||
} catch {
|
||||
// No upstream configured
|
||||
return [];
|
||||
}
|
||||
|
||||
// Format: hash|shortHash|message|author|date|parents
|
||||
const format = "%H|%h|%s|%an|%aI|%P";
|
||||
const output = execSync(`git log @{u}..HEAD --pretty=format:"${format}"`, execOptions);
|
||||
|
||||
const commits: GitCommit[] = [];
|
||||
for (const line of output.split("\n")) {
|
||||
const parts = line.split("|");
|
||||
if (parts.length < 5) continue;
|
||||
|
||||
const [hash, shortHash, message, author, date, parentsStr] = parts;
|
||||
const parents = parentsStr ? parentsStr.split(" ").filter(Boolean) : [];
|
||||
|
||||
commits.push({
|
||||
hash,
|
||||
shortHash,
|
||||
message: message || "",
|
||||
author: author || "",
|
||||
date: date || "",
|
||||
parents,
|
||||
});
|
||||
}
|
||||
|
||||
return commits;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent commits reachable from a remote tracking ref.
|
||||
* @param remoteRef The remote ref (e.g. "origin/main") to list commits for
|
||||
* @param limit Maximum number of commits to return (default 10)
|
||||
*/
|
||||
function getRemoteCommits(remoteRef: string, limit: number = 10, cwd?: string): GitCommit[] {
|
||||
try {
|
||||
if (!isValidGitRef(remoteRef)) {
|
||||
throw new Error("Invalid remote ref");
|
||||
}
|
||||
|
||||
// Verify the ref exists
|
||||
const execOptions = { encoding: "utf-8" as const, timeout: 5000, cwd };
|
||||
try {
|
||||
execSync(`git rev-parse --verify "${remoteRef}"`, execOptions);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Format: hash|shortHash|message|author|date|parents
|
||||
const format = "%H|%h|%s|%an|%aI|%P";
|
||||
const safeLimit = Math.min(Math.max(1, limit), 50);
|
||||
const output = execSync(`git log --max-count=${safeLimit} --pretty=format:"${format}" "${remoteRef}"`, {
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
cwd,
|
||||
});
|
||||
|
||||
const commits: GitCommit[] = [];
|
||||
for (const line of output.split("\n")) {
|
||||
const parts = line.split("|");
|
||||
if (parts.length < 5) continue;
|
||||
|
||||
const [hash, shortHash, message, author, date, parentsStr] = parts;
|
||||
const parents = parentsStr ? parentsStr.split(" ").filter(Boolean) : [];
|
||||
|
||||
commits.push({
|
||||
hash,
|
||||
shortHash,
|
||||
message: message || "",
|
||||
author: author || "",
|
||||
date: date || "",
|
||||
parents,
|
||||
});
|
||||
}
|
||||
|
||||
return commits;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the diff for a specific commit.
|
||||
* @param hash The commit hash
|
||||
@@ -2555,6 +2672,107 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/git/commits/ahead
|
||||
* Returns local commits ahead of the upstream tracking branch (commits that would be pushed).
|
||||
* Response: Array of GitCommit objects (empty when no upstream is configured)
|
||||
*/
|
||||
router.get("/git/commits/ahead", (_req, res) => {
|
||||
try {
|
||||
const rootDir = store.getRootDir();
|
||||
if (!isGitRepo(rootDir)) {
|
||||
res.status(400).json({ error: "Not a git repository" });
|
||||
return;
|
||||
}
|
||||
const commits = getAheadCommits(rootDir);
|
||||
res.json(commits);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/git/remotes/:name/commits
|
||||
* Returns recent commits for a specific remote tracking ref.
|
||||
* Query: ?ref=branchName (defaults to HEAD of the remote's default branch)
|
||||
* Query: ?limit=N (defaults to 10, max 50)
|
||||
* Response: Array of GitCommit objects
|
||||
*/
|
||||
router.get("/git/remotes/:name/commits", (req, res) => {
|
||||
try {
|
||||
const rootDir = store.getRootDir();
|
||||
if (!isGitRepo(rootDir)) {
|
||||
res.status(400).json({ error: "Not a git repository" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { name } = req.params;
|
||||
if (!isValidBranchName(name)) {
|
||||
res.status(400).json({ error: "Invalid remote name" });
|
||||
return;
|
||||
}
|
||||
|
||||
const ref = req.query.ref as string | undefined;
|
||||
const limit = Math.min(parseInt(req.query.limit as string, 10) || 10, 50);
|
||||
|
||||
// Build the full remote ref: if ref is given, use "remote/ref", otherwise use "remote/HEAD"
|
||||
let remoteRef: string;
|
||||
if (ref) {
|
||||
if (!isValidGitRef(ref)) {
|
||||
res.status(400).json({ error: "Invalid ref name" });
|
||||
return;
|
||||
}
|
||||
// Strip any leading "refs/" or remote prefix the user might accidentally include
|
||||
const cleanRef = ref.replace(/^refs\/(heads\/)?/, "");
|
||||
// If the ref already starts with the remote name, use it as-is
|
||||
if (cleanRef.startsWith(`${name}/`)) {
|
||||
remoteRef = cleanRef;
|
||||
} else {
|
||||
remoteRef = `${name}/${cleanRef}`;
|
||||
}
|
||||
} else {
|
||||
// Default: try remote/HEAD symbolic ref, fall back to remote/main, remote/master
|
||||
try {
|
||||
const headRef = execSync(`git symbolic-ref refs/remotes/${name}/HEAD`, {
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
cwd: rootDir,
|
||||
}).trim();
|
||||
// symbolic-ref returns full ref like refs/remotes/origin/main
|
||||
remoteRef = headRef.replace(/^refs\/remotes\//, "");
|
||||
} catch {
|
||||
// Try common defaults
|
||||
try {
|
||||
execSync(`git rev-parse --verify "${name}/main"`, {
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
cwd: rootDir,
|
||||
});
|
||||
remoteRef = `${name}/main`;
|
||||
} catch {
|
||||
try {
|
||||
execSync(`git rev-parse --verify "${name}/master"`, {
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
cwd: rootDir,
|
||||
});
|
||||
remoteRef = `${name}/master`;
|
||||
} catch {
|
||||
// Remote exists but no common branch found
|
||||
res.json([]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const commits = getRemoteCommits(remoteRef, limit, rootDir);
|
||||
res.json(commits);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/git/branches
|
||||
* Returns all local branches with current indicator, remote tracking info, and last commit date.
|
||||
|
||||
Reference in New Issue
Block a user