feat(#71): implement graph data API

Implemented three new API endpoints for knowledge graph visualization:

1. GET /api/knowledge/graph - Full knowledge graph
   - Returns all entries and links with optional filtering
   - Supports filtering by tags, status, and node count limit
   - Includes orphan detection (entries with no links)

2. GET /api/knowledge/graph/stats - Graph statistics
   - Total entries and links counts
   - Orphan entries detection
   - Average links per entry
   - Top 10 most connected entries
   - Tag distribution across entries

3. GET /api/knowledge/graph/:slug - Entry-centered subgraph
   - Returns graph centered on specific entry
   - Supports depth parameter (1-5) for traversal distance
   - Includes all connected nodes up to specified depth

New Files:
- apps/api/src/knowledge/graph.controller.ts
- apps/api/src/knowledge/graph.controller.spec.ts

Modified Files:
- apps/api/src/knowledge/dto/graph-query.dto.ts (added GraphFilterDto)
- apps/api/src/knowledge/entities/graph.entity.ts (extended with new types)
- apps/api/src/knowledge/services/graph.service.ts (added new methods)
- apps/api/src/knowledge/services/graph.service.spec.ts (added tests)
- apps/api/src/knowledge/knowledge.module.ts (registered controller)
- apps/api/src/knowledge/dto/index.ts (exported new DTOs)
- docs/scratchpads/71-graph-data-api.md (implementation notes)

Test Coverage: 21 tests (all passing)
- 14 service tests including orphan detection, filtering, statistics
- 7 controller tests for all three endpoints

Follows TDD principles with tests written before implementation.
All code quality gates passed (lint, typecheck, tests).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jason Woltje
2026-02-02 15:27:00 -06:00
parent 3969dd5598
commit 5d348526de
240 changed files with 10400 additions and 23 deletions

View File

@@ -0,0 +1,412 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ConflictDetectionService } from "./conflict-detection.service";
import { ConflictDetectionError } from "./types";
// Mock simple-git
const mockGit = {
fetch: vi.fn(),
status: vi.fn(),
raw: vi.fn(),
revparse: vi.fn(),
};
vi.mock("simple-git", () => ({
simpleGit: vi.fn(() => mockGit),
}));
describe("ConflictDetectionService", () => {
let service: ConflictDetectionService;
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks();
// Create service
service = new ConflictDetectionService();
});
it("should be defined", () => {
expect(service).toBeDefined();
});
describe("checkForConflicts", () => {
it("should return no conflicts when branches can merge cleanly", async () => {
// Mock successful fetch
mockGit.fetch.mockResolvedValue(undefined);
// Mock current branch
mockGit.revparse.mockResolvedValue("feature-branch");
// Mock merge test - no conflicts
mockGit.raw.mockResolvedValue("");
// Mock status - no conflicted files
mockGit.status.mockResolvedValue({
conflicted: [],
files: [],
});
const result = await service.checkForConflicts("/test/repo", {
remote: "origin",
remoteBranch: "develop",
strategy: "merge",
});
expect(result.hasConflicts).toBe(false);
expect(result.conflicts).toHaveLength(0);
expect(result.strategy).toBe("merge");
expect(result.remoteBranch).toBe("develop");
expect(mockGit.fetch).toHaveBeenCalledWith("origin", "develop");
});
it("should detect merge conflicts", async () => {
// Mock successful fetch
mockGit.fetch.mockResolvedValue(undefined);
// Mock current branch
mockGit.revparse.mockResolvedValue("feature-branch");
// Mock merge test - conflicts detected
mockGit.raw.mockRejectedValueOnce(
new Error("CONFLICT (content): Merge conflict in file.ts"),
);
// Mock status - show conflicted files
mockGit.status.mockResolvedValue({
conflicted: ["src/file.ts", "src/other.ts"],
files: [
{
path: "src/file.ts",
index: "U",
working_dir: "U",
},
{
path: "src/other.ts",
index: "U",
working_dir: "U",
},
],
});
// Mock merge abort (cleanup)
mockGit.raw.mockResolvedValue("");
const result = await service.checkForConflicts("/test/repo", {
remote: "origin",
remoteBranch: "develop",
strategy: "merge",
});
expect(result.hasConflicts).toBe(true);
expect(result.conflicts).toHaveLength(2);
expect(result.conflicts[0].file).toBe("src/file.ts");
expect(result.conflicts[0].type).toBe("content");
expect(result.canRetry).toBe(true);
});
it("should detect rebase conflicts", async () => {
// Mock successful fetch
mockGit.fetch.mockResolvedValue(undefined);
// Mock current branch
mockGit.revparse.mockResolvedValue("feature-branch");
// Mock rebase test - conflicts detected
mockGit.raw.mockRejectedValueOnce(
new Error("CONFLICT (content): Rebase conflict in file.ts"),
);
// Mock status - show conflicted files
mockGit.status.mockResolvedValue({
conflicted: ["src/file.ts"],
files: [
{
path: "src/file.ts",
index: "U",
working_dir: "U",
},
],
});
// Mock rebase abort (cleanup)
mockGit.raw.mockResolvedValue("");
const result = await service.checkForConflicts("/test/repo", {
remote: "origin",
remoteBranch: "develop",
strategy: "rebase",
});
expect(result.hasConflicts).toBe(true);
expect(result.conflicts).toHaveLength(1);
expect(result.strategy).toBe("rebase");
});
it("should handle fetch failure", async () => {
// Mock fetch failure
mockGit.fetch.mockRejectedValue(new Error("Network error"));
await expect(
service.checkForConflicts("/test/repo", {
remote: "origin",
remoteBranch: "develop",
}),
).rejects.toThrow(ConflictDetectionError);
});
it("should detect delete conflicts", async () => {
// Mock successful fetch
mockGit.fetch.mockResolvedValue(undefined);
// Mock current branch
mockGit.revparse.mockResolvedValue("feature-branch");
// Mock merge test - conflicts detected
mockGit.raw.mockRejectedValueOnce(
new Error("CONFLICT (delete/modify): file.ts deleted in HEAD"),
);
// Mock status - show conflicted files with delete
mockGit.status.mockResolvedValue({
conflicted: ["src/file.ts"],
files: [
{
path: "src/file.ts",
index: "D",
working_dir: "U",
},
],
});
// Mock merge abort
mockGit.raw.mockResolvedValue("");
const result = await service.checkForConflicts("/test/repo", {
remote: "origin",
remoteBranch: "develop",
strategy: "merge",
});
expect(result.hasConflicts).toBe(true);
expect(result.conflicts[0].type).toBe("delete");
});
it("should detect add conflicts", async () => {
// Mock successful fetch
mockGit.fetch.mockResolvedValue(undefined);
// Mock current branch
mockGit.revparse.mockResolvedValue("feature-branch");
// Mock merge test - conflicts detected
mockGit.raw.mockRejectedValueOnce(
new Error("CONFLICT (add/add): Merge conflict in file.ts"),
);
// Mock status - show conflicted files with add
mockGit.status.mockResolvedValue({
conflicted: ["src/file.ts"],
files: [
{
path: "src/file.ts",
index: "A",
working_dir: "A",
},
],
});
// Mock merge abort
mockGit.raw.mockResolvedValue("");
const result = await service.checkForConflicts("/test/repo", {
remote: "origin",
remoteBranch: "develop",
strategy: "merge",
});
expect(result.hasConflicts).toBe(true);
expect(result.conflicts[0].type).toBe("add");
});
it("should use default values for remote and branch", async () => {
// Mock successful fetch
mockGit.fetch.mockResolvedValue(undefined);
// Mock current branch
mockGit.revparse.mockResolvedValue("feature-branch");
// Mock merge test - no conflicts
mockGit.raw.mockResolvedValue("");
// Mock status - no conflicted files
mockGit.status.mockResolvedValue({
conflicted: [],
files: [],
});
const result = await service.checkForConflicts("/test/repo");
expect(result.remoteBranch).toBe("develop");
expect(mockGit.fetch).toHaveBeenCalledWith("origin", "develop");
});
it("should clean up after conflict detection", async () => {
// Mock successful fetch
mockGit.fetch.mockResolvedValue(undefined);
// Mock current branch
mockGit.revparse.mockResolvedValue("feature-branch");
// Mock merge test - conflicts
mockGit.raw.mockRejectedValueOnce(new Error("CONFLICT"));
// Mock status
mockGit.status.mockResolvedValue({
conflicted: ["src/file.ts"],
files: [],
});
// Track raw calls
const rawCalls: string[][] = [];
mockGit.raw.mockImplementation((args: string[]) => {
rawCalls.push(args);
if (args[0] === "merge") {
if (args[1] === "--abort") {
return Promise.resolve("");
}
return Promise.reject(new Error("CONFLICT"));
}
return Promise.resolve("");
});
await service.checkForConflicts("/test/repo", {
strategy: "merge",
});
// Verify abort was called
expect(rawCalls).toContainEqual(["merge", "--abort"]);
});
});
describe("fetchRemote", () => {
it("should fetch from remote successfully", async () => {
mockGit.fetch.mockResolvedValue(undefined);
await service.fetchRemote("/test/repo", "origin", "develop");
expect(mockGit.fetch).toHaveBeenCalledWith("origin", "develop");
});
it("should throw ConflictDetectionError on fetch failure", async () => {
mockGit.fetch.mockRejectedValue(new Error("Network error"));
await expect(
service.fetchRemote("/test/repo", "origin", "develop"),
).rejects.toThrow(ConflictDetectionError);
});
it("should use default remote", async () => {
mockGit.fetch.mockResolvedValue(undefined);
await service.fetchRemote("/test/repo");
expect(mockGit.fetch).toHaveBeenCalledWith("origin", undefined);
});
});
describe("detectConflicts", () => {
it("should return empty array when no conflicts", async () => {
mockGit.status.mockResolvedValue({
conflicted: [],
files: [],
});
const conflicts = await service.detectConflicts("/test/repo");
expect(conflicts).toHaveLength(0);
});
it("should detect conflicted files", async () => {
mockGit.status.mockResolvedValue({
conflicted: ["src/file1.ts", "src/file2.ts"],
files: [
{
path: "src/file1.ts",
index: "U",
working_dir: "U",
},
{
path: "src/file2.ts",
index: "U",
working_dir: "U",
},
],
});
const conflicts = await service.detectConflicts("/test/repo");
expect(conflicts).toHaveLength(2);
expect(conflicts[0].file).toBe("src/file1.ts");
expect(conflicts[1].file).toBe("src/file2.ts");
});
it("should determine conflict type from git status", async () => {
mockGit.status.mockResolvedValue({
conflicted: ["deleted.ts", "added.ts", "modified.ts"],
files: [
{
path: "deleted.ts",
index: "D",
working_dir: "U",
},
{
path: "added.ts",
index: "A",
working_dir: "A",
},
{
path: "modified.ts",
index: "U",
working_dir: "U",
},
],
});
const conflicts = await service.detectConflicts("/test/repo");
expect(conflicts[0].type).toBe("delete");
expect(conflicts[1].type).toBe("add");
expect(conflicts[2].type).toBe("content");
});
it("should throw ConflictDetectionError on git status failure", async () => {
mockGit.status.mockRejectedValue(new Error("Git error"));
await expect(service.detectConflicts("/test/repo")).rejects.toThrow(
ConflictDetectionError,
);
});
});
describe("getCurrentBranch", () => {
it("should return current branch name", async () => {
mockGit.revparse.mockResolvedValue("feature-branch");
const branch = await service.getCurrentBranch("/test/repo");
expect(branch).toBe("feature-branch");
expect(mockGit.revparse).toHaveBeenCalledWith([
"--abbrev-ref",
"HEAD",
]);
});
it("should throw ConflictDetectionError on failure", async () => {
mockGit.revparse.mockRejectedValue(new Error("Not a git repository"));
await expect(service.getCurrentBranch("/test/repo")).rejects.toThrow(
ConflictDetectionError,
);
});
});
});

