refactor: use more effect-ts

This commit is contained in:
d-kimsuon
2025-10-18 01:50:04 +09:00
parent 1bd122daa0
commit 6f627fb649
21 changed files with 696 additions and 1537 deletions

View File

@@ -1,369 +0,0 @@
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);
}
});
});

View File

@@ -1,130 +0,0 @@
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,
};
}

View File

@@ -1,51 +0,0 @@
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"}`,
},
};
}
}

View File

@@ -1,351 +0,0 @@
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);
}
});
});

View File

@@ -1,172 +0,0 @@
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,
};
}

View File

@@ -0,0 +1,126 @@
import { describe, expect, it, vi } from "vitest";
import { parseGitBranchesOutput } from "./parseGitBranchesOutput";
import * as utils from "./utils";
describe("getBranches", () => {
describe("正常系", () => {
it("ブランチ一覧を取得できる", async () => {
const mockOutput = `* main abc1234 [origin/main: ahead 1] Latest commit
remotes/origin/main abc1234 Latest commit
feature def5678 [origin/feature] Feature commit`;
const result = parseGitBranchesOutput(mockOutput);
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,
});
}
});
it("ahead/behindの両方を持つブランチを処理できる", async () => {
const mockOutput =
"* main abc1234 [origin/main: ahead 2, behind 3] Commit message";
const result = parseGitBranchesOutput(mockOutput);
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 mockOutput = `* main abc1234 [origin/main] Latest commit
remotes/origin/main abc1234 Latest commit
feature def5678 Feature commit
remotes/origin/feature def5678 Feature commit`;
const result = parseGitBranchesOutput(mockOutput);
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 mockOutput = "";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
});
const result = parseGitBranchesOutput(mockOutput);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toHaveLength(0);
}
});
it("不正な形式の行をスキップする", async () => {
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 = parseGitBranchesOutput(mockOutput);
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 mockOutput = `* feature/special-chars_123 abc1234 Commit
feature/日本語ブランチ def5678 日本語コミット`;
const result = parseGitBranchesOutput(mockOutput);
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/日本語ブランチ");
}
});
});
});

View File

@@ -0,0 +1,63 @@
import type { GitBranch } from "../types";
import { parseLines } from "./utils";
/**
* Get all branches (local and remote) in the repository
*/
export const parseGitBranchesOutput = (output: string) => {
const lines = parseLines(output);
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,
};
};

View File

