diff --git a/src/server/core/claude-code/functions/computeClaudeProjectFilePath.ts b/src/server/core/claude-code/functions/computeClaudeProjectFilePath.ts index 567e44e..df5cb2a 100644 --- a/src/server/core/claude-code/functions/computeClaudeProjectFilePath.ts +++ b/src/server/core/claude-code/functions/computeClaudeProjectFilePath.ts @@ -1,9 +1,13 @@ -import path from "node:path"; +import { Path } from "@effect/platform"; +import { Effect } from "effect"; import { claudeProjectsDirPath } from "../../../lib/config/paths"; -export function computeClaudeProjectFilePath(projectPath: string): string { - return path.join( - claudeProjectsDirPath, - projectPath.replace(/\/$/, "").replace(/\//g, "-"), - ); -} +export const computeClaudeProjectFilePath = (projectPath: string) => + Effect.gen(function* () { + const path = yield* Path.Path; + + return path.join( + claudeProjectsDirPath, + projectPath.replace(/\/$/, "").replace(/\//g, "-"), + ); + }); diff --git a/src/server/core/claude-code/models/ClaudeCode.ts b/src/server/core/claude-code/models/ClaudeCode.ts index 840261d..5189d34 100644 --- a/src/server/core/claude-code/models/ClaudeCode.ts +++ b/src/server/core/claude-code/models/ClaudeCode.ts @@ -48,7 +48,7 @@ export const getMcpListOutput = (projectCwd: string) => claudeCodeExecutablePath, "mcp", "list", - ), + ).pipe(Command.runInShell(true)), ); return output; }); diff --git a/src/server/core/claude-code/presentation/ClaudeCodeController.ts b/src/server/core/claude-code/presentation/ClaudeCodeController.ts index 89e758d..e6f5fb6 100644 --- a/src/server/core/claude-code/presentation/ClaudeCodeController.ts +++ b/src/server/core/claude-code/presentation/ClaudeCodeController.ts @@ -1,10 +1,10 @@ +import { FileSystem, Path } from "@effect/platform"; import { Context, Effect, Layer } from "effect"; import { claudeCommandsDirPath } from "../../../lib/config/paths"; import type { ControllerResponse } from "../../../lib/effect/toEffectResponse"; import type { InferEffect } from "../../../lib/effect/types"; import { ProjectRepository } from "../../project/infrastructure/ProjectRepository"; import { ClaudeCodeService } from "../services/ClaudeCodeService"; -import { FileSystem, Path } from "@effect/platform"; const LayerImpl = Effect.gen(function* () { const projectRepository = yield* ProjectRepository; diff --git a/src/server/core/events/services/fileWatcher.ts b/src/server/core/events/services/fileWatcher.ts index 3b43780..f382fc5 100644 --- a/src/server/core/events/services/fileWatcher.ts +++ b/src/server/core/events/services/fileWatcher.ts @@ -1,5 +1,5 @@ import { type FSWatcher, watch } from "node:fs"; -import { join } from "node:path"; +import { Path } from "@effect/platform"; import { Context, Effect, Layer, Ref } from "effect"; import z from "zod"; import { claudeProjectsDirPath } from "../../../lib/config/paths"; @@ -24,6 +24,7 @@ export class FileWatcherService extends Context.Tag("FileWatcherService")< static Live = Layer.effect( this, Effect.gen(function* () { + const path = yield* Path.Path; const eventBus = yield* EventBus; const isWatchingRef = yield* Ref.make(false); const watcherRef = yield* Ref.make(null); @@ -44,6 +45,7 @@ export class FileWatcherService extends Context.Tag("FileWatcherService")< yield* Effect.tryPromise({ try: async () => { console.log("Starting file watcher on:", claudeProjectsDirPath); + const watcher = watch( claudeProjectsDirPath, { persistent: false, recursive: true }, @@ -59,7 +61,7 @@ export class FileWatcherService extends Context.Tag("FileWatcherService")< const { sessionId } = groups.data; // フルパスを構築してエンコードされた projectId を取得 - const fullPath = join(claudeProjectsDirPath, filename); + const fullPath = path.join(claudeProjectsDirPath, filename); const encodedProjectId = encodeProjectIdFromSessionFilePath(fullPath); const debounceKey = `${encodedProjectId}/${sessionId}`; diff --git a/src/server/core/git/functions/getBranches.test.ts b/src/server/core/git/functions/getBranches.test.ts deleted file mode 100644 index efbbcb8..0000000 --- a/src/server/core/git/functions/getBranches.test.ts +++ /dev/null @@ -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(); - 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); - } - }); -}); diff --git a/src/server/core/git/functions/getBranches.ts b/src/server/core/git/functions/getBranches.ts deleted file mode 100644 index 2d10c89..0000000 --- a/src/server/core/git/functions/getBranches.ts +++ /dev/null @@ -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> { - // Get all branches with verbose information - const result = await executeGitCommand(["branch", "-vv", "--all"], cwd); - - if (!result.success) { - return result as GitResult; - } - - try { - const lines = parseLines(result.data); - const branches: GitBranch[] = []; - const seenBranches = new Set(); - - 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> { - const result = await executeGitCommand(["branch", "--show-current"], cwd); - - if (!result.success) { - return result as GitResult; - } - - 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> { - const result = await executeGitCommand( - ["rev-parse", "--verify", branchName], - cwd, - ); - - return { - success: true, - data: result.success, - }; -} diff --git a/src/server/core/git/functions/getCommits.ts b/src/server/core/git/functions/getCommits.ts deleted file mode 100644 index ef381f3..0000000 --- a/src/server/core/git/functions/getCommits.ts +++ /dev/null @@ -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> { - // 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; - } - - 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"}`, - }, - }; - } -} diff --git a/src/server/core/git/functions/getStatus.test.ts b/src/server/core/git/functions/getStatus.test.ts deleted file mode 100644 index 98e22b1..0000000 --- a/src/server/core/git/functions/getStatus.test.ts +++ /dev/null @@ -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(); - 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); - } - }); -}); diff --git a/src/server/core/git/functions/getStatus.ts b/src/server/core/git/functions/getStatus.ts deleted file mode 100644 index eef7376..0000000 --- a/src/server/core/git/functions/getStatus.ts +++ /dev/null @@ -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> { - // Get porcelain status for consistent parsing - const statusResult = await executeGitCommand( - ["status", "--porcelain=v1", "-b"], - cwd, - ); - - if (!statusResult.success) { - return statusResult as GitResult; - } - - 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> { - const statusResult = await getStatus(cwd); - - if (!statusResult.success) { - return statusResult as GitResult; - } - - 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> { - const statusResult = await getStatus(cwd); - - if (!statusResult.success) { - return statusResult as GitResult; - } - - const { staged, unstaged, untracked } = statusResult.data; - const isClean = - staged.length === 0 && unstaged.length === 0 && untracked.length === 0; - - return { - success: true, - data: isClean, - }; -} diff --git a/src/server/core/git/functions/parseGitBranchesOutput.test.ts b/src/server/core/git/functions/parseGitBranchesOutput.test.ts new file mode 100644 index 0000000..c3e9e42 --- /dev/null +++ b/src/server/core/git/functions/parseGitBranchesOutput.test.ts @@ -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/日本語ブランチ"); + } + }); + }); +}); diff --git a/src/server/core/git/functions/parseGitBranchesOutput.ts b/src/server/core/git/functions/parseGitBranchesOutput.ts new file mode 100644 index 0000000..71cef06 --- /dev/null +++ b/src/server/core/git/functions/parseGitBranchesOutput.ts @@ -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(); + + 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, + }; +}; diff --git a/src/server/core/git/functions/getCommits.test.ts b/src/server/core/git/functions/parseGitCommitsOutput.test.ts similarity index 55% rename from src/server/core/git/functions/getCommits.test.ts rename to src/server/core/git/functions/parseGitCommitsOutput.test.ts index 018cd23..1d209a9 100644 --- a/src/server/core/git/functions/getCommits.test.ts +++ b/src/server/core/git/functions/parseGitCommitsOutput.test.ts @@ -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(); - 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" & 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) { diff --git a/src/server/core/git/functions/parseGitCommitsOutput.ts b/src/server/core/git/functions/parseGitCommitsOutput.ts new file mode 100644 index 0000000..73ab0f0 --- /dev/null +++ b/src/server/core/git/functions/parseGitCommitsOutput.ts @@ -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, + }; +}; diff --git a/src/server/core/git/presentation/GitController.ts b/src/server/core/git/presentation/GitController.ts index ed62037..200830a 100644 --- a/src/server/core/git/presentation/GitController.ts +++ b/src/server/core/git/presentation/GitController.ts @@ -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: { diff --git a/src/server/core/git/services/GitService.ts b/src/server/core/git/services/GitService.ts new file mode 100644 index 0000000..d8053ea --- /dev/null +++ b/src/server/core/git/services/GitService.ts @@ -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; + +export class GitService extends Context.Tag("GitService")< + GitService, + IGitService +>() { + static Live = Layer.effect(this, LayerImpl); +} diff --git a/src/server/core/project/infrastructure/ProjectRepository.ts b/src/server/core/project/infrastructure/ProjectRepository.ts index eeb592a..77eaf0c 100644 --- a/src/server/core/project/infrastructure/ProjectRepository.ts +++ b/src/server/core/project/infrastructure/ProjectRepository.ts @@ -1,5 +1,4 @@ -import { resolve } from "node:path"; -import { FileSystem } from "@effect/platform"; +import { FileSystem, Path } from "@effect/platform"; import { Context, Effect, Layer, Option } from "effect"; import { claudeProjectsDirPath } from "../../../lib/config/paths"; import type { InferEffect } from "../../../lib/effect/types"; @@ -9,6 +8,7 @@ import { ProjectMetaService } from "../services/ProjectMetaService"; const LayerImpl = Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const projectMetaService = yield* ProjectMetaService; const getProject = (projectId: string) => @@ -54,7 +54,7 @@ const LayerImpl = Effect.gen(function* () { // Filter directories and map to Project objects const projectEffects = entries.map((entry) => Effect.gen(function* () { - const fullPath = resolve(claudeProjectsDirPath, entry); + const fullPath = path.resolve(claudeProjectsDirPath, entry); // Check if it's a directory const stat = yield* Effect.tryPromise(() => diff --git a/src/server/core/project/presentation/ProjectController.ts b/src/server/core/project/presentation/ProjectController.ts index cb793ad..ce4cc24 100644 --- a/src/server/core/project/presentation/ProjectController.ts +++ b/src/server/core/project/presentation/ProjectController.ts @@ -122,7 +122,8 @@ const LayerImpl = Effect.gen(function* () { // No project validation needed - startTask will create a new project // if it doesn't exist when running /init command - const claudeProjectFilePath = computeClaudeProjectFilePath(projectPath); + const claudeProjectFilePath = + yield* computeClaudeProjectFilePath(projectPath); const projectId = encodeProjectId(claudeProjectFilePath); const config = yield* honoConfigService.getConfig(); diff --git a/src/server/core/project/services/ProjectMetaService.ts b/src/server/core/project/services/ProjectMetaService.ts index f85c013..217d44a 100644 --- a/src/server/core/project/services/ProjectMetaService.ts +++ b/src/server/core/project/services/ProjectMetaService.ts @@ -1,4 +1,3 @@ -import { basename } from "node:path"; import { FileSystem, Path } from "@effect/platform"; import { Context, Effect, Layer, Option, Ref } from "effect"; import { z } from "zod"; @@ -115,7 +114,7 @@ export class ProjectMetaService extends Context.Tag("ProjectMetaService")< } const projectMeta: ProjectMeta = { - projectName: projectPath ? basename(projectPath) : null, + projectName: projectPath ? path.basename(projectPath) : null, projectPath, sessionCount: files.length, }; diff --git a/src/server/core/session/infrastructure/SessionRepository.ts b/src/server/core/session/infrastructure/SessionRepository.ts index bedae8a..f6676eb 100644 --- a/src/server/core/session/infrastructure/SessionRepository.ts +++ b/src/server/core/session/infrastructure/SessionRepository.ts @@ -1,6 +1,6 @@ -import { resolve } from "node:path"; -import { FileSystem } from "@effect/platform"; +import { FileSystem, Path } from "@effect/platform"; import { Context, Effect, Layer, Option } from "effect"; +import type { InferEffect } from "../../../lib/effect/types"; import { parseCommandXml } from "../../claude-code/functions/parseCommandXml"; import { parseJsonl } from "../../claude-code/functions/parseJsonl"; import { decodeProjectId } from "../../project/functions/id"; @@ -9,83 +9,230 @@ import { decodeSessionId, encodeSessionId } from "../functions/id"; import { VirtualConversationDatabase } from "../infrastructure/VirtualConversationDatabase"; import { SessionMetaService } from "../services/SessionMetaService"; -const getSession = (projectId: string, sessionId: string) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const sessionMetaService = yield* SessionMetaService; - const virtualConversationDatabase = yield* VirtualConversationDatabase; +const LayerImpl = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sessionMetaService = yield* SessionMetaService; + const virtualConversationDatabase = yield* VirtualConversationDatabase; - const sessionPath = decodeSessionId(projectId, sessionId); + const getSession = (projectId: string, sessionId: string) => + Effect.gen(function* () { + const sessionPath = decodeSessionId(projectId, sessionId); - const virtualConversation = - yield* virtualConversationDatabase.getSessionVirtualConversation( - sessionId, + const virtualConversation = + yield* virtualConversationDatabase.getSessionVirtualConversation( + sessionId, + ); + + // Check if session file exists + const exists = yield* fs.exists(sessionPath); + const sessionDetail = yield* exists + ? Effect.gen(function* () { + // Read session file + const content = yield* fs.readFileString(sessionPath); + const allLines = content.split("\n").filter((line) => line.trim()); + + const conversations = parseJsonl(allLines.join("\n")); + + // Get file stats + const stat = yield* fs.stat(sessionPath); + + // Get session metadata + const meta = yield* sessionMetaService.getSessionMeta( + projectId, + sessionId, + ); + + const mergedConversations = [ + ...conversations, + ...(virtualConversation !== null + ? virtualConversation.conversations + : []), + ]; + + const conversationMap = new Map( + mergedConversations.flatMap((c, index) => { + if ( + c.type === "user" || + c.type === "assistant" || + c.type === "system" + ) { + return [[c.uuid, { conversation: c, index }] as const]; + } else { + return []; + } + }), + ); + + const isBroken = mergedConversations.some((item, index) => { + if (item.type !== "summary") return false; + const leftMessage = conversationMap.get(item.leafUuid); + if (leftMessage === undefined) return false; + + return index < leftMessage.index; + }); + + const sessionDetail: SessionDetail = { + id: sessionId, + jsonlFilePath: sessionPath, + meta, + conversations: isBroken ? conversations : mergedConversations, + lastModifiedAt: Option.getOrElse(stat.mtime, () => new Date()), + }; + + return sessionDetail; + }) + : (() => { + if (virtualConversation === null) { + return Effect.succeed(null); + } + + const lastConversation = virtualConversation.conversations + .filter( + (conversation) => + conversation.type === "user" || + conversation.type === "assistant" || + conversation.type === "system", + ) + .at(-1); + + const virtualSession: SessionDetail = { + id: sessionId, + jsonlFilePath: `${decodeProjectId(projectId)}/${sessionId}.jsonl`, + meta: { + messageCount: 0, + firstCommand: null, + }, + conversations: virtualConversation.conversations, + lastModifiedAt: + lastConversation !== undefined + ? new Date(lastConversation.timestamp) + : new Date(), + }; + + return Effect.succeed(virtualSession); + })(); + + return { + session: sessionDetail, + }; + }); + + const getSessions = ( + projectId: string, + options?: { + maxCount?: number; + cursor?: string; + }, + ) => + Effect.gen(function* () { + const { maxCount = 20, cursor } = options ?? {}; + + const claudeProjectPath = decodeProjectId(projectId); + + // Check if project directory exists + const dirExists = yield* fs.exists(claudeProjectPath); + if (!dirExists) { + console.warn(`Project directory not found at ${claudeProjectPath}`); + return { sessions: [] }; + } + + // Read directory entries with error handling + const dirents = yield* Effect.tryPromise({ + try: () => fs.readDirectory(claudeProjectPath).pipe(Effect.runPromise), + catch: (error) => { + console.warn( + `Failed to read sessions for project ${projectId}:`, + error, + ); + return new Error("Failed to read directory"); + }, + }).pipe(Effect.catchAll(() => Effect.succeed([]))); + + // Process session files + const sessionEffects = dirents + .filter((entry) => entry.endsWith(".jsonl")) + .map((entry) => + Effect.gen(function* () { + const fullPath = path.resolve(claudeProjectPath, entry); + const sessionId = encodeSessionId(fullPath); + + // Get file stats with error handling + const stat = yield* Effect.tryPromise(() => + fs.stat(fullPath).pipe(Effect.runPromise), + ).pipe(Effect.catchAll(() => Effect.succeed(null))); + + if (!stat) { + return null; + } + + return { + id: sessionId, + jsonlFilePath: fullPath, + lastModifiedAt: Option.getOrElse(stat.mtime, () => new Date()), + }; + }), + ); + + // Execute all effects in parallel and filter out nulls + const sessionsWithNulls = yield* Effect.all(sessionEffects, { + concurrency: "unbounded", + }); + const sessions = sessionsWithNulls + .filter((s): s is NonNullable => s !== null) + .sort( + (a, b) => b.lastModifiedAt.getTime() - a.lastModifiedAt.getTime(), + ); + + const sessionMap = new Map( + sessions.map((session) => [session.id, session] as const), ); - // Check if session file exists - const exists = yield* fs.exists(sessionPath); - const sessionDetail = yield* exists - ? Effect.gen(function* () { - // Read session file - const content = yield* fs.readFileString(sessionPath); - const allLines = content.split("\n").filter((line) => line.trim()); + const index = + cursor !== undefined + ? sessions.findIndex((session) => session.id === cursor) + : -1; - const conversations = parseJsonl(allLines.join("\n")); + if (index !== -1) { + const sessionsToReturn = sessions.slice( + index + 1, + Math.min(index + 1 + maxCount, sessions.length), + ); - // Get file stats - const stat = yield* fs.stat(sessionPath); - - // Get session metadata - const meta = yield* sessionMetaService.getSessionMeta( - projectId, - sessionId, - ); - - const mergedConversations = [ - ...conversations, - ...(virtualConversation !== null - ? virtualConversation.conversations - : []), - ]; - - const conversationMap = new Map( - mergedConversations.flatMap((c, index) => { - if ( - c.type === "user" || - c.type === "assistant" || - c.type === "system" - ) { - return [[c.uuid, { conversation: c, index }] as const]; - } else { - return []; - } + const sessionsWithMeta = yield* Effect.all( + sessionsToReturn.map((item) => + Effect.gen(function* () { + const meta = yield* sessionMetaService.getSessionMeta( + projectId, + item.id, + ); + return { + ...item, + meta, + }; }), - ); + ), + { concurrency: "unbounded" }, + ); - const isBroken = mergedConversations.some((item, index) => { - if (item.type !== "summary") return false; - const leftMessage = conversationMap.get(item.leafUuid); - if (leftMessage === undefined) return false; + return { + sessions: sessionsWithMeta, + }; + } - return index < leftMessage.index; - }); + // Get predict sessions + const virtualConversations = + yield* virtualConversationDatabase.getProjectVirtualConversations( + projectId, + ); - const sessionDetail: SessionDetail = { - id: sessionId, - jsonlFilePath: sessionPath, - meta, - conversations: isBroken ? conversations : mergedConversations, - lastModifiedAt: Option.getOrElse(stat.mtime, () => new Date()), - }; - - return sessionDetail; - }) - : (() => { - if (virtualConversation === null) { - return Effect.succeed(null); - } - - const lastConversation = virtualConversation.conversations + const virtualSessions = virtualConversations + .filter(({ sessionId }) => !sessionMap.has(sessionId)) + .map(({ sessionId, conversations }): Session => { + const first = conversations + .filter((conversation) => conversation.type === "user") + .at(0); + const last = conversations .filter( (conversation) => conversation.type === "user" || @@ -94,112 +241,42 @@ const getSession = (projectId: string, sessionId: string) => ) .at(-1); - const virtualSession: SessionDetail = { - id: sessionId, - jsonlFilePath: `${decodeProjectId(projectId)}/${sessionId}.jsonl`, - meta: { - messageCount: 0, - firstCommand: null, - }, - conversations: virtualConversation.conversations, - lastModifiedAt: - lastConversation !== undefined - ? new Date(lastConversation.timestamp) - : new Date(), - }; - - return Effect.succeed(virtualSession); - })(); - - return { - session: sessionDetail, - }; - }); - -const getSessions = ( - projectId: string, - options?: { - maxCount?: number; - cursor?: string; - }, -) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const sessionMetaService = yield* SessionMetaService; - const virtualConversationDatabase = yield* VirtualConversationDatabase; - - const { maxCount = 20, cursor } = options ?? {}; - - const claudeProjectPath = decodeProjectId(projectId); - - // Check if project directory exists - const dirExists = yield* fs.exists(claudeProjectPath); - if (!dirExists) { - console.warn(`Project directory not found at ${claudeProjectPath}`); - return { sessions: [] }; - } - - // Read directory entries with error handling - const dirents = yield* Effect.tryPromise({ - try: () => fs.readDirectory(claudeProjectPath).pipe(Effect.runPromise), - catch: (error) => { - console.warn( - `Failed to read sessions for project ${projectId}:`, - error, - ); - return new Error("Failed to read directory"); - }, - }).pipe(Effect.catchAll(() => Effect.succeed([]))); - - // Process session files - const sessionEffects = dirents - .filter((entry) => entry.endsWith(".jsonl")) - .map((entry) => - Effect.gen(function* () { - const fullPath = resolve(claudeProjectPath, entry); - const sessionId = encodeSessionId(fullPath); - - // Get file stats with error handling - const stat = yield* Effect.tryPromise(() => - fs.stat(fullPath).pipe(Effect.runPromise), - ).pipe(Effect.catchAll(() => Effect.succeed(null))); - - if (!stat) { - return null; - } + const firstUserText = + first !== undefined + ? typeof first.message.content === "string" + ? first.message.content + : (() => { + const firstContent = first.message.content.at(0); + if (firstContent === undefined) return null; + if (typeof firstContent === "string") return firstContent; + if (firstContent.type === "text") return firstContent.text; + return null; + })() + : null; return { id: sessionId, - jsonlFilePath: fullPath, - lastModifiedAt: Option.getOrElse(stat.mtime, () => new Date()), + jsonlFilePath: `${decodeProjectId(projectId)}/${sessionId}.jsonl`, + lastModifiedAt: + last !== undefined ? new Date(last.timestamp) : new Date(), + meta: { + messageCount: conversations.length, + firstCommand: firstUserText + ? parseCommandXml(firstUserText) + : null, + }, }; - }), - ); + }) + .sort((a, b) => { + return b.lastModifiedAt.getTime() - a.lastModifiedAt.getTime(); + }); - // Execute all effects in parallel and filter out nulls - const sessionsWithNulls = yield* Effect.all(sessionEffects, { - concurrency: "unbounded", - }); - const sessions = sessionsWithNulls - .filter((s): s is NonNullable => s !== null) - .sort((a, b) => b.lastModifiedAt.getTime() - a.lastModifiedAt.getTime()); - - const sessionMap = new Map( - sessions.map((session) => [session.id, session] as const), - ); - - const index = - cursor !== undefined - ? sessions.findIndex((session) => session.id === cursor) - : -1; - - if (index !== -1) { + // Get sessions with metadata const sessionsToReturn = sessions.slice( - index + 1, - Math.min(index + 1 + maxCount, sessions.length), + 0, + Math.min(maxCount, sessions.length), ); - - const sessionsWithMeta = yield* Effect.all( + const sessionsWithMeta: Session[] = yield* Effect.all( sessionsToReturn.map((item) => Effect.gen(function* () { const meta = yield* sessionMetaService.getSessionMeta( @@ -216,94 +293,21 @@ const getSessions = ( ); return { - sessions: sessionsWithMeta, + sessions: [...virtualSessions, ...sessionsWithMeta], }; - } + }); - // Get predict sessions - const virtualConversations = - yield* virtualConversationDatabase.getProjectVirtualConversations( - projectId, - ); + return { + getSession, + getSessions, + }; +}); - const virtualSessions = virtualConversations - .filter(({ sessionId }) => !sessionMap.has(sessionId)) - .map(({ sessionId, conversations }): Session => { - const first = conversations - .filter((conversation) => conversation.type === "user") - .at(0); - const last = conversations - .filter( - (conversation) => - conversation.type === "user" || - conversation.type === "assistant" || - conversation.type === "system", - ) - .at(-1); - - const firstUserText = - first !== undefined - ? typeof first.message.content === "string" - ? first.message.content - : (() => { - const firstContent = first.message.content.at(0); - if (firstContent === undefined) return null; - if (typeof firstContent === "string") return firstContent; - if (firstContent.type === "text") return firstContent.text; - return null; - })() - : null; - - return { - id: sessionId, - jsonlFilePath: `${decodeProjectId(projectId)}/${sessionId}.jsonl`, - lastModifiedAt: - last !== undefined ? new Date(last.timestamp) : new Date(), - meta: { - messageCount: conversations.length, - firstCommand: firstUserText ? parseCommandXml(firstUserText) : null, - }, - }; - }) - .sort((a, b) => { - return b.lastModifiedAt.getTime() - a.lastModifiedAt.getTime(); - }); - - // Get sessions with metadata - const sessionsToReturn = sessions.slice( - 0, - Math.min(maxCount, sessions.length), - ); - const sessionsWithMeta: Session[] = yield* Effect.all( - sessionsToReturn.map((item) => - Effect.gen(function* () { - const meta = yield* sessionMetaService.getSessionMeta( - projectId, - item.id, - ); - return { - ...item, - meta, - }; - }), - ), - { concurrency: "unbounded" }, - ); - - return { - sessions: [...virtualSessions, ...sessionsWithMeta], - }; - }); +export type ISessionRepository = InferEffect; export class SessionRepository extends Context.Tag("SessionRepository")< SessionRepository, - { - readonly getSession: typeof getSession; - readonly getSessions: typeof getSessions; - } + ISessionRepository >() { - static Live = Layer.succeed(this, { - getSession, - getSessions, - }); + static Live = Layer.effect(this, LayerImpl); } diff --git a/src/server/hono/route.ts b/src/server/hono/route.ts index 3354775..cd3a157 100644 --- a/src/server/hono/route.ts +++ b/src/server/hono/route.ts @@ -131,9 +131,11 @@ export const routes = (app: HonoAppType) => async (c) => { const response = await effectToResponse( c, - projectController.createProject({ - ...c.req.valid("json"), - }), + projectController + .createProject({ + ...c.req.valid("json"), + }) + .pipe(Effect.provide(runtime)), ); return response; }, diff --git a/src/server/lib/storage/FileCacheStorage/PersistentService.ts b/src/server/lib/storage/FileCacheStorage/PersistentService.ts index 1dc1a32..e1e6871 100644 --- a/src/server/lib/storage/FileCacheStorage/PersistentService.ts +++ b/src/server/lib/storage/FileCacheStorage/PersistentService.ts @@ -1,64 +1,67 @@ -import { resolve } from "node:path"; -import { FileSystem } from "@effect/platform"; +import { FileSystem, Path } from "@effect/platform"; import { Context, Effect, Layer } from "effect"; import { z } from "zod"; import { claudeCodeViewerCacheDirPath } from "../../config/paths"; +import type { InferEffect } from "../../effect/types"; const saveSchema = z.array(z.tuple([z.string(), z.unknown()])); -const getCacheFilePath = (key: string) => - resolve(claudeCodeViewerCacheDirPath, `${key}.json`); +const LayerImpl = Effect.gen(function* () { + const path = yield* Path.Path; -const load = (key: string) => { - const cacheFilePath = getCacheFilePath(key); + const getCacheFilePath = (key: string) => + path.resolve(claudeCodeViewerCacheDirPath, `${key}.json`); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; + const load = (key: string) => { + const cacheFilePath = getCacheFilePath(key); - if (!(yield* fs.exists(claudeCodeViewerCacheDirPath))) { - yield* fs.makeDirectory(claudeCodeViewerCacheDirPath, { - recursive: true, - }); - } + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; - if (!(yield* fs.exists(cacheFilePath))) { - yield* fs.writeFileString(cacheFilePath, "[]"); - } else { - const content = yield* fs.readFileString(cacheFilePath); - const parsed = saveSchema.safeParse(JSON.parse(content)); + if (!(yield* fs.exists(claudeCodeViewerCacheDirPath))) { + yield* fs.makeDirectory(claudeCodeViewerCacheDirPath, { + recursive: true, + }); + } - if (!parsed.success) { + if (!(yield* fs.exists(cacheFilePath))) { yield* fs.writeFileString(cacheFilePath, "[]"); } else { - parsed.data; - return parsed.data; + const content = yield* fs.readFileString(cacheFilePath); + const parsed = saveSchema.safeParse(JSON.parse(content)); + + if (!parsed.success) { + yield* fs.writeFileString(cacheFilePath, "[]"); + } else { + parsed.data; + return parsed.data; + } } - } - return []; - }); -}; + return []; + }); + }; -const save = (key: string, entries: readonly [string, unknown][]) => { - const cacheFilePath = getCacheFilePath(key); + const save = (key: string, entries: readonly [string, unknown][]) => { + const cacheFilePath = getCacheFilePath(key); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - yield* fs.writeFileString(cacheFilePath, JSON.stringify(entries)); - }); -}; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.writeFileString(cacheFilePath, JSON.stringify(entries)); + }); + }; + + return { + load, + save, + }; +}); + +export type IPersistentService = InferEffect; export class PersistentService extends Context.Tag("PersistentService")< PersistentService, - { - readonly load: typeof load; - readonly save: typeof save; - } + IPersistentService >() { - static Live = Layer.succeed(this, { - load, - save, - }); + static Live = Layer.effect(this, LayerImpl); } - -export type IPersistentService = Context.Tag.Service;