View File

@@ -0,0 +1,240 @@
import { Injectable, Logger } from "@nestjs/common";
import { simpleGit, SimpleGit, StatusResult } from "simple-git";
import {
ConflictCheckResult,
ConflictInfo,
ConflictCheckOptions,
ConflictDetectionError,
} from "./types";
/**
* Service for detecting merge conflicts before pushing
*/
@Injectable()
export class ConflictDetectionService {
private readonly logger = new Logger(ConflictDetectionService.name);
/**
* Get a simple-git instance for a local path
*/
private getGit(localPath: string): SimpleGit {
return simpleGit(localPath);
}
/**
* Check for conflicts before pushing
* Fetches latest from remote and attempts a test merge/rebase
*/
async checkForConflicts(
localPath: string,
options?: ConflictCheckOptions,
): Promise<ConflictCheckResult> {
const remote = options?.remote ?? "origin";
const remoteBranch = options?.remoteBranch ?? "develop";
const strategy = options?.strategy ?? "merge";
try {
this.logger.log(
`Checking for conflicts in ${localPath} with ${remote}/${remoteBranch} using ${strategy}`,
);
// Get current branch
const localBranch = await this.getCurrentBranch(localPath);
// Fetch latest from remote
await this.fetchRemote(localPath, remote, remoteBranch);
// Attempt test merge/rebase
const hasConflicts = await this.attemptMerge(
localPath,
remote,
remoteBranch,
strategy,
);
if (!hasConflicts) {
this.logger.log("No conflicts detected");
return {
hasConflicts: false,
conflicts: [],
strategy,
canRetry: false,
remoteBranch,
localBranch,
};
}
// Detect conflicts
const conflicts = await this.detectConflicts(localPath);
// Cleanup - abort the merge/rebase
await this.cleanupMerge(localPath, strategy);
this.logger.log(`Detected ${conflicts.length} conflicts`);
return {
hasConflicts: true,
conflicts,
strategy,
canRetry: true,
remoteBranch,
localBranch,
};
} catch (error) {
this.logger.error(`Failed to check for conflicts: ${error}`);
throw new ConflictDetectionError(
`Failed to check for conflicts in ${localPath}`,
"checkForConflicts",
error as Error,
);
}
}
/**
* Fetch latest from remote
*/
async fetchRemote(
localPath: string,
remote: string = "origin",
branch?: string,
): Promise<void> {
try {
this.logger.log(`Fetching from ${remote}${branch ? `/${branch}` : ""}`);
const git = this.getGit(localPath);
await git.fetch(remote, branch);
this.logger.log("Successfully fetched from remote");
} catch (error) {
this.logger.error(`Failed to fetch from remote: ${error}`);
throw new ConflictDetectionError(
`Failed to fetch from ${remote}`,
"fetchRemote",
error as Error,
);
}
}
/**
* Detect conflicts in current state
*/
async detectConflicts(localPath: string): Promise<ConflictInfo[]> {
try {
const git = this.getGit(localPath);
const status: StatusResult = await git.status();
const conflicts: ConflictInfo[] = [];
// Process conflicted files
for (const file of status.conflicted) {
// Find the file in status.files to get more details
const fileStatus = status.files.find((f) => f.path === file);
// Determine conflict type
let type: ConflictInfo["type"] = "content";
if (fileStatus) {
if (fileStatus.index === "D" || fileStatus.working_dir === "D") {
type = "delete";
} else if (fileStatus.index === "A" && fileStatus.working_dir === "A") {
type = "add";
} else if (fileStatus.index === "R" || fileStatus.working_dir === "R") {
type = "rename";
}
}
conflicts.push({
file,
type,
});
}
return conflicts;
} catch (error) {
this.logger.error(`Failed to detect conflicts: ${error}`);
throw new ConflictDetectionError(
`Failed to detect conflicts in ${localPath}`,
"detectConflicts",
error as Error,
);
}
}
/**
* Get current branch name
*/
async getCurrentBranch(localPath: string): Promise<string> {
try {
const git = this.getGit(localPath);
const branch = await git.revparse(["--abbrev-ref", "HEAD"]);
return branch.trim();
} catch (error) {
this.logger.error(`Failed to get current branch: ${error}`);
throw new ConflictDetectionError(
`Failed to get current branch in ${localPath}`,
"getCurrentBranch",
error as Error,
);
}
}
/**
* Attempt a test merge/rebase to detect conflicts
* Returns true if conflicts detected, false if clean
*/
private async attemptMerge(
localPath: string,
remote: string,
remoteBranch: string,
strategy: "merge" | "rebase",
): Promise<boolean> {
const git = this.getGit(localPath);
const remoteRef = `${remote}/${remoteBranch}`;
try {
if (strategy === "merge") {
// Attempt test merge with --no-commit and --no-ff
await git.raw(["merge", "--no-commit", "--no-ff", remoteRef]);
} else {
// Attempt test rebase
await git.raw(["rebase", remoteRef]);
}
// If we get here, no conflicts
return false;
} catch (error) {
// Check if error is due to conflicts
const errorMessage = (error as Error).message || String(error);
if (
errorMessage.includes("CONFLICT") ||
errorMessage.includes("conflict")
) {
// Conflicts detected
return true;
}
// Other error - rethrow
throw error;
}
}
/**
* Cleanup after test merge/rebase
*/
private async cleanupMerge(
localPath: string,
strategy: "merge" | "rebase",
): Promise<void> {
try {
const git = this.getGit(localPath);
if (strategy === "merge") {
await git.raw(["merge", "--abort"]);
} else {
await git.raw(["rebase", "--abort"]);
}
this.logger.log(`Cleaned up ${strategy} operation`);
} catch (error) {
// Log warning but don't throw - cleanup is best-effort
this.logger.warn(`Failed to cleanup ${strategy}: ${error}`);
}
}
}

