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>
241 lines
6.3 KiB
TypeScript
241 lines
6.3 KiB
TypeScript
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}`);
|
|
}
|
|
}
|
|
}
|