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:
gsxdsm
2026-03-29 19:25:39 -07:00
parent aa8a78f023
commit 3f2fb0912b
15 changed files with 2926 additions and 1 deletions

View File

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

View File

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

View File

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

View 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>
);
}

View File

@@ -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" : ""}`}

View File

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

View File

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