View File

@@ -0,0 +1,229 @@
import { ConfigService } from "@nestjs/config";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { GitOperationsService } from "./git-operations.service";
import { GitOperationError } from "./types";
// Mock simple-git
const mockGit = {
clone: vi.fn(),
checkoutLocalBranch: vi.fn(),
add: vi.fn(),
commit: vi.fn(),
push: vi.fn(),
addConfig: vi.fn(),
};
vi.mock("simple-git", () => ({
simpleGit: vi.fn(() => mockGit),
}));
describe("GitOperationsService", () => {
let service: GitOperationsService;
let mockConfigService: ConfigService;
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks();
// Create mock config service
mockConfigService = {
get: vi.fn((key: string) => {
if (key === "orchestrator.git.userName") return "Test User";
if (key === "orchestrator.git.userEmail") return "test@example.com";
return undefined;
}),
} as any;
// Create service with mock
service = new GitOperationsService(mockConfigService);
});
describe("cloneRepository", () => {
it("should clone a repository successfully", async () => {
mockGit.clone.mockResolvedValue(undefined);
await service.cloneRepository("https://github.com/test/repo.git", "/tmp/repo");
expect(mockGit.clone).toHaveBeenCalledWith(
"https://github.com/test/repo.git",
"/tmp/repo",
);
});
it("should clone a repository with specific branch", async () => {
mockGit.clone.mockResolvedValue(undefined);
await service.cloneRepository(
"https://github.com/test/repo.git",
"/tmp/repo",
"develop",
);
expect(mockGit.clone).toHaveBeenCalledWith(
"https://github.com/test/repo.git",
"/tmp/repo",
["--branch", "develop"],
);
});
it("should throw GitOperationError on clone failure", async () => {
const error = new Error("Clone failed");
mockGit.clone.mockRejectedValue(error);
await expect(
service.cloneRepository("https://github.com/test/repo.git", "/tmp/repo"),
).rejects.toThrow(GitOperationError);
try {
await service.cloneRepository(
"https://github.com/test/repo.git",
"/tmp/repo",
);
} catch (e) {
expect(e).toBeInstanceOf(GitOperationError);
expect((e as GitOperationError).operation).toBe("clone");
expect((e as GitOperationError).cause).toBe(error);
}
});
});
describe("createBranch", () => {
it("should create and checkout a new branch", async () => {
mockGit.checkoutLocalBranch.mockResolvedValue(undefined);
await service.createBranch("/tmp/repo", "feature/new-branch");
expect(mockGit.checkoutLocalBranch).toHaveBeenCalledWith(
"feature/new-branch",
);
});
it("should throw GitOperationError on branch creation failure", async () => {
const error = new Error("Branch already exists");
mockGit.checkoutLocalBranch.mockRejectedValue(error);
await expect(
service.createBranch("/tmp/repo", "feature/new-branch"),
).rejects.toThrow(GitOperationError);
try {
await service.createBranch("/tmp/repo", "feature/new-branch");
} catch (e) {
expect(e).toBeInstanceOf(GitOperationError);
expect((e as GitOperationError).operation).toBe("createBranch");
expect((e as GitOperationError).cause).toBe(error);
}
});
});
describe("commit", () => {
it("should stage all changes and commit with message", async () => {
mockGit.add.mockResolvedValue(undefined);
mockGit.commit.mockResolvedValue({ commit: "abc123" });
await service.commit("/tmp/repo", "feat: add new feature");
expect(mockGit.add).toHaveBeenCalledWith(".");
expect(mockGit.commit).toHaveBeenCalledWith("feat: add new feature");
});
it("should stage specific files when provided", async () => {
mockGit.add.mockResolvedValue(undefined);
mockGit.commit.mockResolvedValue({ commit: "abc123" });
await service.commit("/tmp/repo", "fix: update files", [
"file1.ts",
"file2.ts",
]);
expect(mockGit.add).toHaveBeenCalledWith(["file1.ts", "file2.ts"]);
expect(mockGit.commit).toHaveBeenCalledWith("fix: update files");
});
it("should configure git user before committing", async () => {
mockGit.add.mockResolvedValue(undefined);
mockGit.commit.mockResolvedValue({ commit: "abc123" });
mockGit.addConfig.mockResolvedValue(undefined);
await service.commit("/tmp/repo", "test commit");
expect(mockGit.addConfig).toHaveBeenCalledWith("user.name", "Test User");
expect(mockGit.addConfig).toHaveBeenCalledWith(
"user.email",
"test@example.com",
);
});
it("should throw GitOperationError on commit failure", async () => {
mockGit.add.mockResolvedValue(undefined);
const error = new Error("Nothing to commit");
mockGit.commit.mockRejectedValue(error);
await expect(service.commit("/tmp/repo", "test commit")).rejects.toThrow(
GitOperationError,
);
try {
await service.commit("/tmp/repo", "test commit");
} catch (e) {
expect(e).toBeInstanceOf(GitOperationError);
expect((e as GitOperationError).operation).toBe("commit");
expect((e as GitOperationError).cause).toBe(error);
}
});
});
describe("push", () => {
it("should push to origin and current branch by default", async () => {
mockGit.push.mockResolvedValue(undefined);
await service.push("/tmp/repo");
expect(mockGit.push).toHaveBeenCalledWith("origin", undefined);
});
it("should push to specified remote and branch", async () => {
mockGit.push.mockResolvedValue(undefined);
await service.push("/tmp/repo", "upstream", "main");
expect(mockGit.push).toHaveBeenCalledWith("upstream", "main");
});
it("should support force push", async () => {
mockGit.push.mockResolvedValue(undefined);
await service.push("/tmp/repo", "origin", "develop", true);
expect(mockGit.push).toHaveBeenCalledWith("origin", "develop", {
"--force": null,
});
});
it("should throw GitOperationError on push failure", async () => {
const error = new Error("Push rejected");
mockGit.push.mockRejectedValue(error);
await expect(service.push("/tmp/repo")).rejects.toThrow(GitOperationError);
try {
await service.push("/tmp/repo");
} catch (e) {
expect(e).toBeInstanceOf(GitOperationError);
expect((e as GitOperationError).operation).toBe("push");
expect((e as GitOperationError).cause).toBe(error);
}
});
});
describe("git config", () => {
it("should read git config from ConfigService", () => {
expect(mockConfigService.get("orchestrator.git.userName")).toBe(
"Test User",
);
expect(mockConfigService.get("orchestrator.git.userEmail")).toBe(
"test@example.com",
);
});
});
});