@@ -1,23 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getCommits } from "./getCommits";
import { describe, expect, it, vi } from "vitest";
import { parseGitCommitsOutput } from "./parseGitCommitsOutput";
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`;
@@ -27,7 +14,7 @@ ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`;
data: mockOutput,
});
const result = await getCommits(mockCwd);
const result = parseGitCommitsOutput(mockOutput);
expect(result.success).toBe(true);
if (result.success) {
@@ -51,22 +38,9 @@ ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`;
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({
@@ -74,7 +48,7 @@ ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`;
data: mockOutput,
});
const result = await getCommits(mockCwd);
const result = parseGitCommitsOutput(mockOutput);
expect(result.success).toBe(true);
if (result.success) {
@@ -83,7 +57,6 @@ ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`;
});
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
@@ -95,7 +68,7 @@ ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`;
data: mockOutput,
});
const result = await getCommits(mockCwd);
const result = parseGitCommitsOutput(mockOutput);
expect(result.success).toBe(true);
if (result.success) {
@@ -107,76 +80,8 @@ ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`;
});
});
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`;
@@ -185,7 +90,7 @@ def456|fix: 日本語メッセージ|日本語 著者|2024-01-14 09:20:00 +0900`
data: mockOutput,
});
const result = await getCommits(mockCwd);
const result = parseGitCommitsOutput(mockOutput);
expect(result.success).toBe(true);
if (result.success) {
@@ -199,7 +104,6 @@ def456|fix: 日本語メッセージ|日本語 著者|2024-01-14 09:20:00 +0900`
});
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({
@@ -207,21 +111,15 @@ def456|fix: 日本語メッセージ|日本語 著者|2024-01-14 09:20:00 +0900`
data: mockOutput,
});
const result = await getCommits(mockCwd);
const result = parseGitCommitsOutput(mockOutput);
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
@@ -233,7 +131,7 @@ def456|fix: bug|Author|2024-01-14 09:20:00 +0900
data: mockOutput,
});
const result = await getCommits(mockCwd);
const result = parseGitCommitsOutput(mockOutput);
expect(result.success).toBe(true);
if (result.success) {

View File

@@ -0,0 +1,31 @@
import { parseLines } from "../functions/utils";
import type { GitCommit } from "../types";
/**
* Get the last 20 commits from the current branch
*/
export const parseGitCommitsOutput = (output: string) => {
const lines = parseLines(output);
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,
};
};

View File

@@ -2,11 +2,11 @@ import { Context, Effect, Layer } from "effect";
import type { ControllerResponse } from "../../../lib/effect/toEffectResponse";
import type { InferEffect } from "../../../lib/effect/types";
import { ProjectRepository } from "../../project/infrastructure/ProjectRepository";
import { getBranches } from "../functions/getBranches";
import { getCommits } from "../functions/getCommits";
import { getDiff } from "../functions/getDiff";
import { GitService } from "../services/GitService";
const LayerImpl = Effect.gen(function* () {
const gitService = yield* GitService;
const projectRepository = yield* ProjectRepository;
const getGitBranches = (options: { projectId: string }) =>
@@ -23,26 +23,11 @@ const LayerImpl = Effect.gen(function* () {
}
const projectPath = project.meta.projectPath;
try {
const result = yield* Effect.promise(() => getBranches(projectPath));
return {
response: result,
status: 200,
} as const satisfies ControllerResponse;
} catch (error) {
console.error("Get branches error:", error);
if (error instanceof Error) {
return {
response: { error: error.message },
status: 400,
} as const satisfies ControllerResponse;
}
return {
response: { error: "Failed to get branches" },
status: 500,
} as const satisfies ControllerResponse;
}
const branches = yield* gitService.getBranches(projectPath);
return {
response: branches,
status: 200,
} as const satisfies ControllerResponse;
});
const getGitCommits = (options: { projectId: string }) =>
@@ -60,25 +45,11 @@ const LayerImpl = Effect.gen(function* () {
const projectPath = project.meta.projectPath;
try {
const result = yield* Effect.promise(() => getCommits(projectPath));
return {
response: result,
status: 200,
} as const satisfies ControllerResponse;
} catch (error) {
console.error("Get commits error:", error);
if (error instanceof Error) {
return {
response: { error: error.message },
status: 400,
} as const satisfies ControllerResponse;
}
return {
response: { error: "Failed to get commits" },
status: 500,
} as const satisfies ControllerResponse;
}
const commits = yield* gitService.getCommits(projectPath);
return {
response: commits,
status: 200,
} as const satisfies ControllerResponse;
});
const getGitDiff = (options: {

View File

@@ -0,0 +1,128 @@
import { Command, FileSystem, Path } from "@effect/platform";
import { Context, Data, Effect, Either, Layer } from "effect";
import type { InferEffect } from "../../../lib/effect/types";
import { env } from "../../../lib/env";
import { parseGitBranchesOutput } from "../functions/parseGitBranchesOutput";
import { parseGitCommitsOutput } from "../functions/parseGitCommitsOutput";
class NotARepositoryError extends Data.TaggedError("NotARepositoryError")<{
cwd: string;
}> {}
class GitCommandError extends Data.TaggedError("GitCommandError")<{
cwd: string;
command: string;
}> {}
class DetachedHeadError extends Data.TaggedError("DetachedHeadError")<{
cwd: string;
}> {}
const LayerImpl = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const execGitCommand = (args: string[], cwd: string) =>
Effect.gen(function* () {
const absoluteCwd = path.resolve(cwd);
if (!(yield* fs.exists(absoluteCwd))) {
return yield* Effect.fail(
new NotARepositoryError({ cwd: absoluteCwd }),
);
}
if (!(yield* fs.exists(path.resolve(absoluteCwd, ".git")))) {
return yield* Effect.fail(
new NotARepositoryError({ cwd: absoluteCwd }),
);
}
const command = Command.string(
Command.make("cd", absoluteCwd, "&&", "git", ...args).pipe(
Command.env({
PATH: env.get("PATH"),
}),
Command.runInShell(true),
),
);
const result = yield* Effect.either(command);
if (Either.isLeft(result)) {
return yield* Effect.fail(
new GitCommandError({
cwd: absoluteCwd,
command: command.toString(),
}),
);
}
return result.right;
});
const getBranches = (cwd: string) =>
Effect.gen(function* () {
const result = yield* execGitCommand(["branch", "-vv", "--all"], cwd);
return parseGitBranchesOutput(result);
});
const getCurrentBranch = (cwd: string) =>
Effect.gen(function* () {
const currentBranch = yield* execGitCommand(
["branch", "--show-current"],
cwd,
).pipe(Effect.map((result) => result.trim()));
if (currentBranch === "") {
return yield* Effect.fail(new DetachedHeadError({ cwd }));
}
return currentBranch;
});
const branchExists = (cwd: string, branchName: string) =>
Effect.gen(function* () {
const result = yield* Effect.either(
execGitCommand(["branch", "--exists", branchName], cwd),
);
if (Either.isLeft(result)) {
return false;
}
return true;
});
const getCommits = (cwd: string) =>
Effect.gen(function* () {
const result = yield* execGitCommand(
[
"log",
"--oneline",
"-n",
"20",
"--format=%H|%s|%an|%ad",
"--date=iso",
],
cwd,
);
return parseGitCommitsOutput(result);
});
return {
getBranches,
getCurrentBranch,
branchExists,
getCommits,
};
});
export type IGitService = InferEffect<typeof LayerImpl>;
export class GitService extends Context.Tag("GitService")<
GitService,
IGitService
>() {
static Live = Layer.effect(this, LayerImpl);
}