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:
gsxdsm
2026-04-03 22:02:57 -07:00
parent f1f98ba718
commit acfeea4fd3
9 changed files with 833 additions and 6 deletions

View File

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

View File

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

View File

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

View File

@@ -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");

View File

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

View File

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

View File

@@ -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) {

View File

@@ -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");

View File

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