View File

@@ -0,0 +1,147 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { simpleGit, SimpleGit } from "simple-git";
import { GitOperationError } from "./types";
/**
* Service for managing git operations
*/
@Injectable()
export class GitOperationsService {
private readonly logger = new Logger(GitOperationsService.name);
private readonly gitUserName: string;
private readonly gitUserEmail: string;
constructor(private readonly configService: ConfigService) {
this.gitUserName =
this.configService.get<string>("orchestrator.git.userName") ??
"Mosaic Orchestrator";
this.gitUserEmail =
this.configService.get<string>("orchestrator.git.userEmail") ??
"orchestrator@mosaicstack.dev";
}
/**
* Get a simple-git instance for a local path
*/
private getGit(localPath: string): SimpleGit {
return simpleGit(localPath);
}
/**
* Clone a repository
*/
async cloneRepository(
url: string,
localPath: string,
branch?: string,
): Promise<void> {
try {
this.logger.log(`Cloning repository ${url} to ${localPath}`);
const git = simpleGit();
if (branch) {
await git.clone(url, localPath, ["--branch", branch]);
} else {
await git.clone(url, localPath);
}
this.logger.log(`Successfully cloned repository to ${localPath}`);
} catch (error) {
this.logger.error(`Failed to clone repository: ${error}`);
throw new GitOperationError(
`Failed to clone repository from ${url}`,
"clone",
error as Error,
);
}
}
/**
* Create a new branch
*/
async createBranch(localPath: string, branchName: string): Promise<void> {
try {
this.logger.log(`Creating branch ${branchName} at ${localPath}`);
const git = this.getGit(localPath);
await git.checkoutLocalBranch(branchName);
this.logger.log(`Successfully created branch ${branchName}`);
} catch (error) {
this.logger.error(`Failed to create branch: ${error}`);
throw new GitOperationError(
`Failed to create branch ${branchName}`,
"createBranch",
error as Error,
);
}
}
/**
* Commit changes
*/
async commit(
localPath: string,
message: string,
files?: string[],
): Promise<void> {
try {
this.logger.log(`Committing changes at ${localPath}`);
const git = this.getGit(localPath);
// Configure git user
await git.addConfig("user.name", this.gitUserName);
await git.addConfig("user.email", this.gitUserEmail);
// Stage files
if (files && files.length > 0) {
await git.add(files);
} else {
await git.add(".");
}
// Commit
await git.commit(message);
this.logger.log(`Successfully committed changes: ${message}`);
} catch (error) {
this.logger.error(`Failed to commit: ${error}`);
throw new GitOperationError(
`Failed to commit changes`,
"commit",
error as Error,
);
}
}
/**
* Push changes to remote
*/
async push(
localPath: string,
remote: string = "origin",
branch?: string,
force: boolean = false,
): Promise<void> {
try {
this.logger.log(`Pushing changes from ${localPath} to ${remote}`);
const git = this.getGit(localPath);
if (force) {
await git.push(remote, branch, { "--force": null });
} else {
await git.push(remote, branch);
}
this.logger.log(`Successfully pushed changes to ${remote}`);
} catch (error) {
this.logger.error(`Failed to push: ${error}`);
throw new GitOperationError(
`Failed to push changes to ${remote}`,
"push",
error as Error,
);
}
}
}

