mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2026-01-22 23:14:21 +01:00
refactor: move directories
This commit is contained in:
369
src/server/core/git/functions/getBranches.test.ts
Normal file
369
src/server/core/git/functions/getBranches.test.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { branchExists, getBranches, getCurrentBranch } from "./getBranches";
|
||||
import * as utils from "./utils";
|
||||
|
||||
vi.mock("./utils", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof utils>();
|
||||
return {
|
||||
...actual,
|
||||
executeGitCommand: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("getBranches", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("正常系", () => {
|
||||
it("ブランチ一覧を取得できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `* main abc1234 [origin/main: ahead 1] Latest commit
|
||||
remotes/origin/main abc1234 Latest commit
|
||||
feature def5678 [origin/feature] Feature commit`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getBranches(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
|
||||
expect(result.data[0]).toEqual({
|
||||
name: "main",
|
||||
current: true,
|
||||
remote: "origin/main",
|
||||
commit: "abc1234",
|
||||
ahead: 1,
|
||||
behind: undefined,
|
||||
});
|
||||
|
||||
expect(result.data[1]).toEqual({
|
||||
name: "feature",
|
||||
current: false,
|
||||
remote: "origin/feature",
|
||||
commit: "def5678",
|
||||
ahead: undefined,
|
||||
behind: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
expect(utils.executeGitCommand).toHaveBeenCalledWith(
|
||||
["branch", "-vv", "--all"],
|
||||
mockCwd,
|
||||
);
|
||||
});
|
||||
|
||||
it("ahead/behindの両方を持つブランチを処理できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput =
|
||||
"* main abc1234 [origin/main: ahead 2, behind 3] Commit message";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getBranches(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0]).toEqual({
|
||||
name: "main",
|
||||
current: true,
|
||||
remote: "origin/main",
|
||||
commit: "abc1234",
|
||||
ahead: 2,
|
||||
behind: 3,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("リモートトラッキングブランチを除外する", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `* main abc1234 [origin/main] Latest commit
|
||||
remotes/origin/main abc1234 Latest commit
|
||||
feature def5678 Feature commit
|
||||
remotes/origin/feature def5678 Feature commit`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getBranches(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0]?.name).toBe("main");
|
||||
expect(result.data[1]?.name).toBe("feature");
|
||||
}
|
||||
});
|
||||
|
||||
it("空の結果を返す(ブランチがない場合)", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = "";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getBranches(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("不正な形式の行をスキップする", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `* main abc1234 [origin/main] Latest commit
|
||||
invalid line
|
||||
feature def5678 Feature commit`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getBranches(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0]?.name).toBe("main");
|
||||
expect(result.data[1]?.name).toBe("feature");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("エラー系", () => {
|
||||
it("ディレクトリが存在しない場合", async () => {
|
||||
const mockCwd = "/nonexistent/repo";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "NOT_A_REPOSITORY",
|
||||
message: `Directory does not exist: ${mockCwd}`,
|
||||
command: "git branch -vv --all",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getBranches(mockCwd);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("NOT_A_REPOSITORY");
|
||||
expect(result.error.message).toContain("Directory does not exist");
|
||||
}
|
||||
});
|
||||
|
||||
it("Gitリポジトリでない場合", async () => {
|
||||
const mockCwd = "/test/not-a-repo";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "NOT_A_REPOSITORY",
|
||||
message: `Not a git repository: ${mockCwd}`,
|
||||
command: "git branch -vv --all",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getBranches(mockCwd);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("NOT_A_REPOSITORY");
|
||||
expect(result.error.message).toContain("Not a git repository");
|
||||
}
|
||||
});
|
||||
|
||||
it("Gitコマンドが失敗した場合", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "COMMAND_FAILED",
|
||||
message: "Command failed",
|
||||
command: "git branch",
|
||||
stderr: "fatal: not a git repository",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getBranches(mockCwd);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("COMMAND_FAILED");
|
||||
expect(result.error.message).toBe("Command failed");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("エッジケース", () => {
|
||||
it("特殊文字を含むブランチ名を処理できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `* feature/special-chars_123 abc1234 Commit
|
||||
feature/日本語ブランチ def5678 日本語コミット`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getBranches(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0]?.name).toBe("feature/special-chars_123");
|
||||
expect(result.data[1]?.name).toBe("feature/日本語ブランチ");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCurrentBranch", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("現在のブランチ名を取得できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = "main\n";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getCurrentBranch(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toBe("main");
|
||||
}
|
||||
|
||||
expect(utils.executeGitCommand).toHaveBeenCalledWith(
|
||||
["branch", "--show-current"],
|
||||
mockCwd,
|
||||
);
|
||||
});
|
||||
|
||||
it("detached HEAD状態の場合はエラーを返す", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = "";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getCurrentBranch(mockCwd);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("COMMAND_FAILED");
|
||||
expect(result.error.message).toContain("detached HEAD");
|
||||
}
|
||||
});
|
||||
|
||||
it("Gitリポジトリでない場合", async () => {
|
||||
const mockCwd = "/test/not-a-repo";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "NOT_A_REPOSITORY",
|
||||
message: `Not a git repository: ${mockCwd}`,
|
||||
command: "git branch --show-current",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getCurrentBranch(mockCwd);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("NOT_A_REPOSITORY");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("branchExists", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("ブランチが存在する場合trueを返す", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: "abc1234\n",
|
||||
});
|
||||
|
||||
const result = await branchExists(mockCwd, "main");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toBe(true);
|
||||
}
|
||||
|
||||
expect(utils.executeGitCommand).toHaveBeenCalledWith(
|
||||
["rev-parse", "--verify", "main"],
|
||||
mockCwd,
|
||||
);
|
||||
});
|
||||
|
||||
it("ブランチが存在しない場合falseを返す", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "COMMAND_FAILED",
|
||||
message: "Command failed",
|
||||
command: "git rev-parse --verify nonexistent",
|
||||
stderr: "fatal: Needed a single revision",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await branchExists(mockCwd, "nonexistent");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("Gitリポジトリでない場合", async () => {
|
||||
const mockCwd = "/test/not-a-repo";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "NOT_A_REPOSITORY",
|
||||
message: `Not a git repository: ${mockCwd}`,
|
||||
command: "git rev-parse --verify main",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await branchExists(mockCwd, "main");
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
130
src/server/core/git/functions/getBranches.ts
Normal file
130
src/server/core/git/functions/getBranches.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { executeGitCommand, parseLines } from "../functions/utils";
|
||||
import type { GitBranch, GitResult } from "../types";
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
244
src/server/core/git/functions/getCommits.test.ts
Normal file
244
src/server/core/git/functions/getCommits.test.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getCommits } from "./getCommits";
|
||||
import * as utils from "./utils";
|
||||
|
||||
vi.mock("./utils", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof utils>();
|
||||
return {
|
||||
...actual,
|
||||
executeGitCommand: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("getCommits", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("正常系", () => {
|
||||
it("コミット一覧を取得できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `abc123|feat: add new feature|John Doe|2024-01-15 10:30:00 +0900
|
||||
def456|fix: bug fix|Jane Smith|2024-01-14 09:20:00 +0900
|
||||
ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getCommits(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(3);
|
||||
expect(result.data[0]).toEqual({
|
||||
sha: "abc123",
|
||||
message: "feat: add new feature",
|
||||
author: "John Doe",
|
||||
date: "2024-01-15 10:30:00 +0900",
|
||||
});
|
||||
expect(result.data[1]).toEqual({
|
||||
sha: "def456",
|
||||
message: "fix: bug fix",
|
||||
author: "Jane Smith",
|
||||
date: "2024-01-14 09:20:00 +0900",
|
||||
});
|
||||
expect(result.data[2]).toEqual({
|
||||
sha: "ghi789",
|
||||
message: "chore: update deps",
|
||||
author: "Bob Johnson",
|
||||
date: "2024-01-13 08:10:00 +0900",
|
||||
});
|
||||
}
|
||||
|
||||
expect(utils.executeGitCommand).toHaveBeenCalledWith(
|
||||
[
|
||||
"log",
|
||||
"--oneline",
|
||||
"-n",
|
||||
"20",
|
||||
"--format=%H|%s|%an|%ad",
|
||||
"--date=iso",
|
||||
],
|
||||
mockCwd,
|
||||
);
|
||||
});
|
||||
|
||||
it("空の結果を返す(コミットがない場合)", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = "";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getCommits(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("不正な形式の行をスキップする", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `abc123|feat: add new feature|John Doe|2024-01-15 10:30:00 +0900
|
||||
invalid line without enough pipes
|
||||
def456|fix: bug fix|Jane Smith|2024-01-14 09:20:00 +0900
|
||||
||missing data|
|
||||
ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getCommits(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(3);
|
||||
expect(result.data[0]?.sha).toBe("abc123");
|
||||
expect(result.data[1]?.sha).toBe("def456");
|
||||
expect(result.data[2]?.sha).toBe("ghi789");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("エラー系", () => {
|
||||
it("ディレクトリが存在しない場合", async () => {
|
||||
const mockCwd = "/nonexistent/repo";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "NOT_A_REPOSITORY",
|
||||
message: `Directory does not exist: ${mockCwd}`,
|
||||
command: "git log --oneline -n 20 --format=%H|%s|%an|%ad --date=iso",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getCommits(mockCwd);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("NOT_A_REPOSITORY");
|
||||
expect(result.error.message).toContain("Directory does not exist");
|
||||
}
|
||||
});
|
||||
|
||||
it("Gitリポジトリでない場合", async () => {
|
||||
const mockCwd = "/test/not-a-repo";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "NOT_A_REPOSITORY",
|
||||
message: `Not a git repository: ${mockCwd}`,
|
||||
command: "git log --oneline -n 20 --format=%H|%s|%an|%ad --date=iso",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getCommits(mockCwd);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("NOT_A_REPOSITORY");
|
||||
expect(result.error.message).toContain("Not a git repository");
|
||||
}
|
||||
});
|
||||
|
||||
it("Gitコマンドが失敗した場合", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "COMMAND_FAILED",
|
||||
message: "Command failed",
|
||||
command: "git log",
|
||||
stderr:
|
||||
"fatal: your current branch 'main' does not have any commits yet",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getCommits(mockCwd);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("COMMAND_FAILED");
|
||||
expect(result.error.message).toBe("Command failed");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("エッジケース", () => {
|
||||
it("特殊文字を含むコミットメッセージを処理できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `abc123|feat: add "quotes" & <special> chars|Author Name|2024-01-15 10:30:00 +0900
|
||||
def456|fix: 日本語メッセージ|日本語 著者|2024-01-14 09:20:00 +0900`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getCommits(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data[0]?.message).toBe(
|
||||
'feat: add "quotes" & <special> chars',
|
||||
);
|
||||
expect(result.data[1]?.message).toBe("fix: 日本語メッセージ");
|
||||
expect(result.data[1]?.author).toBe("日本語 著者");
|
||||
}
|
||||
});
|
||||
|
||||
it("空白を含むパスでも正常に動作する", async () => {
|
||||
const mockCwd = "/test/my repo with spaces";
|
||||
const mockOutput = `abc123|feat: test|Author|2024-01-15 10:30:00 +0900`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getCommits(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(1);
|
||||
}
|
||||
|
||||
expect(utils.executeGitCommand).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
mockCwd,
|
||||
);
|
||||
});
|
||||
|
||||
it("空行やスペースのみの行をスキップする", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `abc123|feat: add feature|Author|2024-01-15 10:30:00 +0900
|
||||
|
||||
|
||||
def456|fix: bug|Author|2024-01-14 09:20:00 +0900
|
||||
`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getCommits(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
51
src/server/core/git/functions/getCommits.ts
Normal file
51
src/server/core/git/functions/getCommits.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { executeGitCommand, parseLines } from "../functions/utils";
|
||||
import type { GitCommit, GitResult } from "../types";
|
||||
|
||||
/**
|
||||
* 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"}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
521
src/server/core/git/functions/getDiff.test.ts
Normal file
521
src/server/core/git/functions/getDiff.test.ts
Normal file
@@ -0,0 +1,521 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { compareBranches, getDiff } from "./getDiff";
|
||||
import * as utils from "./utils";
|
||||
|
||||
vi.mock("./utils", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof utils>();
|
||||
return {
|
||||
...actual,
|
||||
executeGitCommand: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("node:fs/promises");
|
||||
|
||||
describe("getDiff", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("正常系", () => {
|
||||
it("2つのブランチ間のdiffを取得できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const fromRef = "base:main";
|
||||
const toRef = "compare:feature";
|
||||
|
||||
const mockNumstatOutput = `5\t2\tsrc/file1.ts
|
||||
10\t0\tsrc/file2.ts`;
|
||||
|
||||
const mockDiffOutput = `diff --git a/src/file1.ts b/src/file1.ts
|
||||
index abc123..def456 100644
|
||||
--- a/src/file1.ts
|
||||
+++ b/src/file1.ts
|
||||
@@ -1,5 +1,8 @@
|
||||
function hello() {
|
||||
- console.log("old");
|
||||
+ console.log("new");
|
||||
+ console.log("added line 1");
|
||||
+ console.log("added line 2");
|
||||
}
|
||||
diff --git a/src/file2.ts b/src/file2.ts
|
||||
new file mode 100644
|
||||
index 0000000..ghi789
|
||||
--- /dev/null
|
||||
+++ b/src/file2.ts
|
||||
@@ -0,0 +1,10 @@
|
||||
+export const newFile = true;`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand)
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockNumstatOutput,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockDiffOutput,
|
||||
});
|
||||
|
||||
const result = await getDiff(mockCwd, fromRef, toRef);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.files).toHaveLength(2);
|
||||
expect(result.data.files[0]?.filePath).toBe("src/file1.ts");
|
||||
expect(result.data.files[0]?.status).toBe("modified");
|
||||
expect(result.data.files[0]?.additions).toBe(5);
|
||||
expect(result.data.files[0]?.deletions).toBe(2);
|
||||
|
||||
expect(result.data.files[1]?.filePath).toBe("src/file2.ts");
|
||||
expect(result.data.files[1]?.status).toBe("added");
|
||||
expect(result.data.files[1]?.additions).toBe(10);
|
||||
expect(result.data.files[1]?.deletions).toBe(0);
|
||||
|
||||
expect(result.data.summary.totalFiles).toBe(2);
|
||||
expect(result.data.summary.totalAdditions).toBe(15);
|
||||
expect(result.data.summary.totalDeletions).toBe(2);
|
||||
}
|
||||
|
||||
expect(utils.executeGitCommand).toHaveBeenCalledWith(
|
||||
["diff", "--numstat", "main", "feature"],
|
||||
mockCwd,
|
||||
);
|
||||
|
||||
expect(utils.executeGitCommand).toHaveBeenCalledWith(
|
||||
["diff", "--unified=5", "main", "feature"],
|
||||
mockCwd,
|
||||
);
|
||||
});
|
||||
|
||||
it("HEADとworking directoryの比較ができる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const fromRef = "base:HEAD";
|
||||
const toRef = "compare:working";
|
||||
|
||||
const mockNumstatOutput = `3\t1\tsrc/modified.ts`;
|
||||
const mockDiffOutput = `diff --git a/src/modified.ts b/src/modified.ts
|
||||
index abc123..def456 100644
|
||||
--- a/src/modified.ts
|
||||
+++ b/src/modified.ts
|
||||
@@ -1,3 +1,5 @@
|
||||
const value = 1;`;
|
||||
|
||||
const mockStatusOutput = `## main
|
||||
M src/modified.ts
|
||||
?? src/untracked.ts`;
|
||||
|
||||
vi.mocked(readFile).mockResolvedValue("line1\nline2\nline3");
|
||||
|
||||
vi.mocked(utils.executeGitCommand)
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockNumstatOutput,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockDiffOutput,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockStatusOutput,
|
||||
});
|
||||
|
||||
const result = await getDiff(mockCwd, fromRef, toRef);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
// modified file + untracked file
|
||||
expect(result.data.files.length).toBeGreaterThanOrEqual(1);
|
||||
const modifiedFile = result.data.files.find(
|
||||
(f) => f.filePath === "src/modified.ts",
|
||||
);
|
||||
expect(modifiedFile).toBeDefined();
|
||||
expect(modifiedFile?.status).toBe("modified");
|
||||
}
|
||||
});
|
||||
|
||||
it("同一refの場合は空の結果を返す", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const fromRef = "base:main";
|
||||
const toRef = "compare:main";
|
||||
|
||||
const result = await getDiff(mockCwd, fromRef, toRef);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.files).toHaveLength(0);
|
||||
expect(result.data.diffs).toHaveLength(0);
|
||||
expect(result.data.summary.totalFiles).toBe(0);
|
||||
expect(result.data.summary.totalAdditions).toBe(0);
|
||||
expect(result.data.summary.totalDeletions).toBe(0);
|
||||
}
|
||||
|
||||
expect(utils.executeGitCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.skip("削除されたファイルを処理できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const fromRef = "base:main";
|
||||
const toRef = "compare:feature";
|
||||
|
||||
const mockNumstatOutput = `0\t10\tsrc/deleted.ts`;
|
||||
const mockDiffOutput = `diff --git a/src/deleted.ts b/src/deleted.ts
|
||||
deleted file mode 100644
|
||||
index abc123..0000000 100644
|
||||
--- a/src/deleted.ts
|
||||
+++ /dev/null
|
||||
@@ -1,10 +0,0 @@
|
||||
-deleted line 1
|
||||
-deleted line 2
|
||||
-deleted line 3
|
||||
-deleted line 4
|
||||
-deleted line 5
|
||||
-deleted line 6
|
||||
-deleted line 7
|
||||
-deleted line 8
|
||||
-deleted line 9
|
||||
-deleted line 10`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand)
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockNumstatOutput,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockDiffOutput,
|
||||
});
|
||||
|
||||
const result = await getDiff(mockCwd, fromRef, toRef);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.files).toHaveLength(1);
|
||||
expect(result.data.files[0]?.filePath).toBe("src/deleted.ts");
|
||||
expect(result.data.files[0]?.status).toBe("deleted");
|
||||
expect(result.data.files[0]?.additions).toBe(0);
|
||||
expect(result.data.files[0]?.deletions).toBe(10);
|
||||
}
|
||||
});
|
||||
|
||||
it.skip("名前変更されたファイルを処理できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const fromRef = "base:main";
|
||||
const toRef = "compare:feature";
|
||||
|
||||
const mockNumstatOutput = `0\t0\tnew-name.ts`;
|
||||
const mockDiffOutput = `diff --git a/old-name.ts b/new-name.ts
|
||||
similarity index 100%
|
||||
rename from old-name.ts
|
||||
rename to new-name.ts
|
||||
index abc123..abc123 100644`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand)
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockNumstatOutput,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockDiffOutput,
|
||||
});
|
||||
|
||||
const result = await getDiff(mockCwd, fromRef, toRef);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.files).toHaveLength(1);
|
||||
expect(result.data.files[0]?.status).toBe("renamed");
|
||||
expect(result.data.files[0]?.filePath).toBe("new-name.ts");
|
||||
expect(result.data.files[0]?.oldPath).toBe("old-name.ts");
|
||||
}
|
||||
});
|
||||
|
||||
it("空のdiffを処理できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const fromRef = "base:main";
|
||||
const toRef = "compare:feature";
|
||||
|
||||
const mockNumstatOutput = "";
|
||||
const mockDiffOutput = "";
|
||||
|
||||
vi.mocked(utils.executeGitCommand)
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockNumstatOutput,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockDiffOutput,
|
||||
});
|
||||
|
||||
const result = await getDiff(mockCwd, fromRef, toRef);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.files).toHaveLength(0);
|
||||
expect(result.data.diffs).toHaveLength(0);
|
||||
expect(result.data.summary.totalFiles).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("エラー系", () => {
|
||||
it("ディレクトリが存在しない場合", async () => {
|
||||
const mockCwd = "/nonexistent/repo";
|
||||
const fromRef = "base:main";
|
||||
const toRef = "compare:feature";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "NOT_A_REPOSITORY",
|
||||
message: `Directory does not exist: ${mockCwd}`,
|
||||
command: "git diff --numstat main feature",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getDiff(mockCwd, fromRef, toRef);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("NOT_A_REPOSITORY");
|
||||
expect(result.error.message).toContain("Directory does not exist");
|
||||
}
|
||||
});
|
||||
|
||||
it("Gitリポジトリでない場合", async () => {
|
||||
const mockCwd = "/test/not-a-repo";
|
||||
const fromRef = "base:main";
|
||||
const toRef = "compare:feature";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "NOT_A_REPOSITORY",
|
||||
message: `Not a git repository: ${mockCwd}`,
|
||||
command: "git diff --numstat main feature",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getDiff(mockCwd, fromRef, toRef);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("NOT_A_REPOSITORY");
|
||||
expect(result.error.message).toContain("Not a git repository");
|
||||
}
|
||||
});
|
||||
|
||||
it("ブランチが見つからない場合", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const fromRef = "base:nonexistent";
|
||||
const toRef = "compare:feature";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "BRANCH_NOT_FOUND",
|
||||
message: "Branch or commit not found",
|
||||
command: "git diff --numstat nonexistent feature",
|
||||
stderr:
|
||||
"fatal: ambiguous argument 'nonexistent': unknown revision or path not in the working tree.",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getDiff(mockCwd, fromRef, toRef);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("BRANCH_NOT_FOUND");
|
||||
expect(result.error.message).toBe("Branch or commit not found");
|
||||
}
|
||||
});
|
||||
|
||||
it("numstatコマンドが失敗した場合", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const fromRef = "base:main";
|
||||
const toRef = "compare:feature";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "COMMAND_FAILED",
|
||||
message: "Command failed",
|
||||
command: "git diff --numstat main feature",
|
||||
stderr: "fatal: bad revision",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getDiff(mockCwd, fromRef, toRef);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("COMMAND_FAILED");
|
||||
}
|
||||
});
|
||||
|
||||
it("無効なfromRefの場合", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const fromRef = "invalidref";
|
||||
const toRef = "compare:feature";
|
||||
|
||||
await expect(getDiff(mockCwd, fromRef, toRef)).rejects.toThrow(
|
||||
"Invalid ref text",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("エッジケース", () => {
|
||||
it("特殊文字を含むファイル名を処理できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const fromRef = "base:main";
|
||||
const toRef = "compare:feature";
|
||||
|
||||
const mockNumstatOutput = `5\t2\tsrc/file with spaces.ts
|
||||
3\t1\tsrc/日本語ファイル.ts`;
|
||||
|
||||
const mockDiffOutput = `diff --git a/src/file with spaces.ts b/src/file with spaces.ts
|
||||
index abc123..def456 100644
|
||||
--- a/src/file with spaces.ts
|
||||
+++ b/src/file with spaces.ts
|
||||
@@ -1,3 +1,5 @@
|
||||
content
|
||||
diff --git a/src/日本語ファイル.ts b/src/日本語ファイル.ts
|
||||
index abc123..def456 100644
|
||||
--- a/src/日本語ファイル.ts
|
||||
+++ b/src/日本語ファイル.ts
|
||||
@@ -1,2 +1,3 @@
|
||||
content`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand)
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockNumstatOutput,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockDiffOutput,
|
||||
});
|
||||
|
||||
const result = await getDiff(mockCwd, fromRef, toRef);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.files).toHaveLength(2);
|
||||
expect(result.data.files[0]?.filePath).toBe("src/file with spaces.ts");
|
||||
expect(result.data.files[1]?.filePath).toBe("src/日本語ファイル.ts");
|
||||
}
|
||||
});
|
||||
|
||||
it("バイナリファイルの変更を処理できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const fromRef = "base:main";
|
||||
const toRef = "compare:feature";
|
||||
|
||||
const mockNumstatOutput = `-\t-\timage.png`;
|
||||
const mockDiffOutput = `diff --git a/image.png b/image.png
|
||||
index abc123..def456 100644
|
||||
Binary files a/image.png and b/image.png differ`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand)
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockNumstatOutput,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockDiffOutput,
|
||||
});
|
||||
|
||||
const result = await getDiff(mockCwd, fromRef, toRef);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.files).toHaveLength(1);
|
||||
expect(result.data.files[0]?.filePath).toBe("image.png");
|
||||
expect(result.data.files[0]?.additions).toBe(0);
|
||||
expect(result.data.files[0]?.deletions).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("大量のファイル変更を処理できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const fromRef = "base:main";
|
||||
const toRef = "compare:feature";
|
||||
|
||||
const mockNumstatOutput = Array.from(
|
||||
{ length: 100 },
|
||||
(_, i) => `1\t1\tfile${i}.ts`,
|
||||
).join("\n");
|
||||
const mockDiffOutput = Array.from(
|
||||
{ length: 100 },
|
||||
(_, i) => `diff --git a/file${i}.ts b/file${i}.ts
|
||||
index abc123..def456 100644
|
||||
--- a/file${i}.ts
|
||||
+++ b/file${i}.ts
|
||||
@@ -1 +1 @@
|
||||
-old
|
||||
+new`,
|
||||
).join("\n");
|
||||
|
||||
vi.mocked(utils.executeGitCommand)
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockNumstatOutput,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockDiffOutput,
|
||||
});
|
||||
|
||||
const result = await getDiff(mockCwd, fromRef, toRef);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.files).toHaveLength(100);
|
||||
expect(result.data.summary.totalFiles).toBe(100);
|
||||
expect(result.data.summary.totalAdditions).toBe(100);
|
||||
expect(result.data.summary.totalDeletions).toBe(100);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareBranches", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("getDiffのショートハンドとして機能する", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const baseBranch = "base:main";
|
||||
const targetBranch = "compare:feature";
|
||||
|
||||
const mockNumstatOutput = `5\t2\tfile.ts`;
|
||||
const mockDiffOutput = `diff --git a/file.ts b/file.ts
|
||||
index abc123..def456 100644
|
||||
--- a/file.ts
|
||||
+++ b/file.ts
|
||||
@@ -1,2 +1,5 @@
|
||||
content`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand)
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockNumstatOutput,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockDiffOutput,
|
||||
});
|
||||
|
||||
const result = await compareBranches(mockCwd, baseBranch, targetBranch);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.files).toHaveLength(1);
|
||||
expect(result.data.files[0]?.filePath).toBe("file.ts");
|
||||
}
|
||||
});
|
||||
});
|
||||
386
src/server/core/git/functions/getDiff.ts
Normal file
386
src/server/core/git/functions/getDiff.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import parseGitDiff, {
|
||||
type AnyChunk,
|
||||
type AnyFileChange,
|
||||
} from "parse-git-diff";
|
||||
import {
|
||||
executeGitCommand,
|
||||
parseLines,
|
||||
stripAnsiColors,
|
||||
} from "../functions/utils";
|
||||
import type {
|
||||
GitComparisonResult,
|
||||
GitDiff,
|
||||
GitDiffFile,
|
||||
GitDiffHunk,
|
||||
GitDiffLine,
|
||||
GitResult,
|
||||
} from "../types";
|
||||
|
||||
/**
|
||||
* 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 untracked files using git status
|
||||
*/
|
||||
async function getUntrackedFiles(cwd: string): Promise<GitResult<string[]>> {
|
||||
const statusResult = await executeGitCommand(
|
||||
["status", "--untracked-files=all", "--short"],
|
||||
cwd,
|
||||
);
|
||||
|
||||
if (!statusResult.success) {
|
||||
return statusResult;
|
||||
}
|
||||
|
||||
try {
|
||||
const untrackedFiles = parseLines(statusResult.data)
|
||||
.map((line) => stripAnsiColors(line)) // Remove ANSI color codes first
|
||||
.filter((line) => line.startsWith("??"))
|
||||
.map((line) => line.slice(3));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: untrackedFiles,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "PARSE_ERROR",
|
||||
message: `Failed to parse status output: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create artificial diff for an untracked file (all lines as additions)
|
||||
*/
|
||||
async function createUntrackedFileDiff(
|
||||
cwd: string,
|
||||
filePath: string,
|
||||
): Promise<GitDiff | null> {
|
||||
try {
|
||||
const fullPath = resolve(cwd, filePath);
|
||||
const content = await readFile(fullPath, "utf8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
const diffLines: GitDiffLine[] = lines.map((line, index) => ({
|
||||
type: "added" as const,
|
||||
content: line,
|
||||
newLineNumber: index + 1,
|
||||
}));
|
||||
|
||||
const file: GitDiffFile = {
|
||||
filePath,
|
||||
status: "added",
|
||||
additions: lines.length,
|
||||
deletions: 0,
|
||||
};
|
||||
|
||||
const hunk: GitDiffHunk = {
|
||||
oldStart: 0,
|
||||
oldCount: 0,
|
||||
newStart: 1,
|
||||
newCount: lines.length,
|
||||
header: `@@ -0,0 +1,${lines.length} @@`,
|
||||
lines: diffLines,
|
||||
};
|
||||
|
||||
return {
|
||||
file,
|
||||
hunks: [hunk],
|
||||
};
|
||||
} catch (error) {
|
||||
// Skip files that can't be read (e.g., binary files, permission errors)
|
||||
console.warn(`Failed to read untracked file ${filePath}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
// Include untracked files when comparing to working directory
|
||||
if (toRef === undefined) {
|
||||
const untrackedResult = await getUntrackedFiles(cwd);
|
||||
if (untrackedResult.success) {
|
||||
for (const untrackedFile of untrackedResult.data) {
|
||||
const untrackedDiff = await createUntrackedFileDiff(
|
||||
cwd,
|
||||
untrackedFile,
|
||||
);
|
||||
if (untrackedDiff) {
|
||||
files.push(untrackedDiff.file);
|
||||
diffs.push(untrackedDiff);
|
||||
totalAdditions += untrackedDiff.file.additions;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
351
src/server/core/git/functions/getStatus.test.ts
Normal file
351
src/server/core/git/functions/getStatus.test.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getStatus,
|
||||
getUncommittedChanges,
|
||||
isWorkingDirectoryClean,
|
||||
} from "./getStatus";
|
||||
import * as utils from "./utils";
|
||||
|
||||
vi.mock("./utils", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof utils>();
|
||||
return {
|
||||
...actual,
|
||||
executeGitCommand: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("getStatus", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("正常系", () => {
|
||||
it("Gitステータス情報を取得できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `## main...origin/main [ahead 2, behind 1]
|
||||
M staged-modified.ts
|
||||
M unstaged-modified.ts
|
||||
A staged-added.ts
|
||||
?? untracked-file.ts`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getStatus(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.branch).toBe("main");
|
||||
expect(result.data.ahead).toBe(2);
|
||||
expect(result.data.behind).toBe(1);
|
||||
|
||||
expect(result.data.staged).toHaveLength(2);
|
||||
expect(result.data.staged[0]?.filePath).toBe("staged-modified.ts");
|
||||
expect(result.data.staged[0]?.status).toBe("modified");
|
||||
expect(result.data.staged[1]?.filePath).toBe("staged-added.ts");
|
||||
expect(result.data.staged[1]?.status).toBe("added");
|
||||
|
||||
expect(result.data.unstaged).toHaveLength(1);
|
||||
expect(result.data.unstaged[0]?.filePath).toBe("unstaged-modified.ts");
|
||||
expect(result.data.unstaged[0]?.status).toBe("modified");
|
||||
|
||||
expect(result.data.untracked).toEqual(["untracked-file.ts"]);
|
||||
expect(result.data.conflicted).toHaveLength(0);
|
||||
}
|
||||
|
||||
expect(utils.executeGitCommand).toHaveBeenCalledWith(
|
||||
["status", "--porcelain=v1", "-b"],
|
||||
mockCwd,
|
||||
);
|
||||
});
|
||||
|
||||
it("名前変更されたファイルを処理できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `## main
|
||||
R old-name.ts -> new-name.ts`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getStatus(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.staged).toHaveLength(1);
|
||||
expect(result.data.staged[0]?.filePath).toBe("new-name.ts");
|
||||
expect(result.data.staged[0]?.oldPath).toBe("old-name.ts");
|
||||
expect(result.data.staged[0]?.status).toBe("renamed");
|
||||
}
|
||||
});
|
||||
|
||||
it("コンフリクトファイルを検出できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `## main
|
||||
UU conflicted-file.ts
|
||||
MM both-modified.ts`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getStatus(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.conflicted).toEqual([
|
||||
"conflicted-file.ts",
|
||||
"both-modified.ts",
|
||||
]);
|
||||
expect(result.data.staged).toHaveLength(0);
|
||||
expect(result.data.unstaged).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("空のリポジトリ(クリーンな状態)を処理できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = "## main";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getStatus(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.branch).toBe("main");
|
||||
expect(result.data.ahead).toBe(0);
|
||||
expect(result.data.behind).toBe(0);
|
||||
expect(result.data.staged).toHaveLength(0);
|
||||
expect(result.data.unstaged).toHaveLength(0);
|
||||
expect(result.data.untracked).toHaveLength(0);
|
||||
expect(result.data.conflicted).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("ブランチがupstreamを持たない場合", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `## feature-branch
|
||||
M file.ts`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getStatus(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.branch).toBe("feature-branch");
|
||||
expect(result.data.ahead).toBe(0);
|
||||
expect(result.data.behind).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("エラー系", () => {
|
||||
it("ディレクトリが存在しない場合", async () => {
|
||||
const mockCwd = "/nonexistent/repo";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "NOT_A_REPOSITORY",
|
||||
message: `Directory does not exist: ${mockCwd}`,
|
||||
command: "git status --porcelain=v1 -b",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getStatus(mockCwd);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("NOT_A_REPOSITORY");
|
||||
expect(result.error.message).toContain("Directory does not exist");
|
||||
}
|
||||
});
|
||||
|
||||
it("Gitリポジトリでない場合", async () => {
|
||||
const mockCwd = "/test/not-a-repo";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "NOT_A_REPOSITORY",
|
||||
message: `Not a git repository: ${mockCwd}`,
|
||||
command: "git status --porcelain=v1 -b",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getStatus(mockCwd);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("NOT_A_REPOSITORY");
|
||||
expect(result.error.message).toContain("Not a git repository");
|
||||
}
|
||||
});
|
||||
|
||||
it("Gitコマンドが失敗した場合", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: false,
|
||||
error: {
|
||||
code: "COMMAND_FAILED",
|
||||
message: "Command failed",
|
||||
command: "git status",
|
||||
stderr: "fatal: not a git repository",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getStatus(mockCwd);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.code).toBe("COMMAND_FAILED");
|
||||
expect(result.error.message).toBe("Command failed");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("エッジケース", () => {
|
||||
it("特殊文字を含むファイル名を処理できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `## main
|
||||
M file with spaces.ts
|
||||
A 日本語ファイル.ts
|
||||
?? special@#$%chars.ts`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getStatus(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.staged[0]?.filePath).toBe("file with spaces.ts");
|
||||
expect(result.data.staged[1]?.filePath).toBe("日本語ファイル.ts");
|
||||
expect(result.data.untracked).toEqual(["special@#$%chars.ts"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUncommittedChanges", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("stagedとunstagedの両方の変更を取得できる", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `## main
|
||||
M staged-file.ts
|
||||
M unstaged-file.ts`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getUncommittedChanges(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data.some((f) => f.filePath === "staged-file.ts")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(result.data.some((f) => f.filePath === "unstaged-file.ts")).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("重複するファイルを削除する", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `## main
|
||||
MM both-changed.ts`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await getUncommittedChanges(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
// Conflictとして処理されるため空になる
|
||||
expect(result.data).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWorkingDirectoryClean", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("クリーンな作業ディレクトリでtrueを返す", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = "## main";
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await isWorkingDirectoryClean(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("変更がある場合falseを返す", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `## main
|
||||
M modified-file.ts`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await isWorkingDirectoryClean(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("未追跡ファイルがある場合falseを返す", async () => {
|
||||
const mockCwd = "/test/repo";
|
||||
const mockOutput = `## main
|
||||
?? untracked-file.ts`;
|
||||
|
||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||
success: true,
|
||||
data: mockOutput,
|
||||
});
|
||||
|
||||
const result = await isWorkingDirectoryClean(mockCwd);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
172
src/server/core/git/functions/getStatus.ts
Normal file
172
src/server/core/git/functions/getStatus.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
executeGitCommand,
|
||||
getFileStatus,
|
||||
parseLines,
|
||||
parseStatusLine,
|
||||
} from "../functions/utils";
|
||||
import type { GitDiffFile, GitResult, GitStatus } from "../types";
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
151
src/server/core/git/functions/utils.ts
Normal file
151
src/server/core/git/functions/utils.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
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"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove ANSI color codes from a string
|
||||
*/
|
||||
export function stripAnsiColors(text: string): string {
|
||||
// ANSI escape sequence pattern: \x1B[...m
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: this is a valid regex
|
||||
return text.replace(/\x1B\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
}
|
||||
85
src/server/core/git/types.ts
Normal file
85
src/server/core/git/types.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user