feat: add @automaker/git-utils package

ELIMINATES routes/common.ts: Extract all git operations (382 lines) into dedicated package.

- Extract git status parsing (parseGitStatus, isGitRepo)
- Extract diff generation (generateSyntheticDiffForNewFile, etc.)
- Extract repository analysis (getGitRepositoryDiffs)
- Handle both git repos and non-git directories
- Support binary file detection
- Generate synthetic diffs for untracked files

Split into logical modules:
- types.ts: Constants and interfaces
- status.ts: Git status operations
- diff.ts: Diff generation utilities

This package will replace:
- apps/server/src/routes/common.ts (to be deleted)

Dependencies: @automaker/types, @automaker/utils

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Kacper
2025-12-19 23:31:08 +01:00
parent 8b31039557
commit 6f4269aacd
6 changed files with 445 additions and 0 deletions

View File

@@ -0,0 +1,99 @@
/**
* Git status parsing utilities
*/
import { exec } from "child_process";
import { promisify } from "util";
import { GIT_STATUS_MAP, type FileStatus } from './types';
const execAsync = promisify(exec);
/**
* Get a readable status text from git status codes
* Handles both single character and XY format status codes
*/
function getStatusText(indexStatus: string, workTreeStatus: string): string {
// Untracked files
if (indexStatus === "?" && workTreeStatus === "?") {
return "Untracked";
}
// Ignored files
if (indexStatus === "!" && workTreeStatus === "!") {
return "Ignored";
}
// Prioritize staging area status, then working tree
const primaryStatus = indexStatus !== " " && indexStatus !== "?" ? indexStatus : workTreeStatus;
// Handle combined statuses
if (indexStatus !== " " && indexStatus !== "?" && workTreeStatus !== " " && workTreeStatus !== "?") {
// Both staging and working tree have changes
const indexText = GIT_STATUS_MAP[indexStatus] || "Changed";
const workText = GIT_STATUS_MAP[workTreeStatus] || "Changed";
if (indexText === workText) {
return indexText;
}
return `${indexText} (staged), ${workText} (unstaged)`;
}
return GIT_STATUS_MAP[primaryStatus] || "Changed";
}
/**
* Check if a path is a git repository
*/
export async function isGitRepo(repoPath: string): Promise<boolean> {
try {
await execAsync("git rev-parse --is-inside-work-tree", { cwd: repoPath });
return true;
} catch {
return false;
}
}
/**
* Parse the output of `git status --porcelain` into FileStatus array
* Git porcelain format: XY PATH where X=staging area status, Y=working tree status
* For renamed files: XY ORIG_PATH -> NEW_PATH
*/
export function parseGitStatus(statusOutput: string): FileStatus[] {
return statusOutput
.split("\n")
.filter(Boolean)
.map((line) => {
// Git porcelain format uses two status characters: XY
// X = status in staging area (index)
// Y = status in working tree
const indexStatus = line[0] || " ";
const workTreeStatus = line[1] || " ";
// File path starts at position 3 (after "XY ")
let filePath = line.slice(3);
// Handle renamed files (format: "R old_path -> new_path")
if (indexStatus === "R" || workTreeStatus === "R") {
const arrowIndex = filePath.indexOf(" -> ");
if (arrowIndex !== -1) {
filePath = filePath.slice(arrowIndex + 4); // Use new path
}
}
// Determine the primary status character for backwards compatibility
// Prioritize staging area status, then working tree
let primaryStatus: string;
if (indexStatus === "?" && workTreeStatus === "?") {
primaryStatus = "?"; // Untracked
} else if (indexStatus !== " " && indexStatus !== "?") {
primaryStatus = indexStatus; // Staged change
} else {
primaryStatus = workTreeStatus; // Working tree change
}
return {
status: primaryStatus,
path: filePath,
statusText: getStatusText(indexStatus, workTreeStatus),
};
});
}