View File

@@ -1,4 +1,20 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { GitOperationsService } from "./git-operations.service";
import { WorktreeManagerService } from "./worktree-manager.service";
import { ConflictDetectionService } from "./conflict-detection.service";
@Module({})
@Module({
imports: [ConfigModule],
providers: [
GitOperationsService,
WorktreeManagerService,
ConflictDetectionService,
],
exports: [
GitOperationsService,
WorktreeManagerService,
ConflictDetectionService,
],
})
export class GitModule {}

View File

@@ -0,0 +1,5 @@
export * from "./git.module";
export * from "./git-operations.service";
export * from "./worktree-manager.service";
export * from "./conflict-detection.service";
export * from "./types";

View File

@@ -0,0 +1,45 @@
/**
* Result of conflict check operation
*/
export interface ConflictCheckResult {
hasConflicts: boolean;
conflicts: ConflictInfo[];
strategy: "merge" | "rebase";
canRetry: boolean;
remoteBranch: string;
localBranch: string;
}
/**
* Information about a single conflict
*/
export interface ConflictInfo {
file: string;
type: "content" | "delete" | "add" | "rename";
ours?: string;
theirs?: string;
}
/**
* Options for checking conflicts
*/
export interface ConflictCheckOptions {
localPath: string;
remote?: string;
remoteBranch?: string;
strategy?: "merge" | "rebase";
}
/**
* Conflict detection error types
*/
export class ConflictDetectionError extends Error {
constructor(
message: string,
public readonly operation: string,
public readonly cause?: Error,
) {
super(message);
this.name = "ConflictDetectionError";
}
}

