mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-17 10:03:08 +00:00
* Changes from feature/worktree-view-customization * Feature: Git sync, set-tracking, and push divergence handling (#796) * Add quick-add feature with improved workflows (#802) * Changes from feature/quick-add * feat: Clarify system prompt and improve error handling across services. Address PR Feedback * feat: Improve PR description parsing and refactor event handling * feat: Add context options to pipeline orchestrator initialization * fix: Deduplicate React and handle CJS interop for use-sync-external-store Resolve "Cannot read properties of null (reading 'useState')" errors by deduplicating React/react-dom and ensuring use-sync-external-store is bundled together with React to prevent CJS packages from resolving to different React instances. * Changes from feature/worktree-view-customization * refactor: Remove unused worktree swap and highlight props * refactor: Consolidate feature completion logic and improve thinking level defaults * feat: Increase max turn limit to 10000 - Update DEFAULT_MAX_TURNS from 1000 to 10000 in settings-helpers.ts and agent-executor.ts - Update MAX_ALLOWED_TURNS from 2000 to 10000 in settings-helpers.ts - Update UI clamping logic from 2000 to 10000 in app-store.ts - Update fallback values from 1000 to 10000 in use-settings-sync.ts - Update default value from 1000 to 10000 in DEFAULT_GLOBAL_SETTINGS - Update documentation to reflect new range: 1-10000 Allows agents to perform up to 10000 turns for complex feature execution. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * feat: Add model resolution, improve session handling, and enhance UI stability * refactor: Remove unused sync and tracking branch props from worktree components * feat: Add PR number update functionality to worktrees. Address pr feedback * feat: Optimize Gemini CLI startup and add tool result tracking * refactor: Improve error handling and simplify worktree task cleanup --------- Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
258 lines
8.5 KiB
TypeScript
258 lines
8.5 KiB
TypeScript
/**
|
|
* Git status parsing utilities
|
|
*/
|
|
|
|
import { exec } from 'child_process';
|
|
import { promisify } from 'util';
|
|
import fs from 'fs/promises';
|
|
import path from 'path';
|
|
import { GIT_STATUS_MAP, type FileStatus, type MergeStateInfo } 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
|
|
}
|
|
|
|
// Detect merge-affected files: when both X and Y are 'U', or U appears in either position
|
|
// In merge state, git uses 'U' (unmerged) to indicate merge-affected entries
|
|
const isMergeAffected =
|
|
indexStatus === 'U' ||
|
|
workTreeStatus === 'U' ||
|
|
(indexStatus === 'A' && workTreeStatus === 'A') || // both-added
|
|
(indexStatus === 'D' && workTreeStatus === 'D'); // both-deleted (during merge)
|
|
|
|
let mergeType: string | undefined;
|
|
if (isMergeAffected) {
|
|
if (indexStatus === 'U' && workTreeStatus === 'U') mergeType = 'both-modified';
|
|
else if (indexStatus === 'A' && workTreeStatus === 'U') mergeType = 'added-by-us';
|
|
else if (indexStatus === 'U' && workTreeStatus === 'A') mergeType = 'added-by-them';
|
|
else if (indexStatus === 'D' && workTreeStatus === 'U') mergeType = 'deleted-by-us';
|
|
else if (indexStatus === 'U' && workTreeStatus === 'D') mergeType = 'deleted-by-them';
|
|
else if (indexStatus === 'A' && workTreeStatus === 'A') mergeType = 'both-added';
|
|
else if (indexStatus === 'D' && workTreeStatus === 'D') mergeType = 'both-deleted';
|
|
else mergeType = 'unmerged';
|
|
}
|
|
|
|
return {
|
|
status: primaryStatus,
|
|
path: filePath,
|
|
statusText: getStatusText(indexStatus, workTreeStatus),
|
|
indexStatus,
|
|
workTreeStatus,
|
|
...(isMergeAffected && { isMergeAffected: true }),
|
|
...(mergeType && { mergeType }),
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Check if the current HEAD commit is a merge commit (has more than one parent).
|
|
* This is used to detect completed merge commits so we can show what the merge changed.
|
|
*
|
|
* @param repoPath - Path to the git repository or worktree
|
|
* @returns Object with isMergeCommit flag and the list of files affected by the merge
|
|
*/
|
|
export async function detectMergeCommit(
|
|
repoPath: string
|
|
): Promise<{ isMergeCommit: boolean; mergeAffectedFiles: string[] }> {
|
|
try {
|
|
// Check how many parents HEAD has using rev-parse
|
|
// For a merge commit, HEAD^2 exists (second parent); for non-merge commits it doesn't
|
|
try {
|
|
await execAsync('git rev-parse --verify "HEAD^2"', { cwd: repoPath });
|
|
} catch {
|
|
// HEAD^2 doesn't exist — not a merge commit
|
|
return { isMergeCommit: false, mergeAffectedFiles: [] };
|
|
}
|
|
|
|
// HEAD is a merge commit - get the files it changed relative to first parent
|
|
let mergeAffectedFiles: string[] = [];
|
|
try {
|
|
const { stdout: diffOutput } = await execAsync('git diff --name-only "HEAD~1" "HEAD"', {
|
|
cwd: repoPath,
|
|
});
|
|
mergeAffectedFiles = diffOutput
|
|
.trim()
|
|
.split('\n')
|
|
.filter((f) => f.trim().length > 0);
|
|
} catch {
|
|
// Ignore errors getting affected files
|
|
}
|
|
|
|
return { isMergeCommit: true, mergeAffectedFiles };
|
|
} catch {
|
|
return { isMergeCommit: false, mergeAffectedFiles: [] };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detect the current merge state of a git repository.
|
|
* Checks for .git/MERGE_HEAD, .git/rebase-merge, .git/rebase-apply,
|
|
* and .git/CHERRY_PICK_HEAD to determine if a merge/rebase/cherry-pick
|
|
* is in progress.
|
|
*
|
|
* @param repoPath - Path to the git repository or worktree
|
|
* @returns MergeStateInfo describing the current merge state
|
|
*/
|
|
export async function detectMergeState(repoPath: string): Promise<MergeStateInfo> {
|
|
const defaultState: MergeStateInfo = {
|
|
isMerging: false,
|
|
mergeOperationType: null,
|
|
isCleanMerge: false,
|
|
mergeAffectedFiles: [],
|
|
conflictFiles: [],
|
|
};
|
|
|
|
try {
|
|
// Find the actual .git directory (handles worktrees with .git file pointing to main repo)
|
|
const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', { cwd: repoPath });
|
|
const gitDir = path.resolve(repoPath, gitDirRaw.trim());
|
|
|
|
// Check for merge/rebase/cherry-pick indicators
|
|
let mergeOperationType: 'merge' | 'rebase' | 'cherry-pick' | null = null;
|
|
|
|
const checks = [
|
|
{ file: 'MERGE_HEAD', type: 'merge' as const },
|
|
{ file: 'rebase-merge', type: 'rebase' as const },
|
|
{ file: 'rebase-apply', type: 'rebase' as const },
|
|
{ file: 'CHERRY_PICK_HEAD', type: 'cherry-pick' as const },
|
|
];
|
|
|
|
for (const check of checks) {
|
|
try {
|
|
await fs.access(path.join(gitDir, check.file));
|
|
mergeOperationType = check.type;
|
|
break;
|
|
} catch {
|
|
// File doesn't exist, continue checking
|
|
}
|
|
}
|
|
|
|
if (!mergeOperationType) {
|
|
return defaultState;
|
|
}
|
|
|
|
// Get unmerged files (files with conflicts)
|
|
let conflictFiles: string[] = [];
|
|
try {
|
|
const { stdout: diffOutput } = await execAsync('git diff --name-only --diff-filter=U', {
|
|
cwd: repoPath,
|
|
});
|
|
conflictFiles = diffOutput
|
|
.trim()
|
|
.split('\n')
|
|
.filter((f) => f.trim().length > 0);
|
|
} catch {
|
|
// Ignore errors getting conflict files
|
|
}
|
|
|
|
// Get all files affected by the merge (staged files that came from the merge)
|
|
let mergeAffectedFiles: string[] = [];
|
|
try {
|
|
const { stdout: statusOutput } = await execAsync('git status --porcelain', {
|
|
cwd: repoPath,
|
|
});
|
|
const files = parseGitStatus(statusOutput);
|
|
mergeAffectedFiles = files
|
|
.filter((f) => f.isMergeAffected || (f.indexStatus !== ' ' && f.indexStatus !== '?'))
|
|
.map((f) => f.path);
|
|
} catch {
|
|
// Ignore errors
|
|
}
|
|
|
|
return {
|
|
isMerging: true,
|
|
mergeOperationType,
|
|
isCleanMerge: conflictFiles.length === 0,
|
|
mergeAffectedFiles,
|
|
conflictFiles,
|
|
};
|
|
} catch {
|
|
return defaultState;
|
|
}
|
|
}
|