feat(KB-030): add Git Manager dashboard feature
- Add GitManagerModal component with status, commits, branches, and worktrees tabs - Implement git API endpoints for status, commits, diff, branches, worktrees, and actions - Add git API client functions with error handling - Integrate Git Manager into App and Header with toolbar button - Add comprehensive tests for GitManagerModal component - Include README documentation for dashboard package and changeset
This commit is contained in:
115
packages/dashboard/README.md
Normal file
115
packages/dashboard/README.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# @kb/dashboard
|
||||
|
||||
Web-based dashboard for managing kb tasks. Provides a visual kanban board, list view, and git repository management tools.
|
||||
|
||||
## Features
|
||||
|
||||
### Task Management
|
||||
- **Kanban Board**: Drag-and-drop task management across columns (Triage, Todo, In Progress, In Review, Done)
|
||||
- **List View**: Alternative tabular view for tasks with sorting and filtering
|
||||
- **Task Details**: View full task specifications, agent logs, and attachments
|
||||
- **GitHub Import**: Import issues directly from GitHub repositories
|
||||
- **PR Management**: Create and track pull requests for in-review tasks
|
||||
|
||||
### Git Manager
|
||||
The Git Manager provides comprehensive repository visualization and management directly from the web UI. Access it via the Git Branch icon in the header.
|
||||
|
||||
**Status Tab**: View current repository state including:
|
||||
- Current branch name and commit hash
|
||||
- Working directory status (clean/dirty)
|
||||
- Ahead/behind counts relative to remote
|
||||
|
||||
**Commits Tab**: Browse recent commits with:
|
||||
- Commit list with message, author, and date
|
||||
- Expandable diff view for each commit
|
||||
- Pagination support (load more commits)
|
||||
|
||||
**Branches Tab**: Manage local branches:
|
||||
- List all branches with current indicator
|
||||
- Create new branches with optional base
|
||||
- Checkout existing branches
|
||||
- Delete branches (with confirmation)
|
||||
|
||||
**Worktrees Tab**: Visualize worktree layout:
|
||||
- List all worktrees with paths
|
||||
- See which tasks own which worktrees
|
||||
- Identify main vs linked worktrees
|
||||
- Track free/used worktree count
|
||||
|
||||
**Remotes Tab**: Perform remote operations:
|
||||
- Fetch from origin
|
||||
- Pull latest changes
|
||||
- Push current branch
|
||||
- View operation results and error states
|
||||
|
||||
### Configuration
|
||||
- **Settings Modal**: Configure scheduling, worktrees, build commands, merge preferences
|
||||
- **Authentication**: OAuth provider management for AI model access
|
||||
- **Pause Controls**: Soft pause (stop new work) and hard stop (kill all agents)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Run tests
|
||||
pnpm test
|
||||
|
||||
# Build for production
|
||||
pnpm build
|
||||
|
||||
# Start development server
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The dashboard server exposes a REST API at `/api`:
|
||||
|
||||
### Tasks
|
||||
- `GET /api/tasks` - List all tasks
|
||||
- `GET /api/tasks/:id` - Get task details
|
||||
- `POST /api/tasks` - Create new task
|
||||
- `PATCH /api/tasks/:id` - Update task
|
||||
- `POST /api/tasks/:id/move` - Move task to column
|
||||
- `POST /api/tasks/:id/pause` - Pause task
|
||||
- `POST /api/tasks/:id/unpause` - Unpause task
|
||||
- `DELETE /api/tasks/:id` - Delete task
|
||||
|
||||
### Git Operations
|
||||
- `GET /api/git/status` - Current branch and status
|
||||
- `GET /api/git/commits` - Recent commits (with optional `?limit=`)
|
||||
- `GET /api/git/commits/:hash/diff` - Commit diff
|
||||
- `GET /api/git/branches` - List branches
|
||||
- `GET /api/git/worktrees` - List worktrees with task associations
|
||||
- `POST /api/git/branches` - Create branch (`{ name, base? }`)
|
||||
- `POST /api/git/branches/:name/checkout` - Checkout branch
|
||||
- `DELETE /api/git/branches/:name` - Delete branch (`?force=true`)
|
||||
- `POST /api/git/fetch` - Fetch from remote (`{ remote? }`)
|
||||
- `POST /api/git/pull` - Pull current branch
|
||||
- `POST /api/git/push` - Push current branch
|
||||
|
||||
### GitHub Integration
|
||||
- `GET /api/git/remotes` - List GitHub remotes
|
||||
- `POST /api/github/issues/fetch` - Fetch issues (`{ owner, repo, limit?, labels? }`)
|
||||
- `POST /api/github/issues/import` - Import issue (`{ owner, repo, issueNumber }`)
|
||||
- `POST /api/tasks/:id/pr/create` - Create PR
|
||||
- `GET /api/tasks/:id/pr/status` - Get PR status
|
||||
- `POST /api/tasks/:id/pr/refresh` - Refresh PR status
|
||||
|
||||
### Configuration
|
||||
- `GET /api/config` - Server configuration
|
||||
- `GET /api/settings` - User settings
|
||||
- `PUT /api/settings` - Update settings
|
||||
- `GET /api/models` - Available AI models
|
||||
- `GET /api/auth/status` - OAuth provider status
|
||||
- `POST /api/auth/login` - Initiate OAuth login
|
||||
- `POST /api/auth/logout` - Logout from provider
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Frontend**: React + Vite, TypeScript, CSS custom properties for theming
|
||||
- **Backend**: Express server with REST API and Server-Sent Events (SSE) for live updates
|
||||
- **State Management**: Custom hooks with EventSource for real-time task updates
|
||||
- **Git Integration**: Server-side git command execution with validation
|
||||
@@ -9,6 +9,7 @@ import { SettingsModal } from "./components/SettingsModal";
|
||||
import type { SectionId } from "./components/SettingsModal";
|
||||
import { ToastContainer } from "./components/ToastContainer";
|
||||
import { GitHubImportModal } from "./components/GitHubImportModal";
|
||||
import { GitManagerModal } from "./components/GitManagerModal";
|
||||
import { useTasks } from "./hooks/useTasks";
|
||||
import { ToastProvider, useToast } from "./hooks/useToast";
|
||||
|
||||
@@ -17,6 +18,7 @@ function AppInner() {
|
||||
const [detailTask, setDetailTask] = useState<TaskDetail | null>(null);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [githubImportOpen, setGitHubImportOpen] = useState(false);
|
||||
const [gitManagerOpen, setGitManagerOpen] = useState(false);
|
||||
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId | undefined>(undefined);
|
||||
const [maxConcurrent, setMaxConcurrent] = useState(2);
|
||||
const [autoMerge, setAutoMerge] = useState(true);
|
||||
@@ -119,11 +121,20 @@ function AppInner() {
|
||||
addToast(`Imported ${task.id} from GitHub`, "success");
|
||||
}, [addToast]);
|
||||
|
||||
const handleOpenGitManager = useCallback(() => {
|
||||
setGitManagerOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCloseGitManager = useCallback(() => {
|
||||
setGitManagerOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
onOpenGitHubImport={() => setGitHubImportOpen(true)}
|
||||
onOpenGitManager={handleOpenGitManager}
|
||||
globalPaused={globalPaused}
|
||||
enginePaused={enginePaused}
|
||||
onToggleGlobalPause={handleToggleGlobalPause}
|
||||
@@ -189,6 +200,12 @@ function AppInner() {
|
||||
onImport={handleGitHubImport}
|
||||
tasks={tasks}
|
||||
/>
|
||||
<GitManagerModal
|
||||
isOpen={gitManagerOpen}
|
||||
onClose={handleCloseGitManager}
|
||||
tasks={tasks}
|
||||
addToast={addToast}
|
||||
/>
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -370,3 +370,271 @@ describe("rejectPlan", () => {
|
||||
await expect(rejectPlan("KB-001")).rejects.toThrow("awaiting-approval");
|
||||
});
|
||||
});
|
||||
|
||||
// --- Git Management API tests ---
|
||||
|
||||
import {
|
||||
fetchGitStatus,
|
||||
fetchGitCommits,
|
||||
fetchCommitDiff,
|
||||
fetchGitBranches,
|
||||
fetchGitWorktrees,
|
||||
createBranch,
|
||||
checkoutBranch,
|
||||
deleteBranch,
|
||||
fetchRemote,
|
||||
pullBranch,
|
||||
pushBranch,
|
||||
} from "./api";
|
||||
|
||||
describe("Git Management API", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe("fetchGitStatus", () => {
|
||||
it("returns git status", async () => {
|
||||
const status = { branch: "main", commit: "abc1234", isDirty: false, ahead: 0, behind: 0 };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, status));
|
||||
|
||||
const result = await fetchGitStatus();
|
||||
|
||||
expect(result).toEqual(status);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/status", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on error", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Not a git repository" }, 400));
|
||||
|
||||
await expect(fetchGitStatus()).rejects.toThrow("Not a git repository");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGitCommits", () => {
|
||||
it("returns commits without limit", async () => {
|
||||
const commits = [
|
||||
{ hash: "abc123", shortHash: "abc", message: "Test commit", author: "User", date: "2026-01-01", parents: [] },
|
||||
];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, commits));
|
||||
|
||||
const result = await fetchGitCommits();
|
||||
|
||||
expect(result).toEqual(commits);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/commits", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
it("includes limit in query string", async () => {
|
||||
const commits = [{ hash: "abc123", shortHash: "abc", message: "Test", author: "User", date: "2026-01-01", parents: [] }];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, commits));
|
||||
|
||||
const result = await fetchGitCommits(50);
|
||||
|
||||
expect(result).toEqual(commits);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/commits?limit=50", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchCommitDiff", () => {
|
||||
it("returns diff for a commit", async () => {
|
||||
const diff = { stat: "1 file changed", patch: "diff content" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, diff));
|
||||
|
||||
const result = await fetchCommitDiff("abc123");
|
||||
|
||||
expect(result).toEqual(diff);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/commits/abc123/diff", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on 404", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Commit not found" }, 404));
|
||||
|
||||
await expect(fetchCommitDiff("invalid")).rejects.toThrow("Commit not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGitBranches", () => {
|
||||
it("returns branches array", async () => {
|
||||
const branches = [{ name: "main", isCurrent: true, remote: "origin/main" }];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, branches));
|
||||
|
||||
const result = await fetchGitBranches();
|
||||
|
||||
expect(result).toEqual(branches);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchGitWorktrees", () => {
|
||||
it("returns worktrees array", async () => {
|
||||
const worktrees = [{ path: "/path/to/repo", branch: "main", isMain: true, isBare: false }];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, worktrees));
|
||||
|
||||
const result = await fetchGitWorktrees();
|
||||
|
||||
expect(result).toEqual(worktrees);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBranch", () => {
|
||||
it("sends POST to create branch", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { created: true }, 201));
|
||||
|
||||
await createBranch("feature-branch");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/branches", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "feature-branch", base: undefined }),
|
||||
});
|
||||
});
|
||||
|
||||
it("sends base when provided", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { created: true }, 201));
|
||||
|
||||
await createBranch("feature-branch", "main");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/branches", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "feature-branch", base: "main" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on error", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Invalid branch name" }, 400));
|
||||
|
||||
await expect(createBranch("invalid")).rejects.toThrow("Invalid branch name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkoutBranch", () => {
|
||||
it("sends POST to checkout branch", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { checkedOut: "main" }));
|
||||
|
||||
await checkoutBranch("main");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/branches/main/checkout", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("encodes branch name", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {}));
|
||||
|
||||
await checkoutBranch("feature/test");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/branches/feature%2Ftest/checkout", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteBranch", () => {
|
||||
it("sends DELETE to remove branch", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { deleted: "feature" }));
|
||||
|
||||
await deleteBranch("feature");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/branches/feature", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "DELETE",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes force query param when true", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { deleted: "feature" }));
|
||||
|
||||
await deleteBranch("feature", true);
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/branches/feature?force=true", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "DELETE",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchRemote", () => {
|
||||
it("sends POST to fetch origin by default", async () => {
|
||||
const result = { fetched: true, message: "Fetched" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
|
||||
|
||||
const response = await fetchRemote();
|
||||
|
||||
expect(response).toEqual(result);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/fetch", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ remote: undefined }),
|
||||
});
|
||||
});
|
||||
|
||||
it("sends custom remote when provided", async () => {
|
||||
const result = { fetched: true, message: "Fetched from upstream" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
|
||||
|
||||
await fetchRemote("upstream");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/fetch", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({ remote: "upstream" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("pullBranch", () => {
|
||||
it("sends POST to pull", async () => {
|
||||
const result = { success: true, message: "Pulled 2 commits" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
|
||||
|
||||
const response = await pullBranch();
|
||||
|
||||
expect(response).toEqual(result);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/pull", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns conflict info when there are conflicts", async () => {
|
||||
const result = { success: false, message: "Merge conflict", conflict: true };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result, 409));
|
||||
|
||||
const response = await pullBranch();
|
||||
|
||||
expect(response.conflict).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pushBranch", () => {
|
||||
it("sends POST to push", async () => {
|
||||
const result = { success: true, message: "Pushed to origin" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
|
||||
|
||||
const response = await pushBranch();
|
||||
|
||||
expect(response).toEqual(result);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/push", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on rejection", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Push rejected" }, 409));
|
||||
|
||||
await expect(pushBranch()).rejects.toThrow("Push rejected");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -262,3 +262,131 @@ export function refreshPrStatus(id: string): Promise<PrInfo> {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
// --- Git Management API ---
|
||||
|
||||
/** Current git status */
|
||||
export interface GitStatus {
|
||||
branch: string;
|
||||
commit: string;
|
||||
isDirty: boolean;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
}
|
||||
|
||||
/** Git commit info */
|
||||
export interface GitCommit {
|
||||
hash: string;
|
||||
shortHash: string;
|
||||
message: string;
|
||||
author: string;
|
||||
date: string;
|
||||
parents: string[];
|
||||
}
|
||||
|
||||
/** Git branch info */
|
||||
export interface GitBranch {
|
||||
name: string;
|
||||
isCurrent: boolean;
|
||||
remote?: string;
|
||||
lastCommitDate?: string;
|
||||
}
|
||||
|
||||
/** Git worktree info */
|
||||
export interface GitWorktree {
|
||||
path: string;
|
||||
branch?: string;
|
||||
isMain: boolean;
|
||||
isBare: boolean;
|
||||
taskId?: string;
|
||||
}
|
||||
|
||||
/** Result of a fetch operation */
|
||||
export interface GitFetchResult {
|
||||
fetched: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Result of a pull operation */
|
||||
export interface GitPullResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
conflict?: boolean;
|
||||
}
|
||||
|
||||
/** Result of a push operation */
|
||||
export interface GitPushResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Fetch current git status */
|
||||
export function fetchGitStatus(): Promise<GitStatus> {
|
||||
return api<GitStatus>("/git/status");
|
||||
}
|
||||
|
||||
/** Fetch recent commits */
|
||||
export function fetchGitCommits(limit?: number): Promise<GitCommit[]> {
|
||||
const query = limit ? `?limit=${limit}` : "";
|
||||
return api<GitCommit[]>(`/git/commits${query}`);
|
||||
}
|
||||
|
||||
/** Fetch diff for a specific commit */
|
||||
export function fetchCommitDiff(hash: string): Promise<{ stat: string; patch: string }> {
|
||||
return api<{ stat: string; patch: string }>(`/git/commits/${hash}/diff`);
|
||||
}
|
||||
|
||||
/** Fetch all local branches */
|
||||
export function fetchGitBranches(): Promise<GitBranch[]> {
|
||||
return api<GitBranch[]>("/git/branches");
|
||||
}
|
||||
|
||||
/** Fetch all worktrees */
|
||||
export function fetchGitWorktrees(): Promise<GitWorktree[]> {
|
||||
return api<GitWorktree[]>("/git/worktrees");
|
||||
}
|
||||
|
||||
/** Create a new branch */
|
||||
export function createBranch(name: string, base?: string): Promise<void> {
|
||||
return api<void>("/git/branches", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, base }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Checkout an existing branch */
|
||||
export function checkoutBranch(name: string): Promise<void> {
|
||||
return api<void>(`/git/branches/${encodeURIComponent(name)}/checkout`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete a branch */
|
||||
export function deleteBranch(name: string, force?: boolean): Promise<void> {
|
||||
const query = force ? "?force=true" : "";
|
||||
return api<void>(`/git/branches/${encodeURIComponent(name)}${query}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch from remote */
|
||||
export function fetchRemote(remote?: string): Promise<GitFetchResult> {
|
||||
return api<GitFetchResult>("/git/fetch", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ remote }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Pull current branch */
|
||||
export function pullBranch(): Promise<GitPullResult> {
|
||||
return api<GitPullResult>("/git/pull", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
/** Push current branch */
|
||||
export function pushBranch(): Promise<GitPushResult> {
|
||||
return api<GitPushResult>("/git/push", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
603
packages/dashboard/app/components/GitManagerModal.tsx
Normal file
603
packages/dashboard/app/components/GitManagerModal.tsx
Normal file
@@ -0,0 +1,603 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { Task } from "@kb/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import type {
|
||||
GitStatus,
|
||||
GitCommit,
|
||||
GitBranch,
|
||||
GitWorktree,
|
||||
GitFetchResult,
|
||||
GitPullResult,
|
||||
GitPushResult,
|
||||
} from "../api";
|
||||
import {
|
||||
fetchGitStatus,
|
||||
fetchGitCommits,
|
||||
fetchCommitDiff,
|
||||
fetchGitBranches,
|
||||
fetchGitWorktrees,
|
||||
createBranch,
|
||||
checkoutBranch,
|
||||
deleteBranch,
|
||||
fetchRemote,
|
||||
pullBranch,
|
||||
pushBranch,
|
||||
} from "../api";
|
||||
import {
|
||||
GitBranch as GitBranchIcon,
|
||||
GitCommit,
|
||||
GitPullRequest,
|
||||
GitMerge,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
HardDrive,
|
||||
Radio,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
type SectionId = "status" | "commits" | "branches" | "worktrees" | "remotes";
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: "status" as SectionId, label: "Status", icon: Radio },
|
||||
{ id: "commits" as SectionId, label: "Commits", icon: GitCommit },
|
||||
{ id: "branches" as SectionId, label: "Branches", icon: GitBranchIcon },
|
||||
{ id: "worktrees" as SectionId, label: "Worktrees", icon: HardDrive },
|
||||
{ id: "remotes" as SectionId, label: "Remotes", icon: GitMerge },
|
||||
];
|
||||
|
||||
interface GitManagerModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tasks: Task[];
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
}
|
||||
|
||||
export function GitManagerModal({ isOpen, onClose, tasks, addToast }: GitManagerModalProps) {
|
||||
const [activeSection, setActiveSection] = useState<SectionId>("status");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState<GitStatus | null>(null);
|
||||
const [commits, setCommits] = useState<GitCommit[]>([]);
|
||||
const [branches, setBranches] = useState<GitBranch[]>([]);
|
||||
const [worktrees, setWorktrees] = useState<GitWorktree[]>([]);
|
||||
const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
|
||||
const [commitDiff, setCommitDiff] = useState<{ stat: string; patch: string } | null>(null);
|
||||
const [newBranchName, setNewBranchName] = useState("");
|
||||
const [branchBase, setBranchBase] = useState("");
|
||||
const [loadingDiff, setLoadingDiff] = useState(false);
|
||||
const [remoteLoading, setRemoteLoading] = useState<string | null>(null);
|
||||
const [lastRemoteResult, setLastRemoteResult] = useState<GitFetchResult | GitPullResult | GitPushResult | null>(null);
|
||||
const [commitsLimit, setCommitsLimit] = useState(20);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch data when section changes or modal opens
|
||||
const fetchSectionData = useCallback(async () => {
|
||||
if (!isOpen) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
switch (activeSection) {
|
||||
case "status":
|
||||
const statusData = await fetchGitStatus();
|
||||
setStatus(statusData);
|
||||
break;
|
||||
case "commits":
|
||||
const commitsData = await fetchGitCommits(commitsLimit);
|
||||
setCommits(commitsData);
|
||||
break;
|
||||
case "branches":
|
||||
const [branchesData, statusForBranch] = await Promise.all([fetchGitBranches(), fetchGitStatus()]);
|
||||
setBranches(branchesData);
|
||||
setStatus(statusForBranch);
|
||||
break;
|
||||
case "worktrees":
|
||||
const worktreesData = await fetchGitWorktrees();
|
||||
setWorktrees(worktreesData);
|
||||
break;
|
||||
case "remotes":
|
||||
// Just refresh status for remote section
|
||||
const remoteStatus = await fetchGitStatus();
|
||||
setStatus(remoteStatus);
|
||||
break;
|
||||
}
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to fetch git data", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeSection, isOpen, commitsLimit, addToast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchSectionData();
|
||||
}
|
||||
}, [fetchSectionData, isOpen]);
|
||||
|
||||
// Keyboard support
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Handle commit selection and diff loading
|
||||
const handleCommitClick = useCallback(async (hash: string) => {
|
||||
if (selectedCommit === hash) {
|
||||
setSelectedCommit(null);
|
||||
setCommitDiff(null);
|
||||
return;
|
||||
}
|
||||
setSelectedCommit(hash);
|
||||
setLoadingDiff(true);
|
||||
try {
|
||||
const diff = await fetchCommitDiff(hash);
|
||||
setCommitDiff(diff);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to load diff", "error");
|
||||
setCommitDiff(null);
|
||||
} finally {
|
||||
setLoadingDiff(false);
|
||||
}
|
||||
}, [selectedCommit, addToast]);
|
||||
|
||||
// Handle branch creation
|
||||
const handleCreateBranch = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newBranchName.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await createBranch(newBranchName.trim(), branchBase.trim() || undefined);
|
||||
addToast(`Created branch ${newBranchName}`, "success");
|
||||
setNewBranchName("");
|
||||
setBranchBase("");
|
||||
// Refresh branches
|
||||
const branchesData = await fetchGitBranches();
|
||||
setBranches(branchesData);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to create branch", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [newBranchName, branchBase, addToast]);
|
||||
|
||||
// Handle branch checkout
|
||||
const handleCheckoutBranch = useCallback(async (name: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await checkoutBranch(name);
|
||||
addToast(`Switched to ${name}`, "success");
|
||||
// Refresh status and branches
|
||||
const [statusData, branchesData] = await Promise.all([fetchGitStatus(), fetchGitBranches()]);
|
||||
setStatus(statusData);
|
||||
setBranches(branchesData);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Failed to checkout branch", "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
// Handle branch deletion
|
||||
const handleDeleteBranch = useCallback(async (name: string) => {
|
||||
if (!confirm(`Delete branch "${name}"?`)) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await deleteBranch(name);
|
||||
addToast(`Deleted branch ${name}`, "success");
|
||||
// Refresh branches
|
||||
const branchesData = await fetchGitBranches();
|
||||
setBranches(branchesData);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not fully merged")) {
|
||||
if (confirm("Branch has unmerged commits. Force delete?")) {
|
||||
try {
|
||||
await deleteBranch(name, true);
|
||||
addToast(`Force deleted branch ${name}`, "success");
|
||||
const branchesData = await fetchGitBranches();
|
||||
setBranches(branchesData);
|
||||
} catch (forceErr: any) {
|
||||
addToast(forceErr.message || "Failed to delete branch", "error");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
addToast(err.message || "Failed to delete branch", "error");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
// Handle fetch
|
||||
const handleFetch = useCallback(async () => {
|
||||
setRemoteLoading("fetch");
|
||||
try {
|
||||
const result = await fetchRemote();
|
||||
setLastRemoteResult(result);
|
||||
addToast(result.message || "Fetch completed", result.fetched ? "success" : "info");
|
||||
// Refresh status
|
||||
const statusData = await fetchGitStatus();
|
||||
setStatus(statusData);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Fetch failed", "error");
|
||||
} finally {
|
||||
setRemoteLoading(null);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
// Handle pull
|
||||
const handlePull = useCallback(async () => {
|
||||
setRemoteLoading("pull");
|
||||
try {
|
||||
const result = await pullBranch();
|
||||
setLastRemoteResult(result);
|
||||
if (result.conflict) {
|
||||
addToast("Merge conflict detected. Resolve manually.", "error");
|
||||
} else {
|
||||
addToast(result.message || "Pull completed", "success");
|
||||
}
|
||||
// Refresh status
|
||||
const statusData = await fetchGitStatus();
|
||||
setStatus(statusData);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Pull failed", "error");
|
||||
} finally {
|
||||
setRemoteLoading(null);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
// Handle push
|
||||
const handlePush = useCallback(async () => {
|
||||
setRemoteLoading("push");
|
||||
try {
|
||||
const result = await pushBranch();
|
||||
setLastRemoteResult(result);
|
||||
addToast(result.message || "Push completed", "success");
|
||||
// Refresh status
|
||||
const statusData = await fetchGitStatus();
|
||||
setStatus(statusData);
|
||||
} catch (err: any) {
|
||||
addToast(err.message || "Push failed", "error");
|
||||
} finally {
|
||||
setRemoteLoading(null);
|
||||
}
|
||||
}, [addToast]);
|
||||
|
||||
// Load more commits
|
||||
const handleLoadMoreCommits = useCallback(() => {
|
||||
setCommitsLimit((prev) => Math.min(prev + 20, 100));
|
||||
}, []);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay open" onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal modal-lg" ref={modalRef}>
|
||||
<div className="modal-header">
|
||||
<h3>
|
||||
<GitBranchIcon size={18} style={{ marginRight: 8, verticalAlign: "middle" }} />
|
||||
Git Manager
|
||||
</h3>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="git-manager-layout">
|
||||
{/* Sidebar */}
|
||||
<nav className="git-manager-sidebar">
|
||||
{SECTIONS.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<button
|
||||
key={section.id}
|
||||
className={`git-manager-nav-item${activeSection === section.id ? " active" : ""}`}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{section.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Content */}
|
||||
<div className="git-manager-content">
|
||||
{loading && (
|
||||
<div className="git-manager-loading">
|
||||
<Loader2 size={24} className="spin" />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Tab */}
|
||||
{activeSection === "status" && status && (
|
||||
<div className="git-status-panel">
|
||||
<h4>Repository Status</h4>
|
||||
<div className="git-status-grid">
|
||||
<div className="git-status-item">
|
||||
<span className="git-status-label">Branch</span>
|
||||
<span className="git-status-value">
|
||||
<GitBranchIcon size={14} />
|
||||
{status.branch}
|
||||
</span>
|
||||
</div>
|
||||
<div className="git-status-item">
|
||||
<span className="git-status-label">Commit</span>
|
||||
<code className="git-status-commit">{status.commit}</code>
|
||||
</div>
|
||||
<div className="git-status-item">
|
||||
<span className="git-status-label">Status</span>
|
||||
<span className={`git-status-badge ${status.isDirty ? "dirty" : "clean"}`}>
|
||||
{status.isDirty ? "Modified" : "Clean"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="git-status-item">
|
||||
<span className="git-status-label">Remote</span>
|
||||
<span className="git-status-value">
|
||||
{status.ahead > 0 && (
|
||||
<span className="git-ahead" title={`${status.ahead} commit(s) ahead`}>
|
||||
<ArrowUp size={12} />
|
||||
{status.ahead}
|
||||
</span>
|
||||
)}
|
||||
{status.behind > 0 && (
|
||||
<span className="git-behind" title={`${status.behind} commit(s) behind`}>
|
||||
<ArrowDown size={12} />
|
||||
{status.behind}
|
||||
</span>
|
||||
)}
|
||||
{status.ahead === 0 && status.behind === 0 && (
|
||||
<span className="git-in-sync">Up to date</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Commits Tab */}
|
||||
{activeSection === "commits" && (
|
||||
<div className="git-commits-panel">
|
||||
<h4>Recent Commits</h4>
|
||||
<div className="git-commits-list">
|
||||
{commits.map((commit) => (
|
||||
<div key={commit.hash} className="git-commit-item">
|
||||
<button
|
||||
className="git-commit-header"
|
||||
onClick={() => handleCommitClick(commit.hash)}
|
||||
>
|
||||
{selectedCommit === commit.hash ? (
|
||||
<ChevronDown size={14} />
|
||||
) : (
|
||||
<ChevronRight size={14} />
|
||||
)}
|
||||
<code className="git-commit-hash">{commit.shortHash}</code>
|
||||
<span className="git-commit-message" title={commit.message}>
|
||||
{commit.message}
|
||||
</span>
|
||||
<span className="git-commit-meta">
|
||||
{commit.author} • {new Date(commit.date).toLocaleDateString()}
|
||||
</span>
|
||||
</button>
|
||||
{selectedCommit === commit.hash && (
|
||||
<div className="git-commit-diff">
|
||||
{loadingDiff ? (
|
||||
<div className="git-diff-loading">
|
||||
<Loader2 size={16} className="spin" />
|
||||
Loading diff...
|
||||
</div>
|
||||
) : commitDiff ? (
|
||||
<>
|
||||
<pre className="git-diff-stat">{commitDiff.stat}</pre>
|
||||
<pre className="git-diff-patch">{commitDiff.patch}</pre>
|
||||
</>
|
||||
) : (
|
||||
<div className="git-diff-error">Failed to load diff</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{commits.length >= commitsLimit && commitsLimit < 100 && (
|
||||
<button className="git-load-more" onClick={handleLoadMoreCommits}>
|
||||
Load more commits
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Branches Tab */}
|
||||
{activeSection === "branches" && (
|
||||
<div className="git-branches-panel">
|
||||
<h4>Branches</h4>
|
||||
|
||||
{/* Create branch form */}
|
||||
<form className="git-create-branch-form" onSubmit={handleCreateBranch}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="New branch name"
|
||||
value={newBranchName}
|
||||
onChange={(e) => setNewBranchName(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Base branch (optional)"
|
||||
value={branchBase}
|
||||
onChange={(e) => setBranchBase(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary btn-sm" disabled={loading || !newBranchName.trim()}>
|
||||
<Plus size={14} />
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Branches list */}
|
||||
<div className="git-branches-list">
|
||||
{branches.map((branch) => (
|
||||
<div
|
||||
key={branch.name}
|
||||
className={`git-branch-item ${branch.isCurrent ? "current" : ""}`}
|
||||
>
|
||||
<span className="git-branch-name">
|
||||
{branch.isCurrent && <Check size={14} className="git-branch-current-icon" />}
|
||||
{branch.name}
|
||||
{branch.remote && (
|
||||
<span className="git-branch-remote">→ {branch.remote}</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="git-branch-actions">
|
||||
{!branch.isCurrent && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => handleCheckoutBranch(branch.name)}
|
||||
disabled={loading}
|
||||
title="Checkout"
|
||||
>
|
||||
<GitBranchIcon size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
onClick={() => handleDeleteBranch(branch.name)}
|
||||
disabled={loading}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Worktrees Tab */}
|
||||
{activeSection === "worktrees" && (
|
||||
<div className="git-worktrees-panel">
|
||||
<h4>Worktrees</h4>
|
||||
<div className="git-worktrees-stats">
|
||||
<span>{worktrees.length} total</span>
|
||||
<span>{worktrees.filter((w) => w.taskId).length} in use by tasks</span>
|
||||
</div>
|
||||
<div className="git-worktrees-list">
|
||||
{worktrees.map((worktree) => (
|
||||
<div
|
||||
key={worktree.path}
|
||||
className={`git-worktree-item ${worktree.isMain ? "main" : ""}`}
|
||||
>
|
||||
<div className="git-worktree-info">
|
||||
<span className="git-worktree-path" title={worktree.path}>
|
||||
{worktree.isMain && <span className="git-worktree-badge main">main</span>}
|
||||
{worktree.isBare && <span className="git-worktree-badge bare">bare</span>}
|
||||
{worktree.path}
|
||||
</span>
|
||||
{worktree.branch && (
|
||||
<span className="git-worktree-branch">
|
||||
<GitBranchIcon size={12} />
|
||||
{worktree.branch}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{worktree.taskId && (
|
||||
<span className="git-worktree-task">{worktree.taskId}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remotes Tab */}
|
||||
{activeSection === "remotes" && (
|
||||
<div className="git-remotes-panel">
|
||||
<h4>Remote Operations</h4>
|
||||
|
||||
{status && (status.ahead > 0 || status.behind > 0) && (
|
||||
<div className="git-remote-status">
|
||||
{status.ahead > 0 && (
|
||||
<div className="git-remote-ahead">
|
||||
<AlertCircle size={16} />
|
||||
{status.ahead} commit(s) to push
|
||||
</div>
|
||||
)}
|
||||
{status.behind > 0 && (
|
||||
<div className="git-remote-behind">
|
||||
<AlertCircle size={16} />
|
||||
{status.behind} commit(s) to pull
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="git-remote-actions">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleFetch}
|
||||
disabled={remoteLoading !== null}
|
||||
>
|
||||
{remoteLoading === "fetch" ? (
|
||||
<Loader2 size={14} className="spin" />
|
||||
) : (
|
||||
<RefreshCw size={14} />
|
||||
)}
|
||||
Fetch
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handlePull}
|
||||
disabled={remoteLoading !== null}
|
||||
>
|
||||
{remoteLoading === "pull" ? (
|
||||
<Loader2 size={14} className="spin" />
|
||||
) : (
|
||||
<GitPullRequest size={14} />
|
||||
)}
|
||||
Pull
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handlePush}
|
||||
disabled={remoteLoading !== null || (status?.ahead === 0 && false)}
|
||||
>
|
||||
{remoteLoading === "push" ? (
|
||||
<Loader2 size={14} className="spin" />
|
||||
) : (
|
||||
<ArrowUp size={14} />
|
||||
)}
|
||||
Push
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{lastRemoteResult && (
|
||||
<div className={`git-remote-result ${"fetched" in lastRemoteResult ? "fetch" : "success" in lastRemoteResult ? (lastRemoteResult as GitPullResult).conflict ? "conflict" : (lastRemoteResult as GitPullResult).success ? "success" : "error" : "success" in lastRemoteResult ? (lastRemoteResult as GitPushResult).success ? "success" : "error" : ""}`}>
|
||||
{lastRemoteResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="btn btn-sm" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Settings, Pause, Play, Square, Download, LayoutGrid, List } from "lucide-react";
|
||||
import { Settings, Pause, Play, Square, Download, LayoutGrid, List, GitBranch } from "lucide-react";
|
||||
|
||||
interface HeaderProps {
|
||||
onOpenSettings?: () => void;
|
||||
onOpenGitHubImport?: () => void;
|
||||
onOpenGitManager?: () => void;
|
||||
globalPaused?: boolean;
|
||||
enginePaused?: boolean;
|
||||
onToggleGlobalPause?: () => void;
|
||||
@@ -14,6 +15,7 @@ interface HeaderProps {
|
||||
export function Header({
|
||||
onOpenSettings,
|
||||
onOpenGitHubImport,
|
||||
onOpenGitManager,
|
||||
globalPaused,
|
||||
enginePaused,
|
||||
onToggleGlobalPause,
|
||||
@@ -56,6 +58,10 @@ export function Header({
|
||||
<button className="btn-icon" onClick={onOpenGitHubImport} title="Import from GitHub">
|
||||
<Download size={16} />
|
||||
</button>
|
||||
{/* Git Manager */}
|
||||
<button className="btn-icon" onClick={onOpenGitManager} title="Git Manager">
|
||||
<GitBranch size={16} />
|
||||
</button>
|
||||
{/* Pause button (soft pause): stops new work, lets agents finish */}
|
||||
<button
|
||||
className={`btn-icon${enginePaused ? " btn-icon--paused" : ""}`}
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { GitManagerModal } from "../GitManagerModal";
|
||||
import type { Task } from "@kb/core";
|
||||
|
||||
// Mock the API module
|
||||
vi.mock("../../api", async () => {
|
||||
return {
|
||||
fetchGitStatus: vi.fn(),
|
||||
fetchGitCommits: vi.fn(),
|
||||
fetchCommitDiff: vi.fn(),
|
||||
fetchGitBranches: vi.fn(),
|
||||
fetchGitWorktrees: vi.fn(),
|
||||
createBranch: vi.fn(),
|
||||
checkoutBranch: vi.fn(),
|
||||
deleteBranch: vi.fn(),
|
||||
fetchRemote: vi.fn(),
|
||||
pullBranch: vi.fn(),
|
||||
pushBranch: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
fetchGitStatus,
|
||||
fetchGitCommits,
|
||||
fetchGitBranches,
|
||||
fetchGitWorktrees,
|
||||
createBranch,
|
||||
checkoutBranch,
|
||||
deleteBranch,
|
||||
fetchRemote,
|
||||
pullBranch,
|
||||
pushBranch,
|
||||
} from "../../api";
|
||||
|
||||
const mockAddToast = vi.fn();
|
||||
|
||||
const mockTasks: Task[] = [
|
||||
{ id: "KB-001", description: "Test task 1", column: "in-progress", dependencies: [], worktree: "/worktrees/kb-001" },
|
||||
{ id: "KB-002", description: "Test task 2", column: "todo", dependencies: [] },
|
||||
];
|
||||
|
||||
describe("GitManagerModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default mock implementations
|
||||
(fetchGitStatus as any).mockResolvedValue({
|
||||
branch: "main",
|
||||
commit: "abc1234",
|
||||
isDirty: false,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
});
|
||||
(fetchGitCommits as any).mockResolvedValue([
|
||||
{ hash: "abc1234", shortHash: "abc1", message: "Test commit", author: "User", date: "2026-01-01", parents: [] },
|
||||
]);
|
||||
(fetchGitBranches as any).mockResolvedValue([
|
||||
{ name: "main", isCurrent: true, remote: "origin/main" },
|
||||
{ name: "feature", isCurrent: false },
|
||||
]);
|
||||
(fetchGitWorktrees as any).mockResolvedValue([
|
||||
{ path: "/worktrees/kb-001", branch: "kb/kb-001", isMain: false, isBare: false, taskId: "KB-001" },
|
||||
{ path: "/repo", branch: "main", isMain: true, isBare: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders nothing when not open", () => {
|
||||
const { container } = render(
|
||||
<GitManagerModal
|
||||
isOpen={false}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders modal when open", async () => {
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Git Manager")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches status on mount", async () => {
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchGitStatus).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("switches tabs when clicking navigation", async () => {
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
// Wait for initial load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Repository Status")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click Commits tab
|
||||
fireEvent.click(screen.getByText("Commits"));
|
||||
await waitFor(() => {
|
||||
expect(fetchGitCommits).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Click Branches tab
|
||||
fireEvent.click(screen.getByText("Branches"));
|
||||
await waitFor(() => {
|
||||
expect(fetchGitBranches).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("closes on Escape key", async () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Git Manager")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows status information", async () => {
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
expect(screen.getByText("Clean")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("loads commits and shows them", async () => {
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Commits"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test commit")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("loads branches and shows current branch", async () => {
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Branches"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
expect(screen.getByText("feature")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("loads worktrees and shows task associations", async () => {
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Worktrees"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("KB-001")).toBeInTheDocument();
|
||||
expect(screen.getByText("2 total")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls createBranch when form is submitted", async () => {
|
||||
const user = userEvent.setup();
|
||||
(createBranch as any).mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Branches"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("New branch name")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("New branch name");
|
||||
await user.type(nameInput, "new-feature");
|
||||
|
||||
const createButton = screen.getByRole("button", { name: /create/i });
|
||||
await user.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createBranch).toHaveBeenCalledWith("new-feature", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("calls checkoutBranch when checkout button clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
(checkoutBranch as any).mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Branches"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("feature")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows remote operations buttons", async () => {
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Remotes"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /fetch/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /pull/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /push/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls fetchRemote when Fetch button clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
(fetchRemote as any).mockResolvedValue({ fetched: true, message: "Fetched" });
|
||||
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Remotes"));
|
||||
|
||||
const fetchButton = await screen.findByRole("button", { name: /fetch/i });
|
||||
await user.click(fetchButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchRemote).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast when fetch fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
(fetchRemote as any).mockRejectedValue(new Error("Network error"));
|
||||
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Remotes"));
|
||||
|
||||
const fetchButton = await screen.findByRole("button", { name: /fetch/i });
|
||||
await user.click(fetchButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Network error", "error");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows ahead/behind indicators in status", async () => {
|
||||
(fetchGitStatus as any).mockResolvedValue({
|
||||
branch: "main",
|
||||
commit: "abc1234",
|
||||
isDirty: false,
|
||||
ahead: 2,
|
||||
behind: 3,
|
||||
});
|
||||
|
||||
render(
|
||||
<GitManagerModal
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
addToast={mockAddToast}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2484,3 +2484,491 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* === Git Manager === */
|
||||
|
||||
.git-manager-layout {
|
||||
display: flex;
|
||||
height: 60vh;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.git-manager-sidebar {
|
||||
width: 160px;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.git-manager-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.git-manager-nav-item:hover {
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.git-manager-nav-item.active {
|
||||
background: var(--card);
|
||||
color: var(--todo);
|
||||
font-weight: 500;
|
||||
border-left-color: var(--todo);
|
||||
}
|
||||
|
||||
.git-manager-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.git-manager-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 40px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Git Status */
|
||||
.git-status-panel h4,
|
||||
.git-commits-panel h4,
|
||||
.git-branches-panel h4,
|
||||
.git-worktrees-panel h4,
|
||||
.git-remotes-panel h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.git-status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.git-status-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.git-status-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.git-status-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.git-status-commit {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.git-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.git-status-badge.clean {
|
||||
background: rgba(63, 185, 80, 0.15);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.git-status-badge.dirty {
|
||||
background: rgba(248, 81, 73, 0.15);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.git-ahead,
|
||||
.git-behind,
|
||||
.git-in-sync {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.git-ahead {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.git-behind {
|
||||
color: var(--triage);
|
||||
}
|
||||
|
||||
.git-in-sync {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Git Commits */
|
||||
.git-commits-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.git-commit-item {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.git-commit-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: var(--card);
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.git-commit-header:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.git-commit-hash {
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.git-commit-message {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.git-commit-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.git-commit-diff {
|
||||
padding: 12px;
|
||||
background: var(--bg);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.git-diff-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.git-diff-stat {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.git-diff-patch {
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.git-diff-error {
|
||||
padding: 20px;
|
||||
color: var(--color-error);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.git-load-more {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-top: 12px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.git-load-more:hover {
|
||||
background: var(--card-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Git Branches */
|
||||
.git-create-branch-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.git-create-branch-form input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.git-create-branch-form input::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.git-branches-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.git-branch-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.git-branch-item:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.git-branch-item.current {
|
||||
background: rgba(88, 166, 255, 0.1);
|
||||
border-color: var(--todo);
|
||||
}
|
||||
|
||||
.git-branch-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.git-branch-current-icon {
|
||||
color: var(--todo);
|
||||
}
|
||||
|
||||
.git-branch-remote {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.git-branch-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Git Worktrees */
|
||||
.git-worktrees-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
padding: 8px 12px;
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.git-worktrees-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.git-worktree-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.git-worktree-item.main {
|
||||
border-color: var(--todo);
|
||||
background: rgba(88, 166, 255, 0.05);
|
||||
}
|
||||
|
||||
.git-worktree-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.git-worktree-path {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.git-worktree-badge {
|
||||
display: inline-flex;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.git-worktree-badge.main {
|
||||
background: var(--todo);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.git-worktree-badge.bare {
|
||||
background: var(--text-muted);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.git-worktree-branch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.git-worktree-task {
|
||||
display: inline-flex;
|
||||
padding: 2px 8px;
|
||||
background: var(--triage);
|
||||
color: var(--bg);
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Git Remotes */
|
||||
.git-remote-status {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.git-remote-ahead,
|
||||
.git-remote-behind {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.git-remote-ahead {
|
||||
background: rgba(63, 185, 80, 0.1);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.git-remote-behind {
|
||||
background: rgba(210, 153, 34, 0.1);
|
||||
color: var(--triage);
|
||||
}
|
||||
|
||||
.git-remote-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.git-remote-result {
|
||||
padding: 12px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.git-remote-result.fetch,
|
||||
.git-remote-result.success {
|
||||
background: rgba(63, 185, 80, 0.1);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.git-remote-result.conflict {
|
||||
background: rgba(248, 81, 73, 0.1);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.git-remote-result.error {
|
||||
background: rgba(248, 81, 73, 0.1);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.5.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
|
||||
@@ -1659,6 +1659,7 @@ describe("POST /tasks/:id/spec/revise", () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// --- Plan Approval route tests ---
|
||||
|
||||
describe("POST /tasks/:id/approve-plan", () => {
|
||||
@@ -1816,3 +1817,260 @@ describe("POST /tasks/:id/reject-plan", () => {
|
||||
expect(res.body.error).toBe("Database error");
|
||||
});
|
||||
});
|
||||
|
||||
// --- Git Management route tests ---
|
||||
// These are integration tests that run against the actual git repository
|
||||
|
||||
describe("Git Management endpoints", () => {
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("GET /git/status", () => {
|
||||
it("returns git status structure", async () => {
|
||||
const res = await GET(buildApp(), "/api/git/status");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty("branch");
|
||||
expect(res.body).toHaveProperty("commit");
|
||||
expect(res.body).toHaveProperty("isDirty");
|
||||
expect(res.body).toHaveProperty("ahead");
|
||||
expect(res.body).toHaveProperty("behind");
|
||||
expect(typeof res.body.branch).toBe("string");
|
||||
expect(typeof res.body.commit).toBe("string");
|
||||
expect(typeof res.body.isDirty).toBe("boolean");
|
||||
expect(typeof res.body.ahead).toBe("number");
|
||||
expect(typeof res.body.behind).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /git/commits", () => {
|
||||
it("returns commits array", async () => {
|
||||
const res = await GET(buildApp(), "/api/git/commits");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
if (res.body.length > 0) {
|
||||
expect(res.body[0]).toHaveProperty("hash");
|
||||
expect(res.body[0]).toHaveProperty("shortHash");
|
||||
expect(res.body[0]).toHaveProperty("message");
|
||||
expect(res.body[0]).toHaveProperty("author");
|
||||
expect(res.body[0]).toHaveProperty("date");
|
||||
}
|
||||
});
|
||||
|
||||
it("respects limit parameter", async () => {
|
||||
const res = await GET(buildApp(), "/api/git/commits?limit=5");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
expect(res.body.length).toBeLessThanOrEqual(5);
|
||||
});
|
||||
|
||||
it("caps limit at 100", async () => {
|
||||
const res = await GET(buildApp(), "/api/git/commits?limit=200");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
expect(res.body.length).toBeLessThanOrEqual(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /git/commits/:hash/diff", () => {
|
||||
it("returns 400 for invalid hash format", async () => {
|
||||
const res = await GET(buildApp(), "/api/git/commits/invalid-hash!/diff");
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Invalid commit hash format");
|
||||
});
|
||||
|
||||
it("returns 404 for non-existent commit", async () => {
|
||||
const res = await GET(buildApp(), "/api/git/commits/0000000/diff");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.error).toContain("Commit not found");
|
||||
});
|
||||
|
||||
it("returns diff for HEAD commit", async () => {
|
||||
// Get HEAD commit hash first
|
||||
const commitsRes = await GET(buildApp(), "/api/git/commits?limit=1");
|
||||
const headHash = commitsRes.body[0]?.hash;
|
||||
|
||||
if (headHash) {
|
||||
const res = await GET(buildApp(), `/api/git/commits/${headHash}/diff`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty("stat");
|
||||
expect(res.body).toHaveProperty("patch");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /git/branches", () => {
|
||||
it("returns branches array", async () => {
|
||||
const res = await GET(buildApp(), "/api/git/branches");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
if (res.body.length > 0) {
|
||||
expect(res.body[0]).toHaveProperty("name");
|
||||
expect(res.body[0]).toHaveProperty("isCurrent");
|
||||
expect(typeof res.body[0].name).toBe("string");
|
||||
expect(typeof res.body[0].isCurrent).toBe("boolean");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /git/worktrees", () => {
|
||||
it("returns worktrees array", async () => {
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
|
||||
const res = await GET(buildApp(), "/api/git/worktrees");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
if (res.body.length > 0) {
|
||||
expect(res.body[0]).toHaveProperty("path");
|
||||
expect(res.body[0]).toHaveProperty("isMain");
|
||||
expect(res.body[0]).toHaveProperty("isBare");
|
||||
}
|
||||
});
|
||||
|
||||
it("correlates worktrees with tasks", async () => {
|
||||
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{ id: "KB-TEST", worktree: "/some/worktree/path" },
|
||||
]);
|
||||
|
||||
const res = await GET(buildApp(), "/api/git/worktrees");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /git/branches", () => {
|
||||
it("returns 400 without name", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/git/branches", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("name is required");
|
||||
});
|
||||
|
||||
it("returns 400 for invalid branch name", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/git/branches",
|
||||
JSON.stringify({ name: "invalid;rm -rf /" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Invalid branch name");
|
||||
});
|
||||
|
||||
it("returns 400 for branch name starting with dash", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/git/branches",
|
||||
JSON.stringify({ name: "--force" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Invalid branch name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /git/branches/:name/checkout", () => {
|
||||
it("returns 400 for invalid branch name", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/git/branches/invalid;cmd/checkout",
|
||||
JSON.stringify({}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /git/branches/:name", () => {
|
||||
it("returns 400 for invalid branch name", async () => {
|
||||
const res = await REQUEST(buildApp(), "DELETE", "/api/git/branches/invalid;cmd");
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /git/fetch", () => {
|
||||
it("returns result structure", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/git/fetch", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
// May succeed or fail depending on network, but should return proper structure
|
||||
expect(res.status === 200 || res.status === 503 || res.status === 500).toBe(true);
|
||||
if (res.status === 200) {
|
||||
expect(res.body).toHaveProperty("fetched");
|
||||
expect(res.body).toHaveProperty("message");
|
||||
}
|
||||
});
|
||||
|
||||
it("validates remote name", async () => {
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/git/fetch",
|
||||
JSON.stringify({ remote: "invalid;rm -rf /" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Invalid remote name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /git/pull", () => {
|
||||
it("returns result or conflict status", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/git/pull", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
// May succeed or fail depending on state, but should return proper structure
|
||||
expect(res.status === 200 || res.status === 409 || res.status === 500).toBe(true);
|
||||
if (res.status === 200 || res.status === 409) {
|
||||
expect(res.body).toHaveProperty("success");
|
||||
expect(res.body).toHaveProperty("message");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /git/push", () => {
|
||||
it("returns result or rejection status", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/git/push", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
// May succeed or fail depending on remote state
|
||||
expect(res.status === 200 || res.status === 409 || res.status === 503 || res.status === 500).toBe(true);
|
||||
if (res.status === 200) {
|
||||
expect(res.body).toHaveProperty("success");
|
||||
expect(res.body).toHaveProperty("message");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,6 +116,404 @@ function getGitHubRemotes(): GitRemote[] {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current directory is a git repository.
|
||||
* Used to validate git operations before executing commands.
|
||||
*/
|
||||
function isGitRepo(): boolean {
|
||||
try {
|
||||
execSync("git rev-parse --git-dir", { encoding: "utf-8", timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current git status including branch, commit hash, and dirty state.
|
||||
* Returns structured data for the Git Manager UI.
|
||||
*/
|
||||
function getGitStatus(): {
|
||||
branch: string;
|
||||
commit: string;
|
||||
isDirty: boolean;
|
||||
ahead: number;
|
||||
behind: number;
|
||||
} | null {
|
||||
try {
|
||||
// Get current branch
|
||||
const branch = execSync("git branch --show-current", { encoding: "utf-8", timeout: 5000 }).trim() || "HEAD detached";
|
||||
|
||||
// Get current commit hash (short)
|
||||
const commit = execSync("git rev-parse --short HEAD", { encoding: "utf-8", timeout: 5000 }).trim();
|
||||
|
||||
// Check if working directory is dirty
|
||||
const statusOutput = execSync("git status --porcelain", { encoding: "utf-8", timeout: 5000 }).trim();
|
||||
const isDirty = statusOutput.length > 0;
|
||||
|
||||
// Get ahead/behind counts from origin
|
||||
let ahead = 0;
|
||||
let behind = 0;
|
||||
try {
|
||||
const revListOutput = execSync("git rev-list --left-right --count HEAD...@{u}", { encoding: "utf-8", timeout: 5000 }).trim();
|
||||
const match = revListOutput.match(/(\d+)\s+(\d+)/);
|
||||
if (match) {
|
||||
ahead = parseInt(match[1], 10);
|
||||
behind = parseInt(match[2], 10);
|
||||
}
|
||||
} catch {
|
||||
// No upstream or other error - leave as 0
|
||||
}
|
||||
|
||||
return { branch, commit, isDirty, ahead, behind };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Git commit info returned by the commits endpoint */
|
||||
export interface GitCommit {
|
||||
hash: string;
|
||||
shortHash: string;
|
||||
message: string;
|
||||
author: string;
|
||||
date: string;
|
||||
parents: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent commits from the git log.
|
||||
* @param limit Maximum number of commits to return (default 20)
|
||||
*/
|
||||
function getGitCommits(limit: number = 20): GitCommit[] {
|
||||
try {
|
||||
// Format: hash|shortHash|message|author|date|parents
|
||||
const format = "%H|%h|%s|%an|%aI|%P";
|
||||
const output = execSync(`git log --max-count=${limit} --pretty=format:"${format}"`, {
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
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
|
||||
* @returns Object with stat and patch
|
||||
*/
|
||||
function getCommitDiff(hash: string): { stat: string; patch: string } | null {
|
||||
try {
|
||||
// Validate the hash is a valid git object
|
||||
execSync(`git cat-file -t ${hash}`, { encoding: "utf-8", timeout: 5000 });
|
||||
|
||||
// Get diff stat
|
||||
const stat = execSync(`git show --stat --format="" ${hash}`, { encoding: "utf-8", timeout: 10000 }).trim();
|
||||
|
||||
// Get patch
|
||||
const patch = execSync(`git show --format="" ${hash}`, { encoding: "utf-8", timeout: 10000 });
|
||||
|
||||
return { stat, patch };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Git branch info returned by the branches endpoint */
|
||||
export interface GitBranch {
|
||||
name: string;
|
||||
isCurrent: boolean;
|
||||
remote?: string;
|
||||
lastCommitDate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all local branches with their info.
|
||||
*/
|
||||
function getGitBranches(): GitBranch[] {
|
||||
try {
|
||||
// Get current branch name
|
||||
let currentBranch = "";
|
||||
try {
|
||||
currentBranch = execSync("git branch --show-current", { encoding: "utf-8", timeout: 5000 }).trim();
|
||||
} catch {
|
||||
// Detached HEAD - no current branch
|
||||
}
|
||||
|
||||
// Get all branches with info
|
||||
const format = "%(refname:short)|%(upstream:short)|%(committerdate:iso8601)|%(HEAD)";
|
||||
const output = execSync(`git for-each-ref --format="${format}" refs/heads/`, {
|
||||
encoding: "utf-8",
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const branches: GitBranch[] = [];
|
||||
for (const line of output.trim().split("\n")) {
|
||||
const parts = line.split("|");
|
||||
if (parts.length < 4) continue;
|
||||
|
||||
const [name, remote, lastCommitDate, headMarker] = parts;
|
||||
const isCurrent = headMarker === "*" || name === currentBranch;
|
||||
|
||||
branches.push({
|
||||
name,
|
||||
isCurrent,
|
||||
remote: remote || undefined,
|
||||
lastCommitDate: lastCommitDate || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return branches;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Git worktree info returned by the worktrees endpoint */
|
||||
export interface GitWorktree {
|
||||
path: string;
|
||||
branch?: string;
|
||||
isMain: boolean;
|
||||
isBare: boolean;
|
||||
taskId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all git worktrees.
|
||||
* @param tasks Optional task list to correlate worktrees with tasks
|
||||
*/
|
||||
function getGitWorktrees(tasks: { id: string; worktree?: string }[] = []): GitWorktree[] {
|
||||
try {
|
||||
const output = execSync("git worktree list --porcelain", { encoding: "utf-8", timeout: 10000 });
|
||||
|
||||
const worktrees: GitWorktree[] = [];
|
||||
let currentWorktree: Partial<GitWorktree> = {};
|
||||
|
||||
for (const line of output.split("\n")) {
|
||||
if (line.startsWith("worktree ")) {
|
||||
// Save previous worktree if exists
|
||||
if (currentWorktree.path) {
|
||||
// Find associated task by matching worktree path
|
||||
const task = tasks.find((t) => t.worktree && currentWorktree.path === t.worktree);
|
||||
worktrees.push({
|
||||
path: currentWorktree.path,
|
||||
branch: currentWorktree.branch,
|
||||
isMain: currentWorktree.isMain || false,
|
||||
isBare: currentWorktree.isBare || false,
|
||||
taskId: task?.id,
|
||||
});
|
||||
}
|
||||
// Start new worktree
|
||||
currentWorktree = { path: line.slice(9).trim() };
|
||||
} else if (line.startsWith("branch ")) {
|
||||
currentWorktree.branch = line.slice(8).trim().replace(/^refs\/heads\//, "");
|
||||
} else if (line === "bare") {
|
||||
currentWorktree.isBare = true;
|
||||
} else if (line === "main") {
|
||||
currentWorktree.isMain = true;
|
||||
} else if (line === "" && currentWorktree.path) {
|
||||
// Empty line signals end of worktree entry
|
||||
const task = tasks.find((t) => t.worktree && currentWorktree.path === t.worktree);
|
||||
worktrees.push({
|
||||
path: currentWorktree.path,
|
||||
branch: currentWorktree.branch,
|
||||
isMain: currentWorktree.isMain || false,
|
||||
isBare: currentWorktree.isBare || false,
|
||||
taskId: task?.id,
|
||||
});
|
||||
currentWorktree = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Handle last worktree if no trailing newline
|
||||
if (currentWorktree.path) {
|
||||
const task = tasks.find((t) => t.worktree && currentWorktree.path === t.worktree);
|
||||
worktrees.push({
|
||||
path: currentWorktree.path,
|
||||
branch: currentWorktree.branch,
|
||||
isMain: currentWorktree.isMain || false,
|
||||
isBare: currentWorktree.isBare || false,
|
||||
taskId: task?.id,
|
||||
});
|
||||
}
|
||||
|
||||
return worktrees;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Git Action Helper Functions ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validates a branch name to prevent command injection.
|
||||
* Branch names must not contain spaces, special shell characters, or start with dashes.
|
||||
*/
|
||||
function isValidBranchName(name: string): boolean {
|
||||
// Must not be empty
|
||||
if (!name || name.length === 0) return false;
|
||||
// Must not start with a dash (could be interpreted as an option)
|
||||
if (name.startsWith("-")) return false;
|
||||
// Must not contain shell metacharacters
|
||||
if (/[;<>&|`$(){}[\]\r\n]/.test(name)) return false;
|
||||
// Must be valid git ref format (no spaces, no double dots, etc)
|
||||
if (/\s/.test(name)) return false;
|
||||
if (name.includes("..")) return false;
|
||||
if (name.includes("~")) return false;
|
||||
if (name.includes("^")) return false;
|
||||
if (name.includes(":")) return false;
|
||||
// Must not be a reserved git ref name
|
||||
const reserved = ["HEAD", "FETCH_HEAD", "ORIG_HEAD", "MERGE_HEAD", "CHERRY_PICK_HEAD"];
|
||||
if (reserved.includes(name)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new branch from current HEAD or specified base.
|
||||
* Returns the created branch name.
|
||||
*/
|
||||
function createGitBranch(name: string, base?: string): string {
|
||||
if (!isValidBranchName(name)) {
|
||||
throw new Error("Invalid branch name");
|
||||
}
|
||||
if (base && !isValidBranchName(base)) {
|
||||
throw new Error("Invalid base branch name");
|
||||
}
|
||||
const cmd = base
|
||||
? `git checkout -b ${name} ${base}`
|
||||
: `git checkout -b ${name}`;
|
||||
execSync(cmd, { encoding: "utf-8", timeout: 10000 });
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout an existing branch.
|
||||
* Throws if there are uncommitted changes that would be lost.
|
||||
*/
|
||||
function checkoutGitBranch(name: string): void {
|
||||
if (!isValidBranchName(name)) {
|
||||
throw new Error("Invalid branch name");
|
||||
}
|
||||
// Check for uncommitted changes that would be lost
|
||||
try {
|
||||
execSync("git diff-index --quiet HEAD --", { encoding: "utf-8", timeout: 5000 });
|
||||
} catch {
|
||||
// Has uncommitted changes - check if they'd be lost
|
||||
const diff = execSync("git diff --name-only", { encoding: "utf-8", timeout: 5000 }).trim();
|
||||
if (diff) {
|
||||
throw new Error("Uncommitted changes would be lost. Commit or stash changes first.");
|
||||
}
|
||||
}
|
||||
execSync(`git checkout ${name}`, { encoding: "utf-8", timeout: 10000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a branch.
|
||||
* Throws if it's the current branch or has unmerged commits.
|
||||
*/
|
||||
function deleteGitBranch(name: string, force: boolean = false): void {
|
||||
if (!isValidBranchName(name)) {
|
||||
throw new Error("Invalid branch name");
|
||||
}
|
||||
const flag = force ? "-D" : "-d";
|
||||
execSync(`git branch ${flag} ${name}`, { encoding: "utf-8", timeout: 10000 });
|
||||
}
|
||||
|
||||
/** Result of a fetch operation */
|
||||
export interface GitFetchResult {
|
||||
fetched: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch from origin or specified remote.
|
||||
*/
|
||||
function fetchGitRemote(remote: string = "origin"): GitFetchResult {
|
||||
if (!isValidBranchName(remote)) {
|
||||
throw new Error("Invalid remote name");
|
||||
}
|
||||
try {
|
||||
const output = execSync(`git fetch ${remote}`, { encoding: "utf-8", timeout: 30000 });
|
||||
return { fetched: true, message: output.trim() || "Fetch completed" };
|
||||
} catch (err: any) {
|
||||
const message = err.message || String(err);
|
||||
if (message.includes("Could not resolve host") || message.includes("Connection refused")) {
|
||||
throw new Error("Failed to connect to remote");
|
||||
}
|
||||
// No updates is not an error
|
||||
return { fetched: false, message: message || "No updates" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Result of a pull operation */
|
||||
export interface GitPullResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
conflict?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the current branch.
|
||||
*/
|
||||
function pullGitBranch(): GitPullResult {
|
||||
try {
|
||||
const output = execSync("git pull", { encoding: "utf-8", timeout: 30000 });
|
||||
return { success: true, message: output.trim() };
|
||||
} catch (err: any) {
|
||||
const message = err.message || String(err);
|
||||
if (message.includes("CONFLICT") || message.includes("Merge conflict")) {
|
||||
return { success: false, message: "Merge conflict detected. Resolve manually.", conflict: true };
|
||||
}
|
||||
throw new Error(message || "Pull failed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Result of a push operation */
|
||||
export interface GitPushResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push the current branch.
|
||||
*/
|
||||
function pushGitBranch(): GitPushResult {
|
||||
try {
|
||||
const output = execSync("git push", { encoding: "utf-8", timeout: 30000 });
|
||||
return { success: true, message: output.trim() || "Push completed" };
|
||||
} catch (err: any) {
|
||||
const message = err.message || String(err);
|
||||
if (message.includes("rejected") || message.includes("non-fast-forward")) {
|
||||
throw new Error("Push rejected. Pull latest changes first.");
|
||||
}
|
||||
if (message.includes("Could not resolve host") || message.includes("Connection refused")) {
|
||||
throw new Error("Failed to connect to remote");
|
||||
}
|
||||
throw new Error(message || "Push failed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-repo GitHub API rate limiter.
|
||||
* Tracks requests per repo and enforces 60 requests per hour per repo.
|
||||
@@ -555,6 +953,265 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/git/status
|
||||
* Returns current git status: branch, commit hash, dirty state, ahead/behind counts.
|
||||
* Response: { branch: string, commit: string, isDirty: boolean, ahead: number, behind: number }
|
||||
*/
|
||||
router.get("/git/status", (_req, res) => {
|
||||
try {
|
||||
if (!isGitRepo()) {
|
||||
res.status(400).json({ error: "Not a git repository" });
|
||||
return;
|
||||
}
|
||||
const status = getGitStatus();
|
||||
if (!status) {
|
||||
res.status(500).json({ error: "Failed to get git status" });
|
||||
return;
|
||||
}
|
||||
res.json(status);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/git/commits
|
||||
* Returns recent commits (default 20, configurable via ?limit=).
|
||||
* Response: Array of GitCommit objects
|
||||
*/
|
||||
router.get("/git/commits", (req, res) => {
|
||||
try {
|
||||
if (!isGitRepo()) {
|
||||
res.status(400).json({ error: "Not a git repository" });
|
||||
return;
|
||||
}
|
||||
const limit = Math.min(parseInt(req.query.limit as string, 10) || 20, 100);
|
||||
const commits = getGitCommits(limit);
|
||||
res.json(commits);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/git/commits/:hash/diff
|
||||
* Returns diff for a specific commit (stat + patch).
|
||||
* Response: { stat: string, patch: string }
|
||||
*/
|
||||
router.get("/git/commits/:hash/diff", (req, res) => {
|
||||
try {
|
||||
if (!isGitRepo()) {
|
||||
res.status(400).json({ error: "Not a git repository" });
|
||||
return;
|
||||
}
|
||||
const { hash } = req.params;
|
||||
// Validate hash format (only hex characters, 7-40 chars)
|
||||
if (!/^[a-f0-9]{7,40}$/i.test(hash)) {
|
||||
res.status(400).json({ error: "Invalid commit hash format" });
|
||||
return;
|
||||
}
|
||||
const diff = getCommitDiff(hash);
|
||||
if (!diff) {
|
||||
res.status(404).json({ error: "Commit not found" });
|
||||
return;
|
||||
}
|
||||
res.json(diff);
|
||||
} 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.
|
||||
* Response: Array of GitBranch objects
|
||||
*/
|
||||
router.get("/git/branches", (_req, res) => {
|
||||
try {
|
||||
if (!isGitRepo()) {
|
||||
res.status(400).json({ error: "Not a git repository" });
|
||||
return;
|
||||
}
|
||||
const branches = getGitBranches();
|
||||
res.json(branches);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/git/worktrees
|
||||
* Returns all worktrees with path, branch, isMain, and associated task ID.
|
||||
* Response: Array of GitWorktree objects
|
||||
*/
|
||||
router.get("/git/worktrees", async (_req, res) => {
|
||||
try {
|
||||
if (!isGitRepo()) {
|
||||
res.status(400).json({ error: "Not a git repository" });
|
||||
return;
|
||||
}
|
||||
// Get tasks to correlate with worktrees
|
||||
const tasks = await store.listTasks();
|
||||
const worktrees = getGitWorktrees(tasks);
|
||||
res.json(worktrees);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Git Action Routes ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* POST /api/git/branches
|
||||
* Create a new branch from current HEAD or specified base.
|
||||
* Body: { name: string, base?: string }
|
||||
*/
|
||||
router.post("/git/branches", async (req, res) => {
|
||||
try {
|
||||
if (!isGitRepo()) {
|
||||
res.status(400).json({ error: "Not a git repository" });
|
||||
return;
|
||||
}
|
||||
const { name, base } = req.body;
|
||||
if (!name || typeof name !== "string") {
|
||||
res.status(400).json({ error: "name is required" });
|
||||
return;
|
||||
}
|
||||
const branchName = createGitBranch(name, base);
|
||||
res.status(201).json({ name: branchName, created: true });
|
||||
} catch (err: any) {
|
||||
if (err.message.includes("Invalid branch name")) {
|
||||
res.status(400).json({ error: err.message });
|
||||
} else if (err.message.includes("already exists")) {
|
||||
res.status(409).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/git/branches/:name/checkout
|
||||
* Checkout an existing branch.
|
||||
*/
|
||||
router.post("/git/branches/:name/checkout", async (req, res) => {
|
||||
try {
|
||||
if (!isGitRepo()) {
|
||||
res.status(400).json({ error: "Not a git repository" });
|
||||
return;
|
||||
}
|
||||
const { name } = req.params;
|
||||
checkoutGitBranch(name);
|
||||
res.json({ checkedOut: name });
|
||||
} catch (err: any) {
|
||||
if (err.message.includes("Invalid branch name")) {
|
||||
res.status(400).json({ error: err.message });
|
||||
} else if (err.message.includes("Uncommitted changes")) {
|
||||
res.status(409).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/git/branches/:name
|
||||
* Delete a branch.
|
||||
* Query: ?force=true to force delete (even with unmerged commits)
|
||||
*/
|
||||
router.delete("/git/branches/:name", async (req, res) => {
|
||||
try {
|
||||
if (!isGitRepo()) {
|
||||
res.status(400).json({ error: "Not a git repository" });
|
||||
return;
|
||||
}
|
||||
const { name } = req.params;
|
||||
const force = req.query.force === "true";
|
||||
deleteGitBranch(name, force);
|
||||
res.json({ deleted: name });
|
||||
} catch (err: any) {
|
||||
if (err.message.includes("Invalid branch name")) {
|
||||
res.status(400).json({ error: err.message });
|
||||
} else if (err.message.includes("Cannot delete branch") || err.message.includes("is currently checked out")) {
|
||||
res.status(409).json({ error: err.message });
|
||||
} else if (err.message.includes("not fully merged")) {
|
||||
res.status(409).json({ error: "Branch has unmerged commits. Use force=true to delete anyway." });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/git/fetch
|
||||
* Fetch from origin or specified remote.
|
||||
* Body: { remote?: string }
|
||||
*/
|
||||
router.post("/git/fetch", async (req, res) => {
|
||||
try {
|
||||
if (!isGitRepo()) {
|
||||
res.status(400).json({ error: "Not a git repository" });
|
||||
return;
|
||||
}
|
||||
const { remote } = req.body;
|
||||
const result = fetchGitRemote(remote || "origin");
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (err.message.includes("Invalid remote name")) {
|
||||
res.status(400).json({ error: err.message });
|
||||
} else if (err.message.includes("Failed to connect")) {
|
||||
res.status(503).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/git/pull
|
||||
* Pull the current branch.
|
||||
*/
|
||||
router.post("/git/pull", async (_req, res) => {
|
||||
try {
|
||||
if (!isGitRepo()) {
|
||||
res.status(400).json({ error: "Not a git repository" });
|
||||
return;
|
||||
}
|
||||
const result = pullGitBranch();
|
||||
if (result.conflict) {
|
||||
res.status(409).json(result);
|
||||
} else {
|
||||
res.json(result);
|
||||
}
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/git/push
|
||||
* Push the current branch.
|
||||
*/
|
||||
router.post("/git/push", async (_req, res) => {
|
||||
try {
|
||||
if (!isGitRepo()) {
|
||||
res.status(400).json({ error: "Not a git repository" });
|
||||
return;
|
||||
}
|
||||
const result = pushGitBranch();
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
if (err.message.includes("rejected") || err.message.includes("Pull latest")) {
|
||||
res.status(409).json({ error: err.message });
|
||||
} else if (err.message.includes("Failed to connect")) {
|
||||
res.status(503).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── GitHub Import Routes ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,5 +13,6 @@ export default defineConfig({
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
include: ["app/**/*.test.{ts,tsx}", "src/**/*.test.{ts,tsx}"],
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
},
|
||||
});
|
||||
|
||||
1
packages/dashboard/vitest.setup.ts
Normal file
1
packages/dashboard/vitest.setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom";
|
||||
Reference in New Issue
Block a user