View File

@@ -0,0 +1,58 @@
/**
* Git operation error types
*/
export class GitOperationError extends Error {
constructor(
message: string,
public readonly operation: string,
public readonly cause?: Error,
) {
super(message);
this.name = "GitOperationError";
}
}
/**
* Options for cloning a repository
*/
export interface CloneOptions {
url: string;
localPath: string;
branch?: string;
}
/**
* Options for creating a branch
*/
export interface CreateBranchOptions {
localPath: string;
branchName: string;
checkout?: boolean;
}
/**
* Options for committing changes
*/
export interface CommitOptions {
localPath: string;
message: string;
files?: string[];
}
/**
* Options for pushing changes
*/
export interface PushOptions {
localPath: string;
remote?: string;
branch?: string;
force?: boolean;
}
/**
* Git configuration
*/
export interface GitConfig {
userName: string;
userEmail: string;
}

View File

@@ -0,0 +1,3 @@
export * from "./git-operations.types";
export * from "./worktree-manager.types";
export * from "./conflict-detection.types";

View File

@@ -0,0 +1,32 @@
/**
* Worktree information
*/
export interface WorktreeInfo {
path: string;
branch: string;
commit: string;
}
/**
* Options for creating a worktree
*/
export interface CreateWorktreeOptions {
repoPath: string;
agentId: string;
taskId: string;
baseBranch?: string;
}
/**
* Worktree error types
*/
export class WorktreeError extends Error {
constructor(
message: string,
public readonly operation: string,
public readonly cause?: Error,
) {
super(message);
this.name = "WorktreeError";
}
}

View File

