feat: add simple git diff preview modal

This commit is contained in:
d-kimsuon
2025-09-07 14:21:42 +09:00
parent 7fafb183f0
commit c5688310b6
20 changed files with 2085 additions and 0 deletions

View File

@@ -12,6 +12,9 @@ import { getEventBus } from "../service/events/EventBus";
import { getFileWatcher } from "../service/events/fileWatcher";
import { sseEventResponse } from "../service/events/sseEventResponse";
import { getFileCompletion } from "../service/file-completion/getFileCompletion";
import { getBranches } from "../service/git/getBranches";
import { getCommits } from "../service/git/getCommits";
import { getDiff } from "../service/git/getDiff";
import { getMcpList } from "../service/mcp/getMcpList";
import { getProject } from "../service/project/getProject";
import { getProjects } from "../service/project/getProjects";
@@ -202,6 +205,81 @@ export const routes = (app: HonoAppType) => {
});
})
.get("/projects/:projectId/git/branches", async (c) => {
const { projectId } = c.req.param();
const { project } = await getProject(projectId);
if (project.meta.projectPath === null) {
return c.json({ error: "Project path not found" }, 400);
}
try {
const result = await getBranches(project.meta.projectPath);
return c.json(result);
} catch (error) {
console.error("Get branches error:", error);
if (error instanceof Error) {
return c.json({ error: error.message }, 400);
}
return c.json({ error: "Failed to get branches" }, 500);
}
})
.get("/projects/:projectId/git/commits", async (c) => {
const { projectId } = c.req.param();
const { project } = await getProject(projectId);
if (project.meta.projectPath === null) {
return c.json({ error: "Project path not found" }, 400);
}
try {
const result = await getCommits(project.meta.projectPath);
return c.json(result);
} catch (error) {
console.error("Get commits error:", error);
if (error instanceof Error) {
return c.json({ error: error.message }, 400);
}
return c.json({ error: "Failed to get commits" }, 500);
}
})
.post(
"/projects/:projectId/git/diff",
zValidator(
"json",
z.object({
fromRef: z.string().min(1, "fromRef is required"),
toRef: z.string().min(1, "toRef is required"),
}),
),
async (c) => {
const { projectId } = c.req.param();
const { fromRef, toRef } = c.req.valid("json");
const { project } = await getProject(projectId);
if (project.meta.projectPath === null) {
return c.json({ error: "Project path not found" }, 400);
}
try {
const result = await getDiff(
project.meta.projectPath,
fromRef,
toRef,
);
return c.json(result);
} catch (error) {
console.error("Get diff error:", error);
if (error instanceof Error) {
return c.json({ error: error.message }, 400);
}
return c.json({ error: "Failed to get diff" }, 500);
}
},
)
.get("/mcp/list", async (c) => {
const { servers } = await getMcpList();
return c.json({ servers });

View File

@@ -0,0 +1,130 @@
import type { GitBranch, GitResult } from "./types";
import { executeGitCommand, parseLines } from "./utils";
/**
* Get all branches (local and remote) in the repository
*/
export async function getBranches(
cwd: string,
): Promise<GitResult<GitBranch[]>> {
// Get all branches with verbose information
const result = await executeGitCommand(["branch", "-vv", "--all"], cwd);
if (!result.success) {
return result as GitResult<GitBranch[]>;
}
try {
const lines = parseLines(result.data);
const branches: GitBranch[] = [];
const seenBranches = new Set<string>();
for (const line of lines) {
// Parse branch line format: " main abc1234 [origin/main: ahead 1] Commit message"
const match = line.match(
/^(\*?\s*)([^\s]+)\s+([a-f0-9]+)(?:\s+\[([^\]]+)\])?\s*(.*)/,
);
if (!match) continue;
const [, prefix, name, commit, tracking] = match;
if (!prefix || !name || !commit) continue;
const current = prefix.includes("*");
// Skip remote tracking branches if we already have the local branch
const cleanName = name.replace("remotes/origin/", "");
if (name.startsWith("remotes/origin/") && seenBranches.has(cleanName)) {
continue;
}
// Parse tracking information
let remote: string | undefined;
let ahead: number | undefined;
let behind: number | undefined;
if (tracking) {
const remoteMatch = tracking.match(/^([^:]+)/);
if (remoteMatch?.[1]) {
remote = remoteMatch[1];
}
const aheadMatch = tracking.match(/ahead (\d+)/);
const behindMatch = tracking.match(/behind (\d+)/);
if (aheadMatch?.[1]) ahead = parseInt(aheadMatch[1], 10);
if (behindMatch?.[1]) behind = parseInt(behindMatch[1], 10);
}
branches.push({
name: cleanName,
current,
remote,
commit,
ahead,
behind,
});
seenBranches.add(cleanName);
}
return {
success: true,
data: branches,
};
} catch (error) {
return {
success: false,
error: {
code: "PARSE_ERROR",
message: `Failed to parse branch information: ${error instanceof Error ? error.message : "Unknown error"}`,
},
};
}
}
/**
* Get current branch name
*/
export async function getCurrentBranch(
cwd: string,
): Promise<GitResult<string>> {
const result = await executeGitCommand(["branch", "--show-current"], cwd);
if (!result.success) {
return result as GitResult<string>;
}
const currentBranch = result.data.trim();
if (!currentBranch) {
return {
success: false,
error: {
code: "COMMAND_FAILED",
message: "Could not determine current branch (possibly detached HEAD)",
},
};
}
return {
success: true,
data: currentBranch,
};
}
/**
* Check if a branch exists
*/
export async function branchExists(
cwd: string,
branchName: string,
): Promise<GitResult<boolean>> {
const result = await executeGitCommand(
["rev-parse", "--verify", branchName],
cwd,
);
return {
success: true,
data: result.success,
};
}

View File

@@ -0,0 +1,51 @@
import type { GitCommit, GitResult } from "./types";
import { executeGitCommand, parseLines } from "./utils";
/**
* Get the last 20 commits from the current branch
*/
export async function getCommits(cwd: string): Promise<GitResult<GitCommit[]>> {
// Get commits with oneline format and limit to 20
const result = await executeGitCommand(
["log", "--oneline", "-n", "20", "--format=%H|%s|%an|%ad", "--date=iso"],
cwd,
);
if (!result.success) {
return result as GitResult<GitCommit[]>;
}
try {
const lines = parseLines(result.data);
const commits: GitCommit[] = [];
for (const line of lines) {
// Parse commit line format: "sha|message|author|date"
const parts = line.split("|");
if (parts.length < 4) continue;
const [sha, message, author, date] = parts;
if (!sha || !message || !author || !date) continue;
commits.push({
sha: sha.trim(),
message: message.trim(),
author: author.trim(),
date: date.trim(),
});
}
return {
success: true,
data: commits,
};
} catch (error) {
return {
success: false,
error: {
code: "PARSE_ERROR",
message: `Failed to parse commit information: ${error instanceof Error ? error.message : "Unknown error"}`,
},
};
}
}

View File

@@ -0,0 +1,283 @@
import parseGitDiff, {
type AnyChunk,
type AnyFileChange,
} from "parse-git-diff";
import type {
GitComparisonResult,
GitDiff,
GitDiffFile,
GitDiffHunk,
GitDiffLine,
GitResult,
} from "./types";
import { executeGitCommand, parseLines } from "./utils";
/**
* Convert parse-git-diff file change to GitDiffFile
*/
function convertToGitDiffFile(
fileChange: AnyFileChange,
fileStats: Map<string, { additions: number; deletions: number }>,
): GitDiffFile {
let filePath: string;
let status: GitDiffFile["status"];
let oldPath: string | undefined;
switch (fileChange.type) {
case "AddedFile":
filePath = fileChange.path;
status = "added";
break;
case "DeletedFile":
filePath = fileChange.path;
status = "deleted";
break;
case "RenamedFile":
filePath = fileChange.pathAfter;
oldPath = fileChange.pathBefore;
status = "renamed";
break;
case "ChangedFile":
filePath = fileChange.path;
status = "modified";
break;
default:
// Fallback for any unknown types
filePath = "";
status = "modified";
}
// Get stats from numstat
const stats = fileStats.get(filePath) ||
fileStats.get(oldPath || "") || { additions: 0, deletions: 0 };
return {
filePath,
status,
additions: stats.additions,
deletions: stats.deletions,
oldPath,
};
}
/**
* Convert parse-git-diff chunk to GitDiffHunk
*/
function convertToGitDiffHunk(chunk: AnyChunk): GitDiffHunk {
if (chunk.type !== "Chunk") {
// For non-standard chunks, return empty hunk
return {
oldStart: 0,
oldCount: 0,
newStart: 0,
newCount: 0,
header: "",
lines: [],
};
}
const lines: GitDiffLine[] = [];
for (const change of chunk.changes) {
let line: GitDiffLine;
switch (change.type) {
case "AddedLine":
line = {
type: "added",
content: change.content,
newLineNumber: change.lineAfter,
};
break;
case "DeletedLine":
line = {
type: "deleted",
content: change.content,
oldLineNumber: change.lineBefore,
};
break;
case "UnchangedLine":
line = {
type: "context",
content: change.content,
oldLineNumber: change.lineBefore,
newLineNumber: change.lineAfter,
};
break;
case "MessageLine":
// This is likely a hunk header or context line
line = {
type: "context",
content: change.content,
};
break;
default:
// Fallback for unknown line types
line = {
type: "context",
content: "",
};
}
lines.push(line);
}
return {
oldStart: chunk.fromFileRange.start,
oldCount: chunk.fromFileRange.lines,
newStart: chunk.toFileRange.start,
newCount: chunk.toFileRange.lines,
header: `@@ -${chunk.fromFileRange.start},${chunk.fromFileRange.lines} +${chunk.toFileRange.start},${chunk.toFileRange.lines} @@${chunk.context ? ` ${chunk.context}` : ""}`,
lines,
};
}
const extractRef = (refText: string) => {
const [group, ref] = refText.split(":");
if (group === undefined || ref === undefined) {
if (refText === "HEAD") {
return "HEAD";
}
if (refText === "working") {
return undefined;
}
throw new Error(`Invalid ref text: ${refText}`);
}
return ref;
};
/**
* Get Git diff between two references (branches, commits, tags)
*/
export const getDiff = async (
cwd: string,
fromRefText: string,
toRefText: string,
): Promise<GitResult<GitComparisonResult>> => {
const fromRef = extractRef(fromRefText);
const toRef = extractRef(toRefText);
if (fromRef === toRef) {
return {
success: true,
data: {
diffs: [],
files: [],
summary: {
totalFiles: 0,
totalAdditions: 0,
totalDeletions: 0,
},
},
};
}
if (fromRef === undefined) {
throw new Error(`Invalid fromRef: ${fromRefText}`);
}
const commandArgs = toRef === undefined ? [fromRef] : [fromRef, toRef];
// Get diff with numstat for file statistics
const numstatResult = await executeGitCommand(
["diff", "--numstat", ...commandArgs],
cwd,
);
if (!numstatResult.success) {
return numstatResult;
}
// Get diff with full content
const diffResult = await executeGitCommand(
["diff", "--unified=5", ...commandArgs],
cwd,
);
if (!diffResult.success) {
return diffResult;
}
try {
// Parse numstat output to get file statistics
const fileStats = new Map<
string,
{ additions: number; deletions: number }
>();
const numstatLines = parseLines(numstatResult.data);
for (const line of numstatLines) {
const parts = line.split("\t");
if (parts.length >= 3 && parts[0] && parts[1] && parts[2]) {
const additions = parts[0] === "-" ? 0 : parseInt(parts[0], 10);
const deletions = parts[1] === "-" ? 0 : parseInt(parts[1], 10);
const filePath = parts[2];
fileStats.set(filePath, { additions, deletions });
}
}
// Parse diff output using parse-git-diff
const parsedDiff = parseGitDiff(diffResult.data);
const files: GitDiffFile[] = [];
const diffs: GitDiff[] = [];
let totalAdditions = 0;
let totalDeletions = 0;
for (const fileChange of parsedDiff.files) {
// Convert to GitDiffFile format
const file = convertToGitDiffFile(fileChange, fileStats);
files.push(file);
// Convert chunks to hunks
const hunks: GitDiffHunk[] = [];
for (const chunk of fileChange.chunks) {
const hunk = convertToGitDiffHunk(chunk);
hunks.push(hunk);
}
diffs.push({
file,
hunks,
});
totalAdditions += file.additions;
totalDeletions += file.deletions;
}
return {
success: true,
data: {
files,
diffs,
summary: {
totalFiles: files.length,
totalAdditions,
totalDeletions,
},
},
};
} catch (error) {
return {
success: false,
error: {
code: "PARSE_ERROR",
message: `Failed to parse diff: ${error instanceof Error ? error.message : "Unknown error"}`,
},
};
}
};
/**
* Compare between two branches (shorthand for getDiff)
*/
export async function compareBranches(
cwd: string,
baseBranch: string,
targetBranch: string,
): Promise<GitResult<GitComparisonResult>> {
return getDiff(cwd, baseBranch, targetBranch);
}

View File

@@ -0,0 +1,172 @@
import type { GitDiffFile, GitResult, GitStatus } from "./types";
import {
executeGitCommand,
getFileStatus,
parseLines,
parseStatusLine,
} from "./utils";
/**
* Get git status information including staged, unstaged, and untracked files
*/
export async function getStatus(cwd: string): Promise<GitResult<GitStatus>> {
// Get porcelain status for consistent parsing
const statusResult = await executeGitCommand(
["status", "--porcelain=v1", "-b"],
cwd,
);
if (!statusResult.success) {
return statusResult as GitResult<GitStatus>;
}
try {
const lines = parseLines(statusResult.data);
const staged: GitDiffFile[] = [];
const unstaged: GitDiffFile[] = [];
const untracked: string[] = [];
const conflicted: string[] = [];
let branch = "HEAD";
let ahead = 0;
let behind = 0;
for (const line of lines) {
// Parse branch line
if (line.startsWith("##")) {
const branchMatch = line.match(/^##\s+(.+?)(?:\.\.\.|$)/);
if (branchMatch?.[1]) {
branch = branchMatch[1];
}
const aheadMatch = line.match(/ahead (\d+)/);
const behindMatch = line.match(/behind (\d+)/);
if (aheadMatch?.[1]) ahead = parseInt(aheadMatch[1], 10);
if (behindMatch?.[1]) behind = parseInt(behindMatch[1], 10);
continue;
}
// Parse file status lines
const { status, filePath, oldPath } = parseStatusLine(line);
const indexStatus = status[0]; // Staged changes
const workTreeStatus = status[1]; // Unstaged changes
// Handle conflicts (both index and work tree have changes)
if (
indexStatus === "U" ||
workTreeStatus === "U" ||
(indexStatus !== " " &&
indexStatus !== "?" &&
workTreeStatus !== " " &&
workTreeStatus !== "?")
) {
conflicted.push(filePath);
continue;
}
// Handle staged changes (index status)
if (indexStatus !== " " && indexStatus !== "?") {
staged.push({
filePath,
status: getFileStatus(`${indexStatus} `),
additions: 0, // We don't have line counts from porcelain status
deletions: 0,
oldPath,
});
}
// Handle unstaged changes (work tree status)
if (workTreeStatus !== " " && workTreeStatus !== "?") {
if (workTreeStatus === "?") {
untracked.push(filePath);
} else {
unstaged.push({
filePath,
status: getFileStatus(` ${workTreeStatus}`),
additions: 0,
deletions: 0,
oldPath,
});
}
}
// Handle untracked files
if (status === "??") {
untracked.push(filePath);
}
}
return {
success: true,
data: {
branch,
ahead,
behind,
staged,
unstaged,
untracked,
conflicted,
},
};
} catch (error) {
return {
success: false,
error: {
code: "PARSE_ERROR",
message: `Failed to parse git status: ${error instanceof Error ? error.message : "Unknown error"}`,
},
};
}
}
/**
* Get uncommitted changes (both staged and unstaged)
*/
export async function getUncommittedChanges(
cwd: string,
): Promise<GitResult<GitDiffFile[]>> {
const statusResult = await getStatus(cwd);
if (!statusResult.success) {
return statusResult as GitResult<GitDiffFile[]>;
}
const { staged, unstaged } = statusResult.data;
const allChanges = [...staged, ...unstaged];
// Remove duplicates (files that are both staged and unstaged)
const uniqueChanges = allChanges.reduce((acc: GitDiffFile[], change) => {
const existing = acc.find((c) => c.filePath === change.filePath);
if (!existing) {
acc.push(change);
}
return acc;
}, [] as GitDiffFile[]);
return {
success: true,
data: uniqueChanges,
};
}
/**
* Check if the working directory is clean (no uncommitted changes)
*/
export async function isWorkingDirectoryClean(
cwd: string,
): Promise<GitResult<boolean>> {
const statusResult = await getStatus(cwd);
if (!statusResult.success) {
return statusResult as GitResult<boolean>;
}
const { staged, unstaged, untracked } = statusResult.data;
const isClean =
staged.length === 0 && unstaged.length === 0 && untracked.length === 0;
return {
success: true,
data: isClean,
};
}

View File

@@ -0,0 +1,32 @@
// Git service utilities for claude-code-viewer
// Provides comprehensive Git operations including branch management, diff generation, and status checking
export * from "./getBranches";
// Re-export main functions for convenience
export { branchExists, getBranches, getCurrentBranch } from "./getBranches";
export * from "./getCommits";
export { getCommits } from "./getCommits";
export * from "./getDiff";
export { compareBranches, getDiff } from "./getDiff";
export * from "./getStatus";
export {
getStatus,
getUncommittedChanges,
isWorkingDirectoryClean,
} from "./getStatus";
// Types re-export for convenience
export type {
GitBranch,
GitCommit,
GitComparisonResult,
GitDiff,
GitDiffFile,
GitDiffHunk,
GitDiffLine,
GitError,
GitResult,
GitStatus,
} from "./types";
export * from "./types";
export * from "./utils";
export { executeGitCommand, isGitRepository } from "./utils";

View File

@@ -0,0 +1,85 @@
export type GitBranch = {
name: string;
current: boolean;
remote?: string;
commit: string;
ahead?: number;
behind?: number;
};
export type GitCommit = {
sha: string;
message: string;
author: string;
date: string;
};
export type GitDiffFile = {
filePath: string;
status: "added" | "modified" | "deleted" | "renamed" | "copied";
additions: number;
deletions: number;
oldPath?: string; // For renamed files
};
export type GitDiffHunk = {
oldStart: number;
oldCount: number;
newStart: number;
newCount: number;
header: string;
lines: GitDiffLine[];
};
export type GitDiffLine = {
type: "context" | "added" | "deleted";
content: string;
oldLineNumber?: number;
newLineNumber?: number;
};
export type GitDiff = {
file: GitDiffFile;
hunks: GitDiffHunk[];
};
export type GitComparisonResult = {
files: GitDiffFile[];
diffs: GitDiff[];
summary: {
totalFiles: number;
totalAdditions: number;
totalDeletions: number;
};
};
export type GitStatus = {
branch: string;
ahead: number;
behind: number;
staged: GitDiffFile[];
unstaged: GitDiffFile[];
untracked: string[];
conflicted: string[];
};
export type GitError = {
code:
| "NOT_A_REPOSITORY"
| "BRANCH_NOT_FOUND"
| "COMMAND_FAILED"
| "PARSE_ERROR";
message: string;
command?: string;
stderr?: string;
};
export type GitResult<T> =
| {
success: true;
data: T;
}
| {
success: false;
error: GitError;
};

View File

@@ -0,0 +1,142 @@
import { execFile } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { promisify } from "node:util";
import type { GitError, GitResult } from "./types";
const execFileAsync = promisify(execFile);
/**
* Execute a git command in the specified directory
*/
export async function executeGitCommand(
args: string[],
cwd: string,
): Promise<GitResult<string>> {
try {
// Check if the directory exists and contains a git repository
if (!existsSync(cwd)) {
return {
success: false,
error: {
code: "NOT_A_REPOSITORY",
message: `Directory does not exist: ${cwd}`,
command: `git ${args.join(" ")}`,
},
};
}
if (!existsSync(resolve(cwd, ".git"))) {
return {
success: false,
error: {
code: "NOT_A_REPOSITORY",
message: `Not a git repository: ${cwd}`,
command: `git ${args.join(" ")}`,
},
};
}
const { stdout } = await execFileAsync("git", args, {
cwd,
maxBuffer: 10 * 1024 * 1024, // 10MB buffer for large diffs
timeout: 30000, // 30 second timeout
});
return {
success: true,
data: stdout,
};
} catch (error: unknown) {
const err = error as { code?: string; stderr?: string; message?: string };
let errorCode: GitError["code"] = "COMMAND_FAILED";
let errorMessage = err.message || "Unknown git command error";
if (err.stderr) {
if (err.stderr.includes("not a git repository")) {
errorCode = "NOT_A_REPOSITORY";
errorMessage = "Not a git repository";
} else if (err.stderr.includes("unknown revision")) {
errorCode = "BRANCH_NOT_FOUND";
errorMessage = "Branch or commit not found";
}
}
return {
success: false,
error: {
code: errorCode,
message: errorMessage,
command: `git ${args.join(" ")}`,
stderr: err.stderr,
},
};
}
}
/**
* Check if a directory is a git repository
*/
export function isGitRepository(cwd: string): boolean {
return existsSync(cwd) && existsSync(resolve(cwd, ".git"));
}
/**
* Safely parse git command output that might be empty
*/
export function parseLines(output: string): string[] {
return output
.trim()
.split("\n")
.filter((line) => line.trim() !== "");
}
/**
* Parse git status porcelain output
*/
export function parseStatusLine(line: string): {
status: string;
filePath: string;
oldPath?: string;
} {
const status = line.slice(0, 2);
const filePath = line.slice(3);
// Handle renamed files (R old -> new)
if (status.startsWith("R")) {
const parts = filePath.split(" -> ");
return {
status,
filePath: parts[1] || filePath,
oldPath: parts[0],
};
}
return { status, filePath };
}
/**
* Convert git status code to readable status
*/
export function getFileStatus(
statusCode: string,
): "added" | "modified" | "deleted" | "renamed" | "copied" {
const firstChar = statusCode[0];
switch (firstChar) {
case "A":
return "added";
case "M":
return "modified";
case "D":
return "deleted";
case "R":
return "renamed";
case "C":
return "copied";
default:
return "modified";
}
}