mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2025-12-19 06:14:19 +01:00
387 lines
9.1 KiB
TypeScript
387 lines
9.1 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
import { resolve } from "node:path";
|
|
import parseGitDiff, {
|
|
type AnyChunk,
|
|
type AnyFileChange,
|
|
} from "parse-git-diff";
|
|
import {
|
|
executeGitCommand,
|
|
parseLines,
|
|
stripAnsiColors,
|
|
} from "../functions/utils";
|
|
import type {
|
|
GitComparisonResult,
|
|
GitDiff,
|
|
GitDiffFile,
|
|
GitDiffHunk,
|
|
GitDiffLine,
|
|
GitResult,
|
|
} from "../types";
|
|
|
|
/**
|
|
* Convert parse-git-diff file change to GitDiffFile
|
|
*/
|
|
function convertToGitDiffFile(
|
|
fileChange: AnyFileChange,
|
|
fileStats: Map<string, { additions: number; deletions: number }>,
|
|
): GitDiffFile {
|
|
let filePath: string;
|
|
let status: GitDiffFile["status"];
|
|
let oldPath: string | undefined;
|
|
|
|
switch (fileChange.type) {
|
|
case "AddedFile":
|
|
filePath = fileChange.path;
|
|
status = "added";
|
|
break;
|
|
case "DeletedFile":
|
|
filePath = fileChange.path;
|
|
status = "deleted";
|
|
break;
|
|
case "RenamedFile":
|
|
filePath = fileChange.pathAfter;
|
|
oldPath = fileChange.pathBefore;
|
|
status = "renamed";
|
|
break;
|
|
case "ChangedFile":
|
|
filePath = fileChange.path;
|
|
status = "modified";
|
|
break;
|
|
default:
|
|
// Fallback for any unknown types
|
|
filePath = "";
|
|
status = "modified";
|
|
}
|
|
|
|
// Get stats from numstat
|
|
const stats = fileStats.get(filePath) ||
|
|
fileStats.get(oldPath || "") || { additions: 0, deletions: 0 };
|
|
|
|
return {
|
|
filePath,
|
|
status,
|
|
additions: stats.additions,
|
|
deletions: stats.deletions,
|
|
oldPath,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Convert parse-git-diff chunk to GitDiffHunk
|
|
*/
|
|
function convertToGitDiffHunk(chunk: AnyChunk): GitDiffHunk {
|
|
if (chunk.type !== "Chunk") {
|
|
// For non-standard chunks, return empty hunk
|
|
return {
|
|
oldStart: 0,
|
|
oldCount: 0,
|
|
newStart: 0,
|
|
newCount: 0,
|
|
header: "",
|
|
lines: [],
|
|
};
|
|
}
|
|
|
|
const lines: GitDiffLine[] = [];
|
|
|
|
for (const change of chunk.changes) {
|
|
let line: GitDiffLine;
|
|
|
|
switch (change.type) {
|
|
case "AddedLine":
|
|
line = {
|
|
type: "added",
|
|
content: change.content,
|
|
newLineNumber: change.lineAfter,
|
|
};
|
|
break;
|
|
case "DeletedLine":
|
|
line = {
|
|
type: "deleted",
|
|
content: change.content,
|
|
oldLineNumber: change.lineBefore,
|
|
};
|
|
break;
|
|
case "UnchangedLine":
|
|
line = {
|
|
type: "context",
|
|
content: change.content,
|
|
oldLineNumber: change.lineBefore,
|
|
newLineNumber: change.lineAfter,
|
|
};
|
|
break;
|
|
case "MessageLine":
|
|
// This is likely a hunk header or context line
|
|
line = {
|
|
type: "context",
|
|
content: change.content,
|
|
};
|
|
break;
|
|
default:
|
|
// Fallback for unknown line types
|
|
line = {
|
|
type: "context",
|
|
content: "",
|
|
};
|
|
}
|
|
|
|
lines.push(line);
|
|
}
|
|
|
|
return {
|
|
oldStart: chunk.fromFileRange.start,
|
|
oldCount: chunk.fromFileRange.lines,
|
|
newStart: chunk.toFileRange.start,
|
|
newCount: chunk.toFileRange.lines,
|
|
header: `@@ -${chunk.fromFileRange.start},${chunk.fromFileRange.lines} +${chunk.toFileRange.start},${chunk.toFileRange.lines} @@${chunk.context ? ` ${chunk.context}` : ""}`,
|
|
lines,
|
|
};
|
|
}
|
|
|
|
const extractRef = (refText: string) => {
|
|
const [group, ref] = refText.split(":");
|
|
if (group === undefined || ref === undefined) {
|
|
if (refText === "HEAD") {
|
|
return "HEAD";
|
|
}
|
|
|
|
if (refText === "working") {
|
|
return undefined;
|
|
}
|
|
|
|
throw new Error(`Invalid ref text: ${refText}`);
|
|
}
|
|
|
|
return ref;
|
|
};
|
|
|
|
/**
|
|
* Get untracked files using git status
|
|
*/
|
|
async function getUntrackedFiles(cwd: string): Promise<GitResult<string[]>> {
|
|
const statusResult = await executeGitCommand(
|
|
["status", "--untracked-files=all", "--short"],
|
|
cwd,
|
|
);
|
|
|
|
if (!statusResult.success) {
|
|
return statusResult;
|
|
}
|
|
|
|
try {
|
|
const untrackedFiles = parseLines(statusResult.data)
|
|
.map((line) => stripAnsiColors(line)) // Remove ANSI color codes first
|
|
.filter((line) => line.startsWith("??"))
|
|
.map((line) => line.slice(3));
|
|
|
|
return {
|
|
success: true,
|
|
data: untrackedFiles,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: {
|
|
code: "PARSE_ERROR",
|
|
message: `Failed to parse status output: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create artificial diff for an untracked file (all lines as additions)
|
|
*/
|
|
async function createUntrackedFileDiff(
|
|
cwd: string,
|
|
filePath: string,
|
|
): Promise<GitDiff | null> {
|
|
try {
|
|
const fullPath = resolve(cwd, filePath);
|
|
const content = await readFile(fullPath, "utf8");
|
|
const lines = content.split("\n");
|
|
|
|
const diffLines: GitDiffLine[] = lines.map((line, index) => ({
|
|
type: "added" as const,
|
|
content: line,
|
|
newLineNumber: index + 1,
|
|
}));
|
|
|
|
const file: GitDiffFile = {
|
|
filePath,
|
|
status: "added",
|
|
additions: lines.length,
|
|
deletions: 0,
|
|
};
|
|
|
|
const hunk: GitDiffHunk = {
|
|
oldStart: 0,
|
|
oldCount: 0,
|
|
newStart: 1,
|
|
newCount: lines.length,
|
|
header: `@@ -0,0 +1,${lines.length} @@`,
|
|
lines: diffLines,
|
|
};
|
|
|
|
return {
|
|
file,
|
|
hunks: [hunk],
|
|
};
|
|
} catch (error) {
|
|
// Skip files that can't be read (e.g., binary files, permission errors)
|
|
console.warn(`Failed to read untracked file ${filePath}:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get Git diff between two references (branches, commits, tags)
|
|
*/
|
|
export const getDiff = async (
|
|
cwd: string,
|
|
fromRefText: string,
|
|
toRefText: string,
|
|
): Promise<GitResult<GitComparisonResult>> => {
|
|
const fromRef = extractRef(fromRefText);
|
|
const toRef = extractRef(toRefText);
|
|
|
|
if (fromRef === toRef) {
|
|
return {
|
|
success: true,
|
|
data: {
|
|
diffs: [],
|
|
files: [],
|
|
summary: {
|
|
totalFiles: 0,
|
|
totalAdditions: 0,
|
|
totalDeletions: 0,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
if (fromRef === undefined) {
|
|
throw new Error(`Invalid fromRef: ${fromRefText}`);
|
|
}
|
|
|
|
const commandArgs = toRef === undefined ? [fromRef] : [fromRef, toRef];
|
|
|
|
// Get diff with numstat for file statistics
|
|
const numstatResult = await executeGitCommand(
|
|
["diff", "--numstat", ...commandArgs],
|
|
cwd,
|
|
);
|
|
|
|
if (!numstatResult.success) {
|
|
return numstatResult;
|
|
}
|
|
|
|
// Get diff with full content
|
|
const diffResult = await executeGitCommand(
|
|
["diff", "--unified=5", ...commandArgs],
|
|
cwd,
|
|
);
|
|
|
|
if (!diffResult.success) {
|
|
return diffResult;
|
|
}
|
|
|
|
try {
|
|
// Parse numstat output to get file statistics
|
|
const fileStats = new Map<
|
|
string,
|
|
{ additions: number; deletions: number }
|
|
>();
|
|
const numstatLines = parseLines(numstatResult.data);
|
|
|
|
for (const line of numstatLines) {
|
|
const parts = line.split("\t");
|
|
if (parts.length >= 3 && parts[0] && parts[1] && parts[2]) {
|
|
const additions = parts[0] === "-" ? 0 : parseInt(parts[0], 10);
|
|
const deletions = parts[1] === "-" ? 0 : parseInt(parts[1], 10);
|
|
const filePath = parts[2];
|
|
fileStats.set(filePath, { additions, deletions });
|
|
}
|
|
}
|
|
|
|
// Parse diff output using parse-git-diff
|
|
const parsedDiff = parseGitDiff(diffResult.data);
|
|
|
|
const files: GitDiffFile[] = [];
|
|
const diffs: GitDiff[] = [];
|
|
let totalAdditions = 0;
|
|
let totalDeletions = 0;
|
|
|
|
for (const fileChange of parsedDiff.files) {
|
|
// Convert to GitDiffFile format
|
|
const file = convertToGitDiffFile(fileChange, fileStats);
|
|
files.push(file);
|
|
|
|
// Convert chunks to hunks
|
|
const hunks: GitDiffHunk[] = [];
|
|
for (const chunk of fileChange.chunks) {
|
|
const hunk = convertToGitDiffHunk(chunk);
|
|
hunks.push(hunk);
|
|
}
|
|
|
|
diffs.push({
|
|
file,
|
|
hunks,
|
|
});
|
|
|
|
totalAdditions += file.additions;
|
|
totalDeletions += file.deletions;
|
|
}
|
|
|
|
// Include untracked files when comparing to working directory
|
|
if (toRef === undefined) {
|
|
const untrackedResult = await getUntrackedFiles(cwd);
|
|
if (untrackedResult.success) {
|
|
for (const untrackedFile of untrackedResult.data) {
|
|
const untrackedDiff = await createUntrackedFileDiff(
|
|
cwd,
|
|
untrackedFile,
|
|
);
|
|
if (untrackedDiff) {
|
|
files.push(untrackedDiff.file);
|
|
diffs.push(untrackedDiff);
|
|
totalAdditions += untrackedDiff.file.additions;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
files,
|
|
diffs,
|
|
summary: {
|
|
totalFiles: files.length,
|
|
totalAdditions,
|
|
totalDeletions,
|
|
},
|
|
},
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: {
|
|
code: "PARSE_ERROR",
|
|
message: `Failed to parse diff: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
},
|
|
};
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Compare between two branches (shorthand for getDiff)
|
|
*/
|
|
export async function compareBranches(
|
|
cwd: string,
|
|
baseBranch: string,
|
|
targetBranch: string,
|
|
): Promise<GitResult<GitComparisonResult>> {
|
|
return getDiff(cwd, baseBranch, targetBranch);
|
|
}
|