@@ -0,0 +1,346 @@
import { ConfigService } from "@nestjs/config";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { WorktreeManagerService } from "./worktree-manager.service";
import { GitOperationsService } from "./git-operations.service";
import { WorktreeError } from "./types";
import * as path from "path";
// Mock simple-git
const mockGit = {
raw: vi.fn(),
};
vi.mock("simple-git", () => ({
simpleGit: vi.fn(() => mockGit),
}));
describe("WorktreeManagerService", () => {
let service: WorktreeManagerService;
let mockConfigService: ConfigService;
let mockGitOperationsService: GitOperationsService;
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks();
// Create mock config service
mockConfigService = {
get: vi.fn((key: string) => {
if (key === "orchestrator.git.userName") return "Test User";
if (key === "orchestrator.git.userEmail") return "test@example.com";
return undefined;
}),
} as any;
// Create mock git operations service
mockGitOperationsService = new GitOperationsService(mockConfigService);
// Create service with mocks
service = new WorktreeManagerService(mockGitOperationsService);
});
describe("createWorktree", () => {
it("should create worktree with correct naming convention", async () => {
const repoPath = "/tmp/test-repo";
const agentId = "agent-123";
const taskId = "task-456";
const expectedPath = path.join(
"/tmp",
"test-repo_worktrees",
`agent-${agentId}-${taskId}`,
);
const branchName = `agent-${agentId}-${taskId}`;
mockGit.raw.mockResolvedValue(
`worktree ${expectedPath}\nHEAD abc123\nbranch refs/heads/${branchName}`,
);
const result = await service.createWorktree(repoPath, agentId, taskId);
expect(result).toBeDefined();
expect(result.path).toBe(expectedPath);
expect(result.branch).toBe(branchName);
expect(mockGit.raw).toHaveBeenCalledWith([
"worktree",
"add",
expectedPath,
"-b",
branchName,
"develop",
]);
});
it("should create worktree with custom base branch", async () => {
const repoPath = "/tmp/test-repo";
const agentId = "agent-123";
const taskId = "task-456";
const baseBranch = "main";
const expectedPath = path.join(
"/tmp",
"test-repo_worktrees",
`agent-${agentId}-${taskId}`,
);
const branchName = `agent-${agentId}-${taskId}`;
mockGit.raw.mockResolvedValue(
`worktree ${expectedPath}\nHEAD abc123\nbranch refs/heads/${branchName}`,
);
await service.createWorktree(repoPath, agentId, taskId, baseBranch);
expect(mockGit.raw).toHaveBeenCalledWith([
"worktree",
"add",
expectedPath,
"-b",
branchName,
baseBranch,
]);
});
it("should throw WorktreeError if worktree already exists", async () => {
const error = new Error("fatal: 'agent-123-task-456' already exists");
mockGit.raw.mockRejectedValue(error);
await expect(
service.createWorktree("/tmp/test-repo", "agent-123", "task-456"),
).rejects.toThrow(WorktreeError);
try {
await service.createWorktree("/tmp/test-repo", "agent-123", "task-456");
} catch (e) {
expect(e).toBeInstanceOf(WorktreeError);
expect((e as WorktreeError).operation).toBe("createWorktree");
expect((e as WorktreeError).cause).toBe(error);
}
});
it("should throw WorktreeError on git command failure", async () => {
const error = new Error("git command failed");
mockGit.raw.mockRejectedValue(error);
await expect(
service.createWorktree("/tmp/test-repo", "agent-123", "task-456"),
).rejects.toThrow(WorktreeError);
});
it("should validate agentId is not empty", async () => {
await expect(
service.createWorktree("/tmp/test-repo", "", "task-456"),
).rejects.toThrow("agentId is required");
});
it("should validate taskId is not empty", async () => {
await expect(
service.createWorktree("/tmp/test-repo", "agent-123", ""),
).rejects.toThrow("taskId is required");
});
it("should validate repoPath is not empty", async () => {
await expect(
service.createWorktree("", "agent-123", "task-456"),
).rejects.toThrow("repoPath is required");
});
});
describe("removeWorktree", () => {
it("should remove worktree successfully", async () => {
const worktreePath = "/tmp/test-repo_worktrees/agent-123-task-456";
mockGit.raw.mockResolvedValue("");
await service.removeWorktree(worktreePath);
expect(mockGit.raw).toHaveBeenCalledWith([
"worktree",
"remove",
worktreePath,
"--force",
]);
});
it("should handle non-existent worktree gracefully", async () => {
const worktreePath = "/tmp/test-repo_worktrees/non-existent";
const error = new Error("fatal: 'non-existent' is not a working tree");
mockGit.raw.mockRejectedValue(error);
// Should not throw, just log warning
await expect(service.removeWorktree(worktreePath)).resolves.not.toThrow();
});
it("should throw WorktreeError on removal failure", async () => {
const worktreePath = "/tmp/test-repo_worktrees/agent-123-task-456";
const error = new Error("permission denied");
mockGit.raw.mockRejectedValue(error);
// Should throw for non-worktree-not-found errors
await expect(service.removeWorktree(worktreePath)).rejects.toThrow();
});
it("should validate worktreePath is not empty", async () => {
await expect(service.removeWorktree("")).rejects.toThrow(
"worktreePath is required",
);
});
});
describe("listWorktrees", () => {
it("should return empty array when no worktrees exist", async () => {
const repoPath = "/tmp/test-repo";
mockGit.raw.mockResolvedValue(`/tmp/test-repo abc123 [develop]`);
const result = await service.listWorktrees(repoPath);
expect(result).toEqual([]);
});
it("should list all active worktrees", async () => {
const repoPath = "/tmp/test-repo";
const output = `/tmp/test-repo abc123 [develop]
/tmp/test-repo_worktrees/agent-123-task-456 def456 [agent-123-task-456]
/tmp/test-repo_worktrees/agent-789-task-012 abc789 [agent-789-task-012]`;
mockGit.raw.mockResolvedValue(output);
const result = await service.listWorktrees(repoPath);
expect(result).toHaveLength(2);
expect(result[0].path).toBe(
"/tmp/test-repo_worktrees/agent-123-task-456",
);
expect(result[0].commit).toBe("def456");
expect(result[0].branch).toBe("agent-123-task-456");
expect(result[1].path).toBe(
"/tmp/test-repo_worktrees/agent-789-task-012",
);
expect(result[1].commit).toBe("abc789");
expect(result[1].branch).toBe("agent-789-task-012");
});
it("should parse worktree info correctly", async () => {
const repoPath = "/tmp/test-repo";
const output = `/tmp/test-repo abc123 [develop]
/tmp/test-repo_worktrees/agent-123-task-456 def456 [agent-123-task-456]`;
mockGit.raw.mockResolvedValue(output);
const result = await service.listWorktrees(repoPath);
expect(result[0]).toEqual({
path: "/tmp/test-repo_worktrees/agent-123-task-456",
commit: "def456",
branch: "agent-123-task-456",
});
});
it("should throw WorktreeError on git command failure", async () => {
const error = new Error("git command failed");
mockGit.raw.mockRejectedValue(error);
await expect(service.listWorktrees("/tmp/test-repo")).rejects.toThrow(
WorktreeError,
);
});
it("should validate repoPath is not empty", async () => {
await expect(service.listWorktrees("")).rejects.toThrow(
"repoPath is required",
);
});
});
describe("cleanupWorktree", () => {
it("should remove worktree on agent completion", async () => {
const repoPath = "/tmp/test-repo";
const agentId = "agent-123";
const taskId = "task-456";
const worktreePath = path.join(
"/tmp",
"test-repo_worktrees",
`agent-${agentId}-${taskId}`,
);
mockGit.raw.mockResolvedValue("");
await service.cleanupWorktree(repoPath, agentId, taskId);
expect(mockGit.raw).toHaveBeenCalledWith([
"worktree",
"remove",
worktreePath,
"--force",
]);
});
it("should handle cleanup errors gracefully", async () => {
const error = new Error("worktree not found");
mockGit.raw.mockRejectedValue(error);
// Should not throw
await expect(
service.cleanupWorktree("/tmp/test-repo", "agent-123", "task-456"),
).resolves.not.toThrow();
});
it("should validate agentId is not empty", async () => {
await expect(
service.cleanupWorktree("/tmp/test-repo", "", "task-456"),
).rejects.toThrow("agentId is required");
});
it("should validate taskId is not empty", async () => {
await expect(
service.cleanupWorktree("/tmp/test-repo", "agent-123", ""),
).rejects.toThrow("taskId is required");
});
it("should validate repoPath is not empty", async () => {
await expect(
service.cleanupWorktree("", "agent-123", "task-456"),
).rejects.toThrow("repoPath is required");
});
});
describe("getWorktreePath", () => {
it("should generate correct worktree path", () => {
const repoPath = "/tmp/test-repo";
const agentId = "agent-123";
const taskId = "task-456";
const expectedPath = path.join(
"/tmp",
"test-repo_worktrees",
`agent-${agentId}-${taskId}`,
);
const result = service.getWorktreePath(repoPath, agentId, taskId);
expect(result).toBe(expectedPath);
});
it("should handle repo paths with trailing slashes", () => {
const repoPath = "/tmp/test-repo/";
const agentId = "agent-123";
const taskId = "task-456";
const expectedPath = path.join(
"/tmp",
"test-repo_worktrees",
`agent-${agentId}-${taskId}`,
);
const result = service.getWorktreePath(repoPath, agentId, taskId);
expect(result).toBe(expectedPath);
});
});
describe("getBranchName", () => {
it("should generate correct branch name", () => {
const agentId = "agent-123";
const taskId = "task-456";
const expectedBranch = `agent-${agentId}-${taskId}`;
const result = service.getBranchName(agentId, taskId);
expect(result).toBe(expectedBranch);
});
});
});

View File

@@ -0,0 +1,238 @@
import { Injectable, Logger } from "@nestjs/common";
import { simpleGit, SimpleGit } from "simple-git";
import * as path from "path";
import { GitOperationsService } from "./git-operations.service";
import { WorktreeInfo, WorktreeError } from "./types";
/**
* Service for managing git worktrees for agent isolation
*/
@Injectable()
export class WorktreeManagerService {
private readonly logger = new Logger(WorktreeManagerService.name);
constructor(
private readonly gitOperationsService: GitOperationsService,
) {}
/**
* Get a simple-git instance for a local path
*/
private getGit(localPath: string): SimpleGit {
return simpleGit(localPath);
}
/**
* Generate worktree path for an agent
*/
public getWorktreePath(
repoPath: string,
agentId: string,
taskId: string,
): string {
// Remove trailing slash if present
const cleanRepoPath = repoPath.replace(/\/$/, "");
const repoDir = path.dirname(cleanRepoPath);
const repoName = path.basename(cleanRepoPath);
const worktreeName = `agent-${agentId}-${taskId}`;
return path.join(repoDir, `${repoName}_worktrees`, worktreeName);
}
/**
* Generate branch name for an agent
*/
public getBranchName(agentId: string, taskId: string): string {
return `agent-${agentId}-${taskId}`;
}
/**
* Create a worktree for an agent
*/
async createWorktree(
repoPath: string,
agentId: string,
taskId: string,
baseBranch: string = "develop",
): Promise<WorktreeInfo> {
// Validate inputs
if (!repoPath) {
throw new Error("repoPath is required");
}
if (!agentId) {
throw new Error("agentId is required");
}
if (!taskId) {
throw new Error("taskId is required");
}
const worktreePath = this.getWorktreePath(repoPath, agentId, taskId);
const branchName = this.getBranchName(agentId, taskId);
try {
this.logger.log(
`Creating worktree for agent ${agentId}, task ${taskId} at ${worktreePath}`,
);
const git = this.getGit(repoPath);
// Create worktree with new branch
await git.raw([
"worktree",
"add",
worktreePath,
"-b",
branchName,
baseBranch,
]);
this.logger.log(`Successfully created worktree at ${worktreePath}`);
// Return worktree info
return {
path: worktreePath,
branch: branchName,
commit: "HEAD", // Will be updated after first commit
};
} catch (error) {
this.logger.error(`Failed to create worktree: ${error}`);
throw new WorktreeError(
`Failed to create worktree for agent ${agentId}, task ${taskId}`,
"createWorktree",
error as Error,
);
}
}
/**
* Remove a worktree
*/
async removeWorktree(worktreePath: string): Promise<void> {
// Validate input
if (!worktreePath) {
throw new Error("worktreePath is required");
}
try {
this.logger.log(`Removing worktree at ${worktreePath}`);
// Get the parent repo path by going up from worktree
const worktreeParent = path.dirname(worktreePath);
const repoName = path.basename(worktreeParent).replace("_worktrees", "");
const repoPath = path.join(path.dirname(worktreeParent), repoName);
const git = this.getGit(repoPath);
// Remove worktree
await git.raw(["worktree", "remove", worktreePath, "--force"]);
this.logger.log(`Successfully removed worktree at ${worktreePath}`);
} catch (error) {
const errorMessage = (error as Error).message || String(error);
// If worktree doesn't exist, log warning but don't throw
if (
errorMessage.includes("is not a working tree") ||
errorMessage.includes("does not exist")
) {
this.logger.warn(`Worktree ${worktreePath} does not exist, skipping removal`);
return;
}
// For other errors, throw
this.logger.error(`Failed to remove worktree: ${error}`);
throw new WorktreeError(
`Failed to remove worktree at ${worktreePath}`,
"removeWorktree",
error as Error,
);
}
}
/**
* List all worktrees for a repository
*/
async listWorktrees(repoPath: string): Promise<WorktreeInfo[]> {
// Validate input
if (!repoPath) {
throw new Error("repoPath is required");
}
try {
this.logger.log(`Listing worktrees for repository at ${repoPath}`);
const git = this.getGit(repoPath);
// Get worktree list
const output = await git.raw(["worktree", "list"]);
// Parse output
const worktrees: WorktreeInfo[] = [];
const lines = output.trim().split("\n");
for (const line of lines) {
// Format: /path/to/worktree commit [branch]
const match = line.match(/^(.+?)\s+([a-f0-9]+)\s+\[(.+?)\]$/);
if (!match) continue;
const [, worktreePath, commit, branch] = match;
// Only include agent worktrees (not the main repo)
if (worktreePath.includes("_worktrees")) {
worktrees.push({
path: worktreePath,
commit,
branch,
});
}
}
this.logger.log(`Found ${worktrees.length} active worktrees`);
return worktrees;
} catch (error) {
this.logger.error(`Failed to list worktrees: ${error}`);
throw new WorktreeError(
`Failed to list worktrees for repository at ${repoPath}`,
"listWorktrees",
error as Error,
);
}
}
/**
* Cleanup worktree for a specific agent
*/
async cleanupWorktree(
repoPath: string,
agentId: string,
taskId: string,
): Promise<void> {
// Validate inputs
if (!repoPath) {
throw new Error("repoPath is required");
}
if (!agentId) {
throw new Error("agentId is required");
}
if (!taskId) {
throw new Error("taskId is required");
}
const worktreePath = this.getWorktreePath(repoPath, agentId, taskId);
try {
this.logger.log(
`Cleaning up worktree for agent ${agentId}, task ${taskId}`,
);
await this.removeWorktree(worktreePath);
this.logger.log(
`Successfully cleaned up worktree for agent ${agentId}, task ${taskId}`,
);
} catch (error) {
// Log error but don't throw - cleanup should be best-effort
this.logger.warn(
`Failed to cleanup worktree for agent ${agentId}, task ${taskId}: ${error}`,
);
}
}
}