Files
claude-code-viewer/src/server/core/git/functions/getDiff.test.ts
d-kimsuon 7ac09bbd6a fix: Git Diff View works in subdirectories
Remove explicit .git directory checks from executeGitCommand functions.
Git automatically searches parent directories for .git, making these
checks unnecessary and preventing subdirectory execution.

Changes:
- src/server/core/git/functions/utils.ts: Remove .git existence check
- src/server/core/git/services/GitService.ts: Remove .git existence check
- src/server/core/git/functions/getDiff.test.ts: Add subdirectory test case

Fixes #25

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-23 03:26:31 +09:00

560 lines
16 KiB
TypeScript

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/subdirectory";
const fromRef = "base:main";
const toRef = "compare:feature";
const mockNumstatOutput = `3\t1\tsrc/file.ts`;
const mockDiffOutput = `diff --git a/src/file.ts b/src/file.ts
index abc123..def456 100644
--- a/src/file.ts
+++ b/src/file.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(1);
expect(result.data.files[0]?.filePath).toBe("src/file.ts");
}
// Verify that git commands are executed in the subdirectory
expect(utils.executeGitCommand).toHaveBeenCalledWith(
["diff", "--numstat", "main", "feature"],
mockCwd,
);
});
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");
}
});
});