mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
Merge main into massive-terminal-upgrade
Resolves merge conflicts: - apps/server/src/routes/terminal/common.ts: Keep randomBytes import, use @automaker/utils for createLogger - apps/ui/eslint.config.mjs: Use main's explicit globals list with XMLHttpRequest and MediaQueryListEvent additions - apps/ui/src/components/views/terminal-view.tsx: Keep our terminal improvements (killAllSessions, beforeunload, better error handling) - apps/ui/src/config/terminal-themes.ts: Keep our search highlight colors for all themes - apps/ui/src/store/app-store.ts: Keep our terminal settings persistence improvements (merge function) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
276
libs/git-utils/README.md
Normal file
276
libs/git-utils/README.md
Normal file
@@ -0,0 +1,276 @@
|
||||
# @automaker/git-utils
|
||||
|
||||
Git operations and utilities for AutoMaker.
|
||||
|
||||
## Overview
|
||||
|
||||
This package provides git-related utilities including repository detection, status parsing, and diff generation for both tracked and untracked files.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @automaker/git-utils
|
||||
```
|
||||
|
||||
## Exports
|
||||
|
||||
### Repository Detection
|
||||
|
||||
Check if a path is a git repository.
|
||||
|
||||
```typescript
|
||||
import { isGitRepo } from '@automaker/git-utils';
|
||||
|
||||
const isRepo = await isGitRepo('/project/path');
|
||||
if (isRepo) {
|
||||
console.log('This is a git repository');
|
||||
}
|
||||
```
|
||||
|
||||
### Status Parsing
|
||||
|
||||
Parse git status output into structured data.
|
||||
|
||||
```typescript
|
||||
import { parseGitStatus } from '@automaker/git-utils';
|
||||
import type { FileStatus } from '@automaker/git-utils';
|
||||
|
||||
const statusOutput = await execAsync('git status --porcelain');
|
||||
const files: FileStatus[] = parseGitStatus(statusOutput.stdout);
|
||||
|
||||
files.forEach((file) => {
|
||||
console.log(`${file.statusText}: ${file.path}`);
|
||||
// Example: "Modified: src/index.ts"
|
||||
// Example: "Untracked: new-file.ts"
|
||||
});
|
||||
```
|
||||
|
||||
### Diff Generation
|
||||
|
||||
Generate diffs including untracked files.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
generateSyntheticDiffForNewFile,
|
||||
appendUntrackedFileDiffs,
|
||||
getGitRepositoryDiffs,
|
||||
} from '@automaker/git-utils';
|
||||
|
||||
// Generate diff for single untracked file
|
||||
const diff = await generateSyntheticDiffForNewFile('/project/path', 'src/new-file.ts');
|
||||
|
||||
// Get complete repository diffs (tracked + untracked)
|
||||
const result = await getGitRepositoryDiffs('/project/path');
|
||||
console.log(result.diff); // Combined diff string
|
||||
console.log(result.files); // Array of FileStatus
|
||||
console.log(result.hasChanges); // Boolean
|
||||
```
|
||||
|
||||
### Non-Git Directory Support
|
||||
|
||||
Handle non-git directories by treating all files as new.
|
||||
|
||||
```typescript
|
||||
import { listAllFilesInDirectory, generateDiffsForNonGitDirectory } from '@automaker/git-utils';
|
||||
|
||||
// List all files (excluding build artifacts)
|
||||
const files = await listAllFilesInDirectory('/project/path');
|
||||
|
||||
// Generate diffs for non-git directory
|
||||
const result = await generateDiffsForNonGitDirectory('/project/path');
|
||||
console.log(result.diff); // Synthetic diffs for all files
|
||||
console.log(result.files); // All files as "New" status
|
||||
```
|
||||
|
||||
## Types
|
||||
|
||||
### FileStatus
|
||||
|
||||
```typescript
|
||||
interface FileStatus {
|
||||
status: string; // Git status code (M/A/D/R/C/U/?/!)
|
||||
path: string; // File path relative to repo root
|
||||
statusText: string; // Human-readable status
|
||||
}
|
||||
```
|
||||
|
||||
### Status Codes
|
||||
|
||||
- `M` - Modified
|
||||
- `A` - Added
|
||||
- `D` - Deleted
|
||||
- `R` - Renamed
|
||||
- `C` - Copied
|
||||
- `U` - Updated
|
||||
- `?` - Untracked
|
||||
- `!` - Ignored
|
||||
- ` ` - Unmodified
|
||||
|
||||
### Status Text Examples
|
||||
|
||||
- `"Modified"` - File has changes
|
||||
- `"Added"` - New file in staging
|
||||
- `"Deleted"` - File removed
|
||||
- `"Renamed"` - File renamed
|
||||
- `"Untracked"` - New file not in git
|
||||
- `"Modified (staged), Modified (unstaged)"` - Changes in both areas
|
||||
|
||||
## Usage Example
|
||||
|
||||
```typescript
|
||||
import { isGitRepo, getGitRepositoryDiffs, parseGitStatus } from '@automaker/git-utils';
|
||||
|
||||
async function getProjectChanges(projectPath: string) {
|
||||
const isRepo = await isGitRepo(projectPath);
|
||||
|
||||
if (!isRepo) {
|
||||
console.log('Not a git repository, analyzing all files...');
|
||||
}
|
||||
|
||||
const result = await getGitRepositoryDiffs(projectPath);
|
||||
|
||||
if (!result.hasChanges) {
|
||||
console.log('No changes detected');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${result.files.length} changed files:\n`);
|
||||
|
||||
// Group by status
|
||||
const byStatus = result.files.reduce(
|
||||
(acc, file) => {
|
||||
acc[file.statusText] = acc[file.statusText] || [];
|
||||
acc[file.statusText].push(file.path);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string[]>
|
||||
);
|
||||
|
||||
Object.entries(byStatus).forEach(([status, paths]) => {
|
||||
console.log(`${status}:`);
|
||||
paths.forEach((path) => console.log(` - ${path}`));
|
||||
});
|
||||
|
||||
return result.diff;
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Binary File Detection
|
||||
|
||||
Automatically detects binary files by extension and generates appropriate diff markers.
|
||||
|
||||
**Supported binary extensions:**
|
||||
|
||||
- Images: `.png`, `.jpg`, `.jpeg`, `.gif`, `.svg`, etc.
|
||||
- Documents: `.pdf`, `.doc`, `.docx`, etc.
|
||||
- Archives: `.zip`, `.tar`, `.gz`, etc.
|
||||
- Media: `.mp3`, `.mp4`, `.wav`, etc.
|
||||
- Fonts: `.ttf`, `.otf`, `.woff`, etc.
|
||||
|
||||
### Large File Handling
|
||||
|
||||
Files larger than 1MB show size information instead of full content.
|
||||
|
||||
### Synthetic Diff Format
|
||||
|
||||
Generates unified diff format for untracked files:
|
||||
|
||||
```diff
|
||||
diff --git a/new-file.ts b/new-file.ts
|
||||
new file mode 100644
|
||||
index 0000000..0000000
|
||||
--- /dev/null
|
||||
+++ b/new-file.ts
|
||||
@@ -0,0 +1,10 @@
|
||||
+export function hello() {
|
||||
+ console.log('Hello');
|
||||
+}
|
||||
```
|
||||
|
||||
### Directory Filtering
|
||||
|
||||
When scanning non-git directories, automatically excludes:
|
||||
|
||||
- `node_modules`, `.git`, `.automaker`
|
||||
- Build outputs: `dist`, `build`, `out`, `tmp`, `.tmp`
|
||||
- Framework caches: `.next`, `.nuxt`, `.cache`, `coverage`
|
||||
- Language-specific: `__pycache__` (Python), `target` (Rust), `vendor` (Go/PHP), `.gradle` (Gradle), `.venv`/`venv` (Python)
|
||||
|
||||
## Error Handling
|
||||
|
||||
Git operations can fail for various reasons. This package provides graceful error handling patterns:
|
||||
|
||||
### Common Error Scenarios
|
||||
|
||||
**1. Repository Not Found**
|
||||
|
||||
```typescript
|
||||
const isRepo = await isGitRepo('/path/does/not/exist');
|
||||
// Returns: false (no exception thrown)
|
||||
```
|
||||
|
||||
**2. Not a Git Repository**
|
||||
|
||||
```typescript
|
||||
const result = await getGitRepositoryDiffs('/not/a/git/repo');
|
||||
// Fallback behavior: treats all files as "new"
|
||||
// Returns synthetic diffs for all files in directory
|
||||
```
|
||||
|
||||
**3. Git Command Failures**
|
||||
|
||||
```typescript
|
||||
// Permission errors, corrupted repos, or git not installed
|
||||
try {
|
||||
const result = await getGitRepositoryDiffs('/project');
|
||||
} catch (error) {
|
||||
// Handle errors from git commands
|
||||
// Errors are logged via @automaker/utils logger
|
||||
console.error('Git operation failed:', error);
|
||||
}
|
||||
```
|
||||
|
||||
**4. File Read Errors**
|
||||
|
||||
```typescript
|
||||
// When generating synthetic diffs for inaccessible files
|
||||
const diff = await generateSyntheticDiffForNewFile('/path', 'locked-file.txt');
|
||||
// Returns placeholder: "[Unable to read file content]"
|
||||
// Error is logged but doesn't throw
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Check repository status first**:
|
||||
|
||||
```typescript
|
||||
const isRepo = await isGitRepo(path);
|
||||
if (!isRepo) {
|
||||
// Handle non-git case appropriately
|
||||
}
|
||||
```
|
||||
|
||||
2. **Expect non-git directories**:
|
||||
- `getGitRepositoryDiffs()` automatically handles both cases
|
||||
- Always returns a valid result structure
|
||||
|
||||
3. **Monitor logs**:
|
||||
- Errors are logged with the `[GitUtils]` prefix
|
||||
- Check logs for permission issues or git configuration problems
|
||||
|
||||
4. **Handle edge cases**:
|
||||
- Empty repositories (no commits yet)
|
||||
- Detached HEAD states
|
||||
- Corrupted git repositories
|
||||
- Missing git binary
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `@automaker/types` - FileStatus type definition
|
||||
- `@automaker/utils` - Logger utilities
|
||||
|
||||
## Used By
|
||||
|
||||
- `@automaker/server` - Git routes, worktree operations, feature context
|
||||
30
libs/git-utils/package.json
Normal file
30
libs/git-utils/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@automaker/git-utils",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Git operations utilities for AutoMaker",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"keywords": [
|
||||
"automaker",
|
||||
"git",
|
||||
"utils"
|
||||
],
|
||||
"author": "AutoMaker Team",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"@automaker/types": "^1.0.0",
|
||||
"@automaker/utils": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.5",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^4.0.16"
|
||||
}
|
||||
}
|
||||
256
libs/git-utils/src/diff.ts
Normal file
256
libs/git-utils/src/diff.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Git diff generation utilities
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { secureFs } from '@automaker/platform';
|
||||
import path from 'path';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { BINARY_EXTENSIONS, type FileStatus } from './types.js';
|
||||
import { isGitRepo, parseGitStatus } from './status.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const logger = createLogger('GitUtils');
|
||||
|
||||
// Max file size for generating synthetic diffs (1MB)
|
||||
const MAX_SYNTHETIC_DIFF_SIZE = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Check if a file is likely binary based on extension
|
||||
*/
|
||||
function isBinaryFile(filePath: string): boolean {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
return BINARY_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a synthetic unified diff for an untracked (new) file
|
||||
* This is needed because `git diff HEAD` doesn't include untracked files
|
||||
*/
|
||||
export async function generateSyntheticDiffForNewFile(
|
||||
basePath: string,
|
||||
relativePath: string
|
||||
): Promise<string> {
|
||||
const fullPath = path.join(basePath, relativePath);
|
||||
|
||||
try {
|
||||
// Check if it's a binary file
|
||||
if (isBinaryFile(relativePath)) {
|
||||
return `diff --git a/${relativePath} b/${relativePath}
|
||||
new file mode 100644
|
||||
index 0000000..0000000
|
||||
Binary file ${relativePath} added
|
||||
`;
|
||||
}
|
||||
|
||||
// Get file stats to check size
|
||||
const stats = await secureFs.stat(fullPath);
|
||||
if (stats.size > MAX_SYNTHETIC_DIFF_SIZE) {
|
||||
const sizeKB = Math.round(stats.size / 1024);
|
||||
return `diff --git a/${relativePath} b/${relativePath}
|
||||
new file mode 100644
|
||||
index 0000000..0000000
|
||||
--- /dev/null
|
||||
+++ b/${relativePath}
|
||||
@@ -0,0 +1 @@
|
||||
+[File too large to display: ${sizeKB}KB]
|
||||
`;
|
||||
}
|
||||
|
||||
// Read file content
|
||||
const content = (await secureFs.readFile(fullPath, 'utf-8')) as string;
|
||||
const hasTrailingNewline = content.endsWith('\n');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Remove trailing empty line if the file ends with newline
|
||||
if (lines.length > 0 && lines.at(-1) === '') {
|
||||
lines.pop();
|
||||
}
|
||||
|
||||
// Generate diff format
|
||||
const lineCount = lines.length;
|
||||
const addedLines = lines.map((line) => `+${line}`).join('\n');
|
||||
|
||||
let diff = `diff --git a/${relativePath} b/${relativePath}
|
||||
new file mode 100644
|
||||
index 0000000..0000000
|
||||
--- /dev/null
|
||||
+++ b/${relativePath}
|
||||
@@ -0,0 +1,${lineCount} @@
|
||||
${addedLines}`;
|
||||
|
||||
// Add "No newline at end of file" indicator if needed
|
||||
if (!hasTrailingNewline && content.length > 0) {
|
||||
diff += '\n\\ No newline at end of file';
|
||||
}
|
||||
|
||||
return diff + '\n';
|
||||
} catch (error) {
|
||||
// Log the error for debugging
|
||||
logger.error(`Failed to generate synthetic diff for ${fullPath}:`, error);
|
||||
// Return a placeholder diff
|
||||
return `diff --git a/${relativePath} b/${relativePath}
|
||||
new file mode 100644
|
||||
index 0000000..0000000
|
||||
--- /dev/null
|
||||
+++ b/${relativePath}
|
||||
@@ -0,0 +1 @@
|
||||
+[Unable to read file content]
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate synthetic diffs for all untracked files and combine with existing diff
|
||||
*/
|
||||
export async function appendUntrackedFileDiffs(
|
||||
basePath: string,
|
||||
existingDiff: string,
|
||||
files: Array<{ status: string; path: string }>
|
||||
): Promise<string> {
|
||||
// Find untracked files (status "?")
|
||||
const untrackedFiles = files.filter((f) => f.status === '?');
|
||||
|
||||
if (untrackedFiles.length === 0) {
|
||||
return existingDiff;
|
||||
}
|
||||
|
||||
// Generate synthetic diffs for each untracked file
|
||||
const syntheticDiffs = await Promise.all(
|
||||
untrackedFiles.map((f) => generateSyntheticDiffForNewFile(basePath, f.path))
|
||||
);
|
||||
|
||||
// Combine existing diff with synthetic diffs
|
||||
const combinedDiff = existingDiff + syntheticDiffs.join('');
|
||||
|
||||
return combinedDiff;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all files in a directory recursively (for non-git repositories)
|
||||
* Excludes hidden files/folders and common build artifacts
|
||||
*/
|
||||
export async function listAllFilesInDirectory(
|
||||
basePath: string,
|
||||
relativePath: string = ''
|
||||
): Promise<string[]> {
|
||||
const files: string[] = [];
|
||||
const fullPath = path.join(basePath, relativePath);
|
||||
|
||||
// Directories to skip
|
||||
const skipDirs = new Set([
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.automaker',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'.nuxt',
|
||||
'__pycache__',
|
||||
'.cache',
|
||||
'coverage',
|
||||
'.venv',
|
||||
'venv',
|
||||
'target',
|
||||
'vendor',
|
||||
'.gradle',
|
||||
'out',
|
||||
'tmp',
|
||||
'.tmp',
|
||||
]);
|
||||
|
||||
try {
|
||||
const entries = await secureFs.readdir(fullPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
// Skip hidden files/folders (except we want to allow some)
|
||||
if (entry.name.startsWith('.') && entry.name !== '.env') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryRelPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (!skipDirs.has(entry.name)) {
|
||||
const subFiles = await listAllFilesInDirectory(basePath, entryRelPath);
|
||||
files.push(...subFiles);
|
||||
}
|
||||
} else if (entry.isFile()) {
|
||||
files.push(entryRelPath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Log the error to help diagnose file system issues
|
||||
logger.error(`Error reading directory ${fullPath}:`, error);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate diffs for all files in a non-git directory
|
||||
* Treats all files as "new" files
|
||||
*/
|
||||
export async function generateDiffsForNonGitDirectory(
|
||||
basePath: string
|
||||
): Promise<{ diff: string; files: FileStatus[] }> {
|
||||
const allFiles = await listAllFilesInDirectory(basePath);
|
||||
|
||||
const files: FileStatus[] = allFiles.map((filePath) => ({
|
||||
status: '?',
|
||||
path: filePath,
|
||||
statusText: 'New',
|
||||
}));
|
||||
|
||||
// Generate synthetic diffs for all files
|
||||
const syntheticDiffs = await Promise.all(
|
||||
files.map((f) => generateSyntheticDiffForNewFile(basePath, f.path))
|
||||
);
|
||||
|
||||
return {
|
||||
diff: syntheticDiffs.join(''),
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get git repository diffs for a given path
|
||||
* Handles both git repos and non-git directories
|
||||
*/
|
||||
export async function getGitRepositoryDiffs(
|
||||
repoPath: string
|
||||
): Promise<{ diff: string; files: FileStatus[]; hasChanges: boolean }> {
|
||||
// Check if it's a git repository
|
||||
const isRepo = await isGitRepo(repoPath);
|
||||
|
||||
if (!isRepo) {
|
||||
// Not a git repo - list all files and treat them as new
|
||||
const result = await generateDiffsForNonGitDirectory(repoPath);
|
||||
return {
|
||||
diff: result.diff,
|
||||
files: result.files,
|
||||
hasChanges: result.files.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Get git diff and status
|
||||
const { stdout: diff } = await execAsync('git diff HEAD', {
|
||||
cwd: repoPath,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
const { stdout: status } = await execAsync('git status --porcelain', {
|
||||
cwd: repoPath,
|
||||
});
|
||||
|
||||
const files = parseGitStatus(status);
|
||||
|
||||
// Generate synthetic diffs for untracked (new) files
|
||||
const combinedDiff = await appendUntrackedFileDiffs(repoPath, diff, files);
|
||||
|
||||
return {
|
||||
diff: combinedDiff,
|
||||
files,
|
||||
hasChanges: files.length > 0,
|
||||
};
|
||||
}
|
||||
19
libs/git-utils/src/index.ts
Normal file
19
libs/git-utils/src/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @automaker/git-utils
|
||||
* Git operations utilities for AutoMaker
|
||||
*/
|
||||
|
||||
// Export types and constants
|
||||
export { BINARY_EXTENSIONS, GIT_STATUS_MAP, type FileStatus } from './types.js';
|
||||
|
||||
// Export status utilities
|
||||
export { isGitRepo, parseGitStatus } from './status.js';
|
||||
|
||||
// Export diff utilities
|
||||
export {
|
||||
generateSyntheticDiffForNewFile,
|
||||
appendUntrackedFileDiffs,
|
||||
listAllFilesInDirectory,
|
||||
generateDiffsForNonGitDirectory,
|
||||
getGitRepositoryDiffs,
|
||||
} from './diff.js';
|
||||
104
libs/git-utils/src/status.ts
Normal file
104
libs/git-utils/src/status.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Git status parsing utilities
|
||||
*/
|
||||
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { GIT_STATUS_MAP, type FileStatus } from './types.js';
|
||||
|
||||
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),
|
||||
};
|
||||
});
|
||||
}
|
||||
73
libs/git-utils/src/types.ts
Normal file
73
libs/git-utils/src/types.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Git utilities types and constants
|
||||
*/
|
||||
|
||||
// Binary file extensions to skip
|
||||
export const BINARY_EXTENSIONS = new Set([
|
||||
'.png',
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.gif',
|
||||
'.bmp',
|
||||
'.ico',
|
||||
'.webp',
|
||||
'.svg',
|
||||
'.pdf',
|
||||
'.doc',
|
||||
'.docx',
|
||||
'.xls',
|
||||
'.xlsx',
|
||||
'.ppt',
|
||||
'.pptx',
|
||||
'.zip',
|
||||
'.tar',
|
||||
'.gz',
|
||||
'.rar',
|
||||
'.7z',
|
||||
'.exe',
|
||||
'.dll',
|
||||
'.so',
|
||||
'.dylib',
|
||||
'.mp3',
|
||||
'.mp4',
|
||||
'.wav',
|
||||
'.avi',
|
||||
'.mov',
|
||||
'.mkv',
|
||||
'.ttf',
|
||||
'.otf',
|
||||
'.woff',
|
||||
'.woff2',
|
||||
'.eot',
|
||||
'.db',
|
||||
'.sqlite',
|
||||
'.sqlite3',
|
||||
'.pyc',
|
||||
'.pyo',
|
||||
'.class',
|
||||
'.o',
|
||||
'.obj',
|
||||
]);
|
||||
|
||||
// Status map for git status codes
|
||||
// Git porcelain format uses XY where X=staging area, Y=working tree
|
||||
export const GIT_STATUS_MAP: Record<string, string> = {
|
||||
M: 'Modified',
|
||||
A: 'Added',
|
||||
D: 'Deleted',
|
||||
R: 'Renamed',
|
||||
C: 'Copied',
|
||||
U: 'Updated',
|
||||
'?': 'Untracked',
|
||||
'!': 'Ignored',
|
||||
' ': 'Unmodified',
|
||||
};
|
||||
|
||||
/**
|
||||
* File status interface for git status results
|
||||
*/
|
||||
export interface FileStatus {
|
||||
status: string;
|
||||
path: string;
|
||||
statusText: string;
|
||||
}
|
||||
306
libs/git-utils/tests/diff.test.ts
Normal file
306
libs/git-utils/tests/diff.test.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
generateSyntheticDiffForNewFile,
|
||||
appendUntrackedFileDiffs,
|
||||
listAllFilesInDirectory,
|
||||
generateDiffsForNonGitDirectory,
|
||||
getGitRepositoryDiffs,
|
||||
} from '../src/diff';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
describe('diff.ts', () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a temporary directory for each test
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'git-utils-test-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up temporary directory
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
describe('generateSyntheticDiffForNewFile', () => {
|
||||
it('should generate diff for binary file', async () => {
|
||||
const fileName = 'test.png';
|
||||
const filePath = path.join(tempDir, fileName);
|
||||
await fs.writeFile(filePath, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||
|
||||
const diff = await generateSyntheticDiffForNewFile(tempDir, fileName);
|
||||
|
||||
expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`);
|
||||
expect(diff).toContain('new file mode 100644');
|
||||
expect(diff).toContain(`Binary file ${fileName} added`);
|
||||
});
|
||||
|
||||
it('should generate diff for large text file', async () => {
|
||||
const fileName = 'large.txt';
|
||||
const filePath = path.join(tempDir, fileName);
|
||||
// Create a file > 1MB
|
||||
const largeContent = 'x'.repeat(1024 * 1024 + 100);
|
||||
await fs.writeFile(filePath, largeContent);
|
||||
|
||||
const diff = await generateSyntheticDiffForNewFile(tempDir, fileName);
|
||||
|
||||
expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`);
|
||||
expect(diff).toContain('[File too large to display:');
|
||||
expect(diff).toMatch(/\d+KB\]/);
|
||||
});
|
||||
|
||||
it('should generate diff for small text file with trailing newline', async () => {
|
||||
const fileName = 'test.txt';
|
||||
const filePath = path.join(tempDir, fileName);
|
||||
const content = 'line 1\nline 2\nline 3\n';
|
||||
await fs.writeFile(filePath, content);
|
||||
|
||||
const diff = await generateSyntheticDiffForNewFile(tempDir, fileName);
|
||||
|
||||
expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`);
|
||||
expect(diff).toContain('new file mode 100644');
|
||||
expect(diff).toContain('--- /dev/null');
|
||||
expect(diff).toContain(`+++ b/${fileName}`);
|
||||
expect(diff).toContain('@@ -0,0 +1,3 @@');
|
||||
expect(diff).toContain('+line 1');
|
||||
expect(diff).toContain('+line 2');
|
||||
expect(diff).toContain('+line 3');
|
||||
expect(diff).not.toContain('\\ No newline at end of file');
|
||||
});
|
||||
|
||||
it('should generate diff for text file without trailing newline', async () => {
|
||||
const fileName = 'no-newline.txt';
|
||||
const filePath = path.join(tempDir, fileName);
|
||||
const content = 'line 1\nline 2';
|
||||
await fs.writeFile(filePath, content);
|
||||
|
||||
const diff = await generateSyntheticDiffForNewFile(tempDir, fileName);
|
||||
|
||||
expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`);
|
||||
expect(diff).toContain('+line 1');
|
||||
expect(diff).toContain('+line 2');
|
||||
expect(diff).toContain('\\ No newline at end of file');
|
||||
});
|
||||
|
||||
it('should generate diff for empty file', async () => {
|
||||
const fileName = 'empty.txt';
|
||||
const filePath = path.join(tempDir, fileName);
|
||||
await fs.writeFile(filePath, '');
|
||||
|
||||
const diff = await generateSyntheticDiffForNewFile(tempDir, fileName);
|
||||
|
||||
expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`);
|
||||
expect(diff).toContain('@@ -0,0 +1,0 @@');
|
||||
});
|
||||
|
||||
it('should generate diff for single line file', async () => {
|
||||
const fileName = 'single.txt';
|
||||
const filePath = path.join(tempDir, fileName);
|
||||
await fs.writeFile(filePath, 'single line\n');
|
||||
|
||||
const diff = await generateSyntheticDiffForNewFile(tempDir, fileName);
|
||||
|
||||
expect(diff).toContain('@@ -0,0 +1,1 @@');
|
||||
expect(diff).toContain('+single line');
|
||||
});
|
||||
|
||||
it('should handle file not found error', async () => {
|
||||
const fileName = 'nonexistent.txt';
|
||||
|
||||
const diff = await generateSyntheticDiffForNewFile(tempDir, fileName);
|
||||
|
||||
expect(diff).toContain(`diff --git a/${fileName} b/${fileName}`);
|
||||
expect(diff).toContain('[Unable to read file content]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendUntrackedFileDiffs', () => {
|
||||
it('should return existing diff when no untracked files', async () => {
|
||||
const existingDiff = 'diff --git a/test.txt b/test.txt\n';
|
||||
const files = [
|
||||
{ status: 'M', path: 'test.txt' },
|
||||
{ status: 'A', path: 'new.txt' },
|
||||
];
|
||||
|
||||
const result = await appendUntrackedFileDiffs(tempDir, existingDiff, files);
|
||||
|
||||
expect(result).toBe(existingDiff);
|
||||
});
|
||||
|
||||
it('should append synthetic diffs for untracked files', async () => {
|
||||
const existingDiff = 'existing diff\n';
|
||||
const untrackedFile = 'untracked.txt';
|
||||
const filePath = path.join(tempDir, untrackedFile);
|
||||
await fs.writeFile(filePath, 'content\n');
|
||||
|
||||
const files = [
|
||||
{ status: 'M', path: 'modified.txt' },
|
||||
{ status: '?', path: untrackedFile },
|
||||
];
|
||||
|
||||
const result = await appendUntrackedFileDiffs(tempDir, existingDiff, files);
|
||||
|
||||
expect(result).toContain('existing diff');
|
||||
expect(result).toContain(`diff --git a/${untrackedFile} b/${untrackedFile}`);
|
||||
expect(result).toContain('+content');
|
||||
});
|
||||
|
||||
it('should handle multiple untracked files', async () => {
|
||||
const file1 = 'file1.txt';
|
||||
const file2 = 'file2.txt';
|
||||
await fs.writeFile(path.join(tempDir, file1), 'file1\n');
|
||||
await fs.writeFile(path.join(tempDir, file2), 'file2\n');
|
||||
|
||||
const files = [
|
||||
{ status: '?', path: file1 },
|
||||
{ status: '?', path: file2 },
|
||||
];
|
||||
|
||||
const result = await appendUntrackedFileDiffs(tempDir, '', files);
|
||||
|
||||
expect(result).toContain(`diff --git a/${file1} b/${file1}`);
|
||||
expect(result).toContain(`diff --git a/${file2} b/${file2}`);
|
||||
expect(result).toContain('+file1');
|
||||
expect(result).toContain('+file2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listAllFilesInDirectory', () => {
|
||||
it('should list files in empty directory', async () => {
|
||||
const files = await listAllFilesInDirectory(tempDir);
|
||||
expect(files).toEqual([]);
|
||||
});
|
||||
|
||||
it('should list files in flat directory', async () => {
|
||||
await fs.writeFile(path.join(tempDir, 'file1.txt'), 'content');
|
||||
await fs.writeFile(path.join(tempDir, 'file2.js'), 'code');
|
||||
|
||||
const files = await listAllFilesInDirectory(tempDir);
|
||||
|
||||
expect(files).toHaveLength(2);
|
||||
expect(files).toContain('file1.txt');
|
||||
expect(files).toContain('file2.js');
|
||||
});
|
||||
|
||||
it('should list files in nested directories', async () => {
|
||||
await fs.mkdir(path.join(tempDir, 'subdir'));
|
||||
await fs.writeFile(path.join(tempDir, 'root.txt'), '');
|
||||
await fs.writeFile(path.join(tempDir, 'subdir', 'nested.txt'), '');
|
||||
|
||||
const files = await listAllFilesInDirectory(tempDir);
|
||||
|
||||
expect(files).toHaveLength(2);
|
||||
expect(files).toContain('root.txt');
|
||||
expect(files).toContain('subdir/nested.txt');
|
||||
});
|
||||
|
||||
it('should skip node_modules directory', async () => {
|
||||
await fs.mkdir(path.join(tempDir, 'node_modules'));
|
||||
await fs.writeFile(path.join(tempDir, 'app.js'), '');
|
||||
await fs.writeFile(path.join(tempDir, 'node_modules', 'package.js'), '');
|
||||
|
||||
const files = await listAllFilesInDirectory(tempDir);
|
||||
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files).toContain('app.js');
|
||||
expect(files).not.toContain('node_modules/package.js');
|
||||
});
|
||||
|
||||
it('should skip common build directories', async () => {
|
||||
await fs.mkdir(path.join(tempDir, 'dist'));
|
||||
await fs.mkdir(path.join(tempDir, 'build'));
|
||||
await fs.mkdir(path.join(tempDir, '.next'));
|
||||
await fs.writeFile(path.join(tempDir, 'source.ts'), '');
|
||||
await fs.writeFile(path.join(tempDir, 'dist', 'output.js'), '');
|
||||
await fs.writeFile(path.join(tempDir, 'build', 'output.js'), '');
|
||||
|
||||
const files = await listAllFilesInDirectory(tempDir);
|
||||
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files).toContain('source.ts');
|
||||
});
|
||||
|
||||
it('should skip hidden files except .env', async () => {
|
||||
await fs.writeFile(path.join(tempDir, '.hidden'), '');
|
||||
await fs.writeFile(path.join(tempDir, '.env'), '');
|
||||
await fs.writeFile(path.join(tempDir, 'visible.txt'), '');
|
||||
|
||||
const files = await listAllFilesInDirectory(tempDir);
|
||||
|
||||
expect(files).toHaveLength(2);
|
||||
expect(files).toContain('.env');
|
||||
expect(files).toContain('visible.txt');
|
||||
expect(files).not.toContain('.hidden');
|
||||
});
|
||||
|
||||
it('should skip .git directory', async () => {
|
||||
await fs.mkdir(path.join(tempDir, '.git'));
|
||||
await fs.writeFile(path.join(tempDir, '.git', 'config'), '');
|
||||
await fs.writeFile(path.join(tempDir, 'README.md'), '');
|
||||
|
||||
const files = await listAllFilesInDirectory(tempDir);
|
||||
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files).toContain('README.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateDiffsForNonGitDirectory', () => {
|
||||
it('should generate diffs for all files in directory', async () => {
|
||||
await fs.writeFile(path.join(tempDir, 'file1.txt'), 'content1\n');
|
||||
await fs.writeFile(path.join(tempDir, 'file2.js'), "console.log('hi');\n");
|
||||
|
||||
const result = await generateDiffsForNonGitDirectory(tempDir);
|
||||
|
||||
expect(result.files).toHaveLength(2);
|
||||
expect(result.files.every((f) => f.status === '?')).toBe(true);
|
||||
expect(result.diff).toContain('diff --git a/file1.txt b/file1.txt');
|
||||
expect(result.diff).toContain('diff --git a/file2.js b/file2.js');
|
||||
expect(result.diff).toContain('+content1');
|
||||
expect(result.diff).toContain("+console.log('hi');");
|
||||
});
|
||||
|
||||
it('should return empty result for empty directory', async () => {
|
||||
const result = await generateDiffsForNonGitDirectory(tempDir);
|
||||
|
||||
expect(result.files).toEqual([]);
|
||||
expect(result.diff).toBe('');
|
||||
});
|
||||
|
||||
it('should mark all files as untracked', async () => {
|
||||
await fs.writeFile(path.join(tempDir, 'test.txt'), 'test');
|
||||
|
||||
const result = await generateDiffsForNonGitDirectory(tempDir);
|
||||
|
||||
expect(result.files).toHaveLength(1);
|
||||
expect(result.files[0].status).toBe('?');
|
||||
expect(result.files[0].statusText).toBe('New');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGitRepositoryDiffs', () => {
|
||||
it('should treat non-git directory as all new files', async () => {
|
||||
await fs.writeFile(path.join(tempDir, 'file.txt'), 'content\n');
|
||||
|
||||
const result = await getGitRepositoryDiffs(tempDir);
|
||||
|
||||
expect(result.hasChanges).toBe(true);
|
||||
expect(result.files).toHaveLength(1);
|
||||
expect(result.files[0].status).toBe('?');
|
||||
expect(result.diff).toContain('diff --git a/file.txt b/file.txt');
|
||||
});
|
||||
|
||||
it('should return no changes for empty non-git directory', async () => {
|
||||
const result = await getGitRepositoryDiffs(tempDir);
|
||||
|
||||
expect(result.hasChanges).toBe(false);
|
||||
expect(result.files).toEqual([]);
|
||||
expect(result.diff).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
9
libs/git-utils/tsconfig.json
Normal file
9
libs/git-utils/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
21
libs/git-utils/vitest.config.ts
Normal file
21
libs/git-utils/vitest.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['tests/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/**/*.d.ts', 'src/index.ts', 'src/types.ts'],
|
||||
thresholds: {
|
||||
lines: 65,
|
||||
functions: 75,
|
||||
branches: 35,
|
||||
statements: 65,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user