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:
SuperComboGamer
2025-12-21 20:27:44 -05:00
393 changed files with 32473 additions and 17974 deletions

276
libs/git-utils/README.md Normal file
View 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

View 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
View 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,
};
}

View 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';

View 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),
};
});
}

View 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;
}

View 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('');
});
});
});

View File

@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View 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,
},
},
},
});