mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2025-12-20 23:04:19 +01:00
refactor: use more effect-ts
This commit is contained in:
@@ -1,9 +1,13 @@
|
|||||||
import path from "node:path";
|
import { Path } from "@effect/platform";
|
||||||
|
import { Effect } from "effect";
|
||||||
import { claudeProjectsDirPath } from "../../../lib/config/paths";
|
import { claudeProjectsDirPath } from "../../../lib/config/paths";
|
||||||
|
|
||||||
export function computeClaudeProjectFilePath(projectPath: string): string {
|
export const computeClaudeProjectFilePath = (projectPath: string) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const path = yield* Path.Path;
|
||||||
|
|
||||||
return path.join(
|
return path.join(
|
||||||
claudeProjectsDirPath,
|
claudeProjectsDirPath,
|
||||||
projectPath.replace(/\/$/, "").replace(/\//g, "-"),
|
projectPath.replace(/\/$/, "").replace(/\//g, "-"),
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const getMcpListOutput = (projectCwd: string) =>
|
|||||||
claudeCodeExecutablePath,
|
claudeCodeExecutablePath,
|
||||||
"mcp",
|
"mcp",
|
||||||
"list",
|
"list",
|
||||||
),
|
).pipe(Command.runInShell(true)),
|
||||||
);
|
);
|
||||||
return output;
|
return output;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import { FileSystem, Path } from "@effect/platform";
|
||||||
import { Context, Effect, Layer } from "effect";
|
import { Context, Effect, Layer } from "effect";
|
||||||
import { claudeCommandsDirPath } from "../../../lib/config/paths";
|
import { claudeCommandsDirPath } from "../../../lib/config/paths";
|
||||||
import type { ControllerResponse } from "../../../lib/effect/toEffectResponse";
|
import type { ControllerResponse } from "../../../lib/effect/toEffectResponse";
|
||||||
import type { InferEffect } from "../../../lib/effect/types";
|
import type { InferEffect } from "../../../lib/effect/types";
|
||||||
import { ProjectRepository } from "../../project/infrastructure/ProjectRepository";
|
import { ProjectRepository } from "../../project/infrastructure/ProjectRepository";
|
||||||
import { ClaudeCodeService } from "../services/ClaudeCodeService";
|
import { ClaudeCodeService } from "../services/ClaudeCodeService";
|
||||||
import { FileSystem, Path } from "@effect/platform";
|
|
||||||
|
|
||||||
const LayerImpl = Effect.gen(function* () {
|
const LayerImpl = Effect.gen(function* () {
|
||||||
const projectRepository = yield* ProjectRepository;
|
const projectRepository = yield* ProjectRepository;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { type FSWatcher, watch } from "node:fs";
|
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 { Context, Effect, Layer, Ref } from "effect";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { claudeProjectsDirPath } from "../../../lib/config/paths";
|
import { claudeProjectsDirPath } from "../../../lib/config/paths";
|
||||||
@@ -24,6 +24,7 @@ export class FileWatcherService extends Context.Tag("FileWatcherService")<
|
|||||||
static Live = Layer.effect(
|
static Live = Layer.effect(
|
||||||
this,
|
this,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const path = yield* Path.Path;
|
||||||
const eventBus = yield* EventBus;
|
const eventBus = yield* EventBus;
|
||||||
const isWatchingRef = yield* Ref.make(false);
|
const isWatchingRef = yield* Ref.make(false);
|
||||||
const watcherRef = yield* Ref.make<FSWatcher | null>(null);
|
const watcherRef = yield* Ref.make<FSWatcher | null>(null);
|
||||||
@@ -44,6 +45,7 @@ export class FileWatcherService extends Context.Tag("FileWatcherService")<
|
|||||||
yield* Effect.tryPromise({
|
yield* Effect.tryPromise({
|
||||||
try: async () => {
|
try: async () => {
|
||||||
console.log("Starting file watcher on:", claudeProjectsDirPath);
|
console.log("Starting file watcher on:", claudeProjectsDirPath);
|
||||||
|
|
||||||
const watcher = watch(
|
const watcher = watch(
|
||||||
claudeProjectsDirPath,
|
claudeProjectsDirPath,
|
||||||
{ persistent: false, recursive: true },
|
{ persistent: false, recursive: true },
|
||||||
@@ -59,7 +61,7 @@ export class FileWatcherService extends Context.Tag("FileWatcherService")<
|
|||||||
const { sessionId } = groups.data;
|
const { sessionId } = groups.data;
|
||||||
|
|
||||||
// フルパスを構築してエンコードされた projectId を取得
|
// フルパスを構築してエンコードされた projectId を取得
|
||||||
const fullPath = join(claudeProjectsDirPath, filename);
|
const fullPath = path.join(claudeProjectsDirPath, filename);
|
||||||
const encodedProjectId =
|
const encodedProjectId =
|
||||||
encodeProjectIdFromSessionFilePath(fullPath);
|
encodeProjectIdFromSessionFilePath(fullPath);
|
||||||
const debounceKey = `${encodedProjectId}/${sessionId}`;
|
const debounceKey = `${encodedProjectId}/${sessionId}`;
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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"}`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
126
src/server/core/git/functions/parseGitBranchesOutput.test.ts
Normal file
126
src/server/core/git/functions/parseGitBranchesOutput.test.ts
Normal 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/日本語ブランチ");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
63
src/server/core/git/functions/parseGitBranchesOutput.ts
Normal file
63
src/server/core/git/functions/parseGitBranchesOutput.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,23 +1,10 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { getCommits } from "./getCommits";
|
import { parseGitCommitsOutput } from "./parseGitCommitsOutput";
|
||||||
import * as utils from "./utils";
|
import * as utils from "./utils";
|
||||||
|
|
||||||
vi.mock("./utils", async (importOriginal) => {
|
|
||||||
const actual = await importOriginal<typeof utils>();
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
executeGitCommand: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getCommits", () => {
|
describe("getCommits", () => {
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("正常系", () => {
|
describe("正常系", () => {
|
||||||
it("コミット一覧を取得できる", async () => {
|
it("コミット一覧を取得できる", async () => {
|
||||||
const mockCwd = "/test/repo";
|
|
||||||
const mockOutput = `abc123|feat: add new feature|John Doe|2024-01-15 10:30:00 +0900
|
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
|
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`;
|
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,
|
data: mockOutput,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await getCommits(mockCwd);
|
const result = parseGitCommitsOutput(mockOutput);
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
if (result.success) {
|
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",
|
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 () => {
|
it("空の結果を返す(コミットがない場合)", async () => {
|
||||||
const mockCwd = "/test/repo";
|
|
||||||
const mockOutput = "";
|
const mockOutput = "";
|
||||||
|
|
||||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||||
@@ -74,7 +48,7 @@ ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`;
|
|||||||
data: mockOutput,
|
data: mockOutput,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await getCommits(mockCwd);
|
const result = parseGitCommitsOutput(mockOutput);
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -83,7 +57,6 @@ ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`;
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("不正な形式の行をスキップする", async () => {
|
it("不正な形式の行をスキップする", async () => {
|
||||||
const mockCwd = "/test/repo";
|
|
||||||
const mockOutput = `abc123|feat: add new feature|John Doe|2024-01-15 10:30:00 +0900
|
const mockOutput = `abc123|feat: add new feature|John Doe|2024-01-15 10:30:00 +0900
|
||||||
invalid line without enough pipes
|
invalid line without enough pipes
|
||||||
def456|fix: bug fix|Jane Smith|2024-01-14 09:20:00 +0900
|
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,
|
data: mockOutput,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await getCommits(mockCwd);
|
const result = parseGitCommitsOutput(mockOutput);
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
if (result.success) {
|
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("エッジケース", () => {
|
describe("エッジケース", () => {
|
||||||
it("特殊文字を含むコミットメッセージを処理できる", async () => {
|
it("特殊文字を含むコミットメッセージを処理できる", async () => {
|
||||||
const mockCwd = "/test/repo";
|
|
||||||
const mockOutput = `abc123|feat: add "quotes" & <special> chars|Author Name|2024-01-15 10:30:00 +0900
|
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`;
|
def456|fix: 日本語メッセージ|日本語 著者|2024-01-14 09:20:00 +0900`;
|
||||||
|
|
||||||
@@ -185,7 +90,7 @@ def456|fix: 日本語メッセージ|日本語 著者|2024-01-14 09:20:00 +0900`
|
|||||||
data: mockOutput,
|
data: mockOutput,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await getCommits(mockCwd);
|
const result = parseGitCommitsOutput(mockOutput);
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -199,7 +104,6 @@ def456|fix: 日本語メッセージ|日本語 著者|2024-01-14 09:20:00 +0900`
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("空白を含むパスでも正常に動作する", async () => {
|
it("空白を含むパスでも正常に動作する", async () => {
|
||||||
const mockCwd = "/test/my repo with spaces";
|
|
||||||
const mockOutput = `abc123|feat: test|Author|2024-01-15 10:30:00 +0900`;
|
const mockOutput = `abc123|feat: test|Author|2024-01-15 10:30:00 +0900`;
|
||||||
|
|
||||||
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
vi.mocked(utils.executeGitCommand).mockResolvedValue({
|
||||||
@@ -207,21 +111,15 @@ def456|fix: 日本語メッセージ|日本語 著者|2024-01-14 09:20:00 +0900`
|
|||||||
data: mockOutput,
|
data: mockOutput,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await getCommits(mockCwd);
|
const result = parseGitCommitsOutput(mockOutput);
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
expect(result.data).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(utils.executeGitCommand).toHaveBeenCalledWith(
|
|
||||||
expect.any(Array),
|
|
||||||
mockCwd,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("空行やスペースのみの行をスキップする", async () => {
|
it("空行やスペースのみの行をスキップする", async () => {
|
||||||
const mockCwd = "/test/repo";
|
|
||||||
const mockOutput = `abc123|feat: add feature|Author|2024-01-15 10:30:00 +0900
|
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,
|
data: mockOutput,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await getCommits(mockCwd);
|
const result = parseGitCommitsOutput(mockOutput);
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
31
src/server/core/git/functions/parseGitCommitsOutput.ts
Normal file
31
src/server/core/git/functions/parseGitCommitsOutput.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -2,11 +2,11 @@ import { Context, Effect, Layer } from "effect";
|
|||||||
import type { ControllerResponse } from "../../../lib/effect/toEffectResponse";
|
import type { ControllerResponse } from "../../../lib/effect/toEffectResponse";
|
||||||
import type { InferEffect } from "../../../lib/effect/types";
|
import type { InferEffect } from "../../../lib/effect/types";
|
||||||
import { ProjectRepository } from "../../project/infrastructure/ProjectRepository";
|
import { ProjectRepository } from "../../project/infrastructure/ProjectRepository";
|
||||||
import { getBranches } from "../functions/getBranches";
|
|
||||||
import { getCommits } from "../functions/getCommits";
|
|
||||||
import { getDiff } from "../functions/getDiff";
|
import { getDiff } from "../functions/getDiff";
|
||||||
|
import { GitService } from "../services/GitService";
|
||||||
|
|
||||||
const LayerImpl = Effect.gen(function* () {
|
const LayerImpl = Effect.gen(function* () {
|
||||||
|
const gitService = yield* GitService;
|
||||||
const projectRepository = yield* ProjectRepository;
|
const projectRepository = yield* ProjectRepository;
|
||||||
|
|
||||||
const getGitBranches = (options: { projectId: string }) =>
|
const getGitBranches = (options: { projectId: string }) =>
|
||||||
@@ -23,26 +23,11 @@ const LayerImpl = Effect.gen(function* () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const projectPath = project.meta.projectPath;
|
const projectPath = project.meta.projectPath;
|
||||||
|
const branches = yield* gitService.getBranches(projectPath);
|
||||||
try {
|
|
||||||
const result = yield* Effect.promise(() => getBranches(projectPath));
|
|
||||||
return {
|
return {
|
||||||
response: result,
|
response: branches,
|
||||||
status: 200,
|
status: 200,
|
||||||
} as const satisfies ControllerResponse;
|
} 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 getGitCommits = (options: { projectId: string }) =>
|
const getGitCommits = (options: { projectId: string }) =>
|
||||||
@@ -60,25 +45,11 @@ const LayerImpl = Effect.gen(function* () {
|
|||||||
|
|
||||||
const projectPath = project.meta.projectPath;
|
const projectPath = project.meta.projectPath;
|
||||||
|
|
||||||
try {
|
const commits = yield* gitService.getCommits(projectPath);
|
||||||
const result = yield* Effect.promise(() => getCommits(projectPath));
|
|
||||||
return {
|
return {
|
||||||
response: result,
|
response: commits,
|
||||||
status: 200,
|
status: 200,
|
||||||
} as const satisfies ControllerResponse;
|
} 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 getGitDiff = (options: {
|
const getGitDiff = (options: {
|
||||||
|
|||||||
128
src/server/core/git/services/GitService.ts
Normal file
128
src/server/core/git/services/GitService.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { resolve } from "node:path";
|
import { FileSystem, Path } from "@effect/platform";
|
||||||
import { FileSystem } from "@effect/platform";
|
|
||||||
import { Context, Effect, Layer, Option } from "effect";
|
import { Context, Effect, Layer, Option } from "effect";
|
||||||
import { claudeProjectsDirPath } from "../../../lib/config/paths";
|
import { claudeProjectsDirPath } from "../../../lib/config/paths";
|
||||||
import type { InferEffect } from "../../../lib/effect/types";
|
import type { InferEffect } from "../../../lib/effect/types";
|
||||||
@@ -9,6 +8,7 @@ import { ProjectMetaService } from "../services/ProjectMetaService";
|
|||||||
|
|
||||||
const LayerImpl = Effect.gen(function* () {
|
const LayerImpl = Effect.gen(function* () {
|
||||||
const fs = yield* FileSystem.FileSystem;
|
const fs = yield* FileSystem.FileSystem;
|
||||||
|
const path = yield* Path.Path;
|
||||||
const projectMetaService = yield* ProjectMetaService;
|
const projectMetaService = yield* ProjectMetaService;
|
||||||
|
|
||||||
const getProject = (projectId: string) =>
|
const getProject = (projectId: string) =>
|
||||||
@@ -54,7 +54,7 @@ const LayerImpl = Effect.gen(function* () {
|
|||||||
// Filter directories and map to Project objects
|
// Filter directories and map to Project objects
|
||||||
const projectEffects = entries.map((entry) =>
|
const projectEffects = entries.map((entry) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fullPath = resolve(claudeProjectsDirPath, entry);
|
const fullPath = path.resolve(claudeProjectsDirPath, entry);
|
||||||
|
|
||||||
// Check if it's a directory
|
// Check if it's a directory
|
||||||
const stat = yield* Effect.tryPromise(() =>
|
const stat = yield* Effect.tryPromise(() =>
|
||||||
|
|||||||
@@ -122,7 +122,8 @@ const LayerImpl = Effect.gen(function* () {
|
|||||||
|
|
||||||
// No project validation needed - startTask will create a new project
|
// No project validation needed - startTask will create a new project
|
||||||
// if it doesn't exist when running /init command
|
// if it doesn't exist when running /init command
|
||||||
const claudeProjectFilePath = computeClaudeProjectFilePath(projectPath);
|
const claudeProjectFilePath =
|
||||||
|
yield* computeClaudeProjectFilePath(projectPath);
|
||||||
const projectId = encodeProjectId(claudeProjectFilePath);
|
const projectId = encodeProjectId(claudeProjectFilePath);
|
||||||
const config = yield* honoConfigService.getConfig();
|
const config = yield* honoConfigService.getConfig();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { basename } from "node:path";
|
|
||||||
import { FileSystem, Path } from "@effect/platform";
|
import { FileSystem, Path } from "@effect/platform";
|
||||||
import { Context, Effect, Layer, Option, Ref } from "effect";
|
import { Context, Effect, Layer, Option, Ref } from "effect";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -115,7 +114,7 @@ export class ProjectMetaService extends Context.Tag("ProjectMetaService")<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const projectMeta: ProjectMeta = {
|
const projectMeta: ProjectMeta = {
|
||||||
projectName: projectPath ? basename(projectPath) : null,
|
projectName: projectPath ? path.basename(projectPath) : null,
|
||||||
projectPath,
|
projectPath,
|
||||||
sessionCount: files.length,
|
sessionCount: files.length,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { resolve } from "node:path";
|
import { FileSystem, Path } from "@effect/platform";
|
||||||
import { FileSystem } from "@effect/platform";
|
|
||||||
import { Context, Effect, Layer, Option } from "effect";
|
import { Context, Effect, Layer, Option } from "effect";
|
||||||
|
import type { InferEffect } from "../../../lib/effect/types";
|
||||||
import { parseCommandXml } from "../../claude-code/functions/parseCommandXml";
|
import { parseCommandXml } from "../../claude-code/functions/parseCommandXml";
|
||||||
import { parseJsonl } from "../../claude-code/functions/parseJsonl";
|
import { parseJsonl } from "../../claude-code/functions/parseJsonl";
|
||||||
import { decodeProjectId } from "../../project/functions/id";
|
import { decodeProjectId } from "../../project/functions/id";
|
||||||
@@ -9,12 +9,14 @@ import { decodeSessionId, encodeSessionId } from "../functions/id";
|
|||||||
import { VirtualConversationDatabase } from "../infrastructure/VirtualConversationDatabase";
|
import { VirtualConversationDatabase } from "../infrastructure/VirtualConversationDatabase";
|
||||||
import { SessionMetaService } from "../services/SessionMetaService";
|
import { SessionMetaService } from "../services/SessionMetaService";
|
||||||
|
|
||||||
const getSession = (projectId: string, sessionId: string) =>
|
const LayerImpl = Effect.gen(function* () {
|
||||||
Effect.gen(function* () {
|
|
||||||
const fs = yield* FileSystem.FileSystem;
|
const fs = yield* FileSystem.FileSystem;
|
||||||
|
const path = yield* Path.Path;
|
||||||
const sessionMetaService = yield* SessionMetaService;
|
const sessionMetaService = yield* SessionMetaService;
|
||||||
const virtualConversationDatabase = yield* VirtualConversationDatabase;
|
const virtualConversationDatabase = yield* VirtualConversationDatabase;
|
||||||
|
|
||||||
|
const getSession = (projectId: string, sessionId: string) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
const sessionPath = decodeSessionId(projectId, sessionId);
|
const sessionPath = decodeSessionId(projectId, sessionId);
|
||||||
|
|
||||||
const virtualConversation =
|
const virtualConversation =
|
||||||
@@ -124,10 +126,6 @@ const getSessions = (
|
|||||||
},
|
},
|
||||||
) =>
|
) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FileSystem.FileSystem;
|
|
||||||
const sessionMetaService = yield* SessionMetaService;
|
|
||||||
const virtualConversationDatabase = yield* VirtualConversationDatabase;
|
|
||||||
|
|
||||||
const { maxCount = 20, cursor } = options ?? {};
|
const { maxCount = 20, cursor } = options ?? {};
|
||||||
|
|
||||||
const claudeProjectPath = decodeProjectId(projectId);
|
const claudeProjectPath = decodeProjectId(projectId);
|
||||||
@@ -156,7 +154,7 @@ const getSessions = (
|
|||||||
.filter((entry) => entry.endsWith(".jsonl"))
|
.filter((entry) => entry.endsWith(".jsonl"))
|
||||||
.map((entry) =>
|
.map((entry) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fullPath = resolve(claudeProjectPath, entry);
|
const fullPath = path.resolve(claudeProjectPath, entry);
|
||||||
const sessionId = encodeSessionId(fullPath);
|
const sessionId = encodeSessionId(fullPath);
|
||||||
|
|
||||||
// Get file stats with error handling
|
// Get file stats with error handling
|
||||||
@@ -182,7 +180,9 @@ const getSessions = (
|
|||||||
});
|
});
|
||||||
const sessions = sessionsWithNulls
|
const sessions = sessionsWithNulls
|
||||||
.filter((s): s is NonNullable<typeof s> => s !== null)
|
.filter((s): s is NonNullable<typeof s> => s !== null)
|
||||||
.sort((a, b) => b.lastModifiedAt.getTime() - a.lastModifiedAt.getTime());
|
.sort(
|
||||||
|
(a, b) => b.lastModifiedAt.getTime() - a.lastModifiedAt.getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
const sessionMap = new Map(
|
const sessionMap = new Map(
|
||||||
sessions.map((session) => [session.id, session] as const),
|
sessions.map((session) => [session.id, session] as const),
|
||||||
@@ -261,7 +261,9 @@ const getSessions = (
|
|||||||
last !== undefined ? new Date(last.timestamp) : new Date(),
|
last !== undefined ? new Date(last.timestamp) : new Date(),
|
||||||
meta: {
|
meta: {
|
||||||
messageCount: conversations.length,
|
messageCount: conversations.length,
|
||||||
firstCommand: firstUserText ? parseCommandXml(firstUserText) : null,
|
firstCommand: firstUserText
|
||||||
|
? parseCommandXml(firstUserText)
|
||||||
|
: null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -295,15 +297,17 @@ const getSessions = (
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export class SessionRepository extends Context.Tag("SessionRepository")<
|
return {
|
||||||
SessionRepository,
|
|
||||||
{
|
|
||||||
readonly getSession: typeof getSession;
|
|
||||||
readonly getSessions: typeof getSessions;
|
|
||||||
}
|
|
||||||
>() {
|
|
||||||
static Live = Layer.succeed(this, {
|
|
||||||
getSession,
|
getSession,
|
||||||
getSessions,
|
getSessions,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type ISessionRepository = InferEffect<typeof LayerImpl>;
|
||||||
|
|
||||||
|
export class SessionRepository extends Context.Tag("SessionRepository")<
|
||||||
|
SessionRepository,
|
||||||
|
ISessionRepository
|
||||||
|
>() {
|
||||||
|
static Live = Layer.effect(this, LayerImpl);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,9 +131,11 @@ export const routes = (app: HonoAppType) =>
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const response = await effectToResponse(
|
const response = await effectToResponse(
|
||||||
c,
|
c,
|
||||||
projectController.createProject({
|
projectController
|
||||||
|
.createProject({
|
||||||
...c.req.valid("json"),
|
...c.req.valid("json"),
|
||||||
}),
|
})
|
||||||
|
.pipe(Effect.provide(runtime)),
|
||||||
);
|
);
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { resolve } from "node:path";
|
import { FileSystem, Path } from "@effect/platform";
|
||||||
import { FileSystem } from "@effect/platform";
|
|
||||||
import { Context, Effect, Layer } from "effect";
|
import { Context, Effect, Layer } from "effect";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { claudeCodeViewerCacheDirPath } from "../../config/paths";
|
import { claudeCodeViewerCacheDirPath } from "../../config/paths";
|
||||||
|
import type { InferEffect } from "../../effect/types";
|
||||||
|
|
||||||
const saveSchema = z.array(z.tuple([z.string(), z.unknown()]));
|
const saveSchema = z.array(z.tuple([z.string(), z.unknown()]));
|
||||||
|
|
||||||
|
const LayerImpl = Effect.gen(function* () {
|
||||||
|
const path = yield* Path.Path;
|
||||||
|
|
||||||
const getCacheFilePath = (key: string) =>
|
const getCacheFilePath = (key: string) =>
|
||||||
resolve(claudeCodeViewerCacheDirPath, `${key}.json`);
|
path.resolve(claudeCodeViewerCacheDirPath, `${key}.json`);
|
||||||
|
|
||||||
const load = (key: string) => {
|
const load = (key: string) => {
|
||||||
const cacheFilePath = getCacheFilePath(key);
|
const cacheFilePath = getCacheFilePath(key);
|
||||||
@@ -48,17 +51,17 @@ const save = (key: string, entries: readonly [string, unknown][]) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export class PersistentService extends Context.Tag("PersistentService")<
|
return {
|
||||||
PersistentService,
|
|
||||||
{
|
|
||||||
readonly load: typeof load;
|
|
||||||
readonly save: typeof save;
|
|
||||||
}
|
|
||||||
>() {
|
|
||||||
static Live = Layer.succeed(this, {
|
|
||||||
load,
|
load,
|
||||||
save,
|
save,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
export type IPersistentService = Context.Tag.Service<PersistentService>;
|
export type IPersistentService = InferEffect<typeof LayerImpl>;
|
||||||
|
|
||||||
|
export class PersistentService extends Context.Tag("PersistentService")<
|
||||||
|
PersistentService,
|
||||||
|
IPersistentService
|
||||||
|
>() {
|
||||||
|
static Live = Layer.effect(this, LayerImpl);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user