mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2025-12-28 10:44:21 +01:00
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>
560 lines
16 KiB
TypeScript
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");
|
|
}
|
|
});
|
|
});
|