fix: made git more stable
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { simpleGit } from 'simple-git';
|
import { simpleGit } from 'simple-git';
|
||||||
import * as fsPromises from 'fs/promises';
|
import * as fsPromises from 'fs/promises';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
|
||||||
export interface GitAvailability {
|
export interface GitAvailability {
|
||||||
gitFound: boolean;
|
gitFound: boolean;
|
||||||
@@ -155,7 +156,7 @@ export class GitEngine {
|
|||||||
'html/',
|
'html/',
|
||||||
];
|
];
|
||||||
|
|
||||||
private createNonInteractiveGit(projectPath: string): ReturnType<typeof simpleGit> {
|
private createNonInteractiveGit(projectPath?: string): ReturnType<typeof simpleGit> {
|
||||||
return simpleGit(projectPath)
|
return simpleGit(projectPath)
|
||||||
.env('GIT_TERMINAL_PROMPT', '0')
|
.env('GIT_TERMINAL_PROMPT', '0')
|
||||||
.env('GCM_INTERACTIVE', 'never')
|
.env('GCM_INTERACTIVE', 'never')
|
||||||
@@ -397,6 +398,279 @@ export class GitEngine {
|
|||||||
return normalized.includes('spawn ebadf');
|
return normalized.includes('spawn ebadf');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private runGitCli(projectPath: string, args: string[], allowRetry = true): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
execFile(
|
||||||
|
'git',
|
||||||
|
args,
|
||||||
|
{
|
||||||
|
cwd: projectPath,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GIT_TERMINAL_PROMPT: '0',
|
||||||
|
GCM_INTERACTIVE: 'never',
|
||||||
|
GIT_SSH_COMMAND: 'ssh -oBatchMode=yes',
|
||||||
|
},
|
||||||
|
windowsHide: true,
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
(error, stdout, stderr) => {
|
||||||
|
if (!error) {
|
||||||
|
resolve(stdout);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const composedMessage = [stderr?.toString().trim(), error.message]
|
||||||
|
.filter((part) => Boolean(part))
|
||||||
|
.join(' | ');
|
||||||
|
|
||||||
|
if (allowRetry && this.isSpawnBadFileDescriptorError(composedMessage)) {
|
||||||
|
this.runGitCli(projectPath, args, false).then(resolve).catch(reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(new Error(composedMessage || 'Git command failed.'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseGitLogCliOutput(raw: string): Array<{ hash: string; date: string; message: string; author_name: string }> {
|
||||||
|
return raw
|
||||||
|
.split('\x1e')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry) => entry.length > 0)
|
||||||
|
.map((entry) => {
|
||||||
|
const [hash, date, message, author] = entry.split('\x1f');
|
||||||
|
return {
|
||||||
|
hash: hash || '',
|
||||||
|
date: date || '',
|
||||||
|
message: message || '',
|
||||||
|
author_name: author || '',
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((entry) => entry.hash.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePorcelainStatus(raw: string): GitStatusDto {
|
||||||
|
const records = raw.split('\0').filter((record) => record.length > 0);
|
||||||
|
const files: GitStatusFile[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < records.length; index += 1) {
|
||||||
|
const record = records[index];
|
||||||
|
const x = record[0] ?? ' ';
|
||||||
|
const y = record[1] ?? ' ';
|
||||||
|
const pathValue = record.slice(3);
|
||||||
|
|
||||||
|
if (x === '?' && y === '?') {
|
||||||
|
files.push({ path: pathValue, status: 'untracked' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (x === 'R' || y === 'R') {
|
||||||
|
const previousPath = pathValue;
|
||||||
|
const renamedTo = records[index + 1] ?? pathValue;
|
||||||
|
files.push({ path: renamedTo, status: 'renamed', previousPath });
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (x === 'D' || y === 'D') {
|
||||||
|
files.push({ path: pathValue, status: 'deleted' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (y === 'M' || y === 'A' || y === 'T') {
|
||||||
|
files.push({ path: pathValue, status: 'modified' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (x !== ' ') {
|
||||||
|
files.push({ path: pathValue, status: 'staged' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts: GitStatusCounts = {
|
||||||
|
untracked: files.filter((file) => file.status === 'untracked').length,
|
||||||
|
modified: files.filter((file) => file.status === 'modified').length,
|
||||||
|
deleted: files.filter((file) => file.status === 'deleted').length,
|
||||||
|
renamed: files.filter((file) => file.status === 'renamed').length,
|
||||||
|
staged: files.filter((file) => file.status === 'staged').length,
|
||||||
|
total: files.length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { files, counts };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getStatusViaCli(projectPath: string): Promise<GitStatusDto> {
|
||||||
|
const raw = await this.runGitCli(projectPath, ['status', '--porcelain=v1', '-z']);
|
||||||
|
return this.parsePorcelainStatus(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRepoStateViaCli(projectPath: string): Promise<RepoState> {
|
||||||
|
try {
|
||||||
|
const isInsideRaw = await this.runGitCli(projectPath, ['rev-parse', '--is-inside-work-tree']);
|
||||||
|
if (isInsideRaw.trim() !== 'true') {
|
||||||
|
return { isRepo: false, hasRemote: false };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { isRepo: false, hasRemote: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootPath = (await this.runGitCli(projectPath, ['rev-parse', '--show-toplevel'])).trim();
|
||||||
|
|
||||||
|
let currentBranch: string | undefined;
|
||||||
|
try {
|
||||||
|
const branch = (await this.runGitCli(projectPath, ['symbolic-ref', '--short', 'HEAD'])).trim();
|
||||||
|
currentBranch = branch || undefined;
|
||||||
|
} catch {
|
||||||
|
currentBranch = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasRemote = false;
|
||||||
|
try {
|
||||||
|
const upstream = (await this.runGitCli(projectPath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'])).trim();
|
||||||
|
hasRemote = upstream.length > 0;
|
||||||
|
} catch {
|
||||||
|
hasRemote = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRepo: true,
|
||||||
|
rootPath,
|
||||||
|
currentBranch,
|
||||||
|
hasRemote,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRemoteStateViaCli(projectPath: string): Promise<GitRemoteStateDto> {
|
||||||
|
let localBranch: string | null = null;
|
||||||
|
try {
|
||||||
|
localBranch = (await this.runGitCli(projectPath, ['symbolic-ref', '--short', 'HEAD'])).trim() || null;
|
||||||
|
} catch {
|
||||||
|
localBranch = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let upstreamBranch: string | null = null;
|
||||||
|
try {
|
||||||
|
upstreamBranch = (await this.runGitCli(projectPath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'])).trim() || null;
|
||||||
|
} catch {
|
||||||
|
upstreamBranch = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!upstreamBranch) {
|
||||||
|
return {
|
||||||
|
localBranch,
|
||||||
|
upstreamBranch: null,
|
||||||
|
hasUpstream: false,
|
||||||
|
ahead: 0,
|
||||||
|
behind: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let ahead = 0;
|
||||||
|
let behind = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
ahead = Number((await this.runGitCli(projectPath, ['rev-list', '--count', `${upstreamBranch}..HEAD`])).trim() || 0);
|
||||||
|
} catch {
|
||||||
|
ahead = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
behind = Number((await this.runGitCli(projectPath, ['rev-list', '--count', `HEAD..${upstreamBranch}`])).trim() || 0);
|
||||||
|
} catch {
|
||||||
|
behind = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
localBranch,
|
||||||
|
upstreamBranch,
|
||||||
|
hasUpstream: true,
|
||||||
|
ahead,
|
||||||
|
behind,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getHistoryViaCli(projectPath: string, limit: number): Promise<GitHistoryEntry[]> {
|
||||||
|
const format = '%H%x1f%aI%x1f%s%x1f%aN%x1e';
|
||||||
|
const localRaw = await this.runGitCli(projectPath, ['log', `--pretty=format:${format}`, '--max-count', String(limit)]);
|
||||||
|
const localCommits = this.parseGitLogCliOutput(localRaw);
|
||||||
|
|
||||||
|
const mapLocalHistory = (): GitHistoryEntry[] => localCommits.map((entry) => ({
|
||||||
|
hash: entry.hash,
|
||||||
|
shortHash: entry.hash.slice(0, 7),
|
||||||
|
date: entry.date,
|
||||||
|
subject: entry.message,
|
||||||
|
author: entry.author_name,
|
||||||
|
syncStatus: 'local-only',
|
||||||
|
}));
|
||||||
|
|
||||||
|
let upstreamBranch: string | null = null;
|
||||||
|
try {
|
||||||
|
upstreamBranch = (await this.runGitCli(projectPath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'])).trim();
|
||||||
|
} catch {
|
||||||
|
return mapLocalHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!upstreamBranch) {
|
||||||
|
return mapLocalHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
let behindCount = 0;
|
||||||
|
try {
|
||||||
|
behindCount = Number((await this.runGitCli(projectPath, ['rev-list', '--count', `HEAD..${upstreamBranch}`])).trim() || 0);
|
||||||
|
} catch {
|
||||||
|
behindCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteHistoryLimit = Math.max(limit, limit + Math.max(behindCount, 0));
|
||||||
|
let remoteCommits: Array<{ hash: string; date: string; message: string; author_name: string }> = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const remoteRaw = await this.runGitCli(projectPath, ['log', `--pretty=format:${format}`, '--max-count', String(remoteHistoryLimit), upstreamBranch]);
|
||||||
|
remoteCommits = this.parseGitLogCliOutput(remoteRaw);
|
||||||
|
} catch {
|
||||||
|
return mapLocalHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
const localMap = new Map(localCommits.map((entry) => [entry.hash, entry]));
|
||||||
|
const remoteMap = new Map(remoteCommits.map((entry) => [entry.hash, entry]));
|
||||||
|
|
||||||
|
const combined = new Map<string, { hash: string; date: string; message: string; author_name: string }>();
|
||||||
|
for (const entry of localMap.values()) {
|
||||||
|
combined.set(entry.hash, entry);
|
||||||
|
}
|
||||||
|
for (const entry of remoteMap.values()) {
|
||||||
|
if (!combined.has(entry.hash)) {
|
||||||
|
combined.set(entry.hash, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const classifiedEntries = Array.from(combined.values())
|
||||||
|
.sort((first, second) => new Date(second.date).getTime() - new Date(first.date).getTime())
|
||||||
|
.map((entry) => {
|
||||||
|
const inLocal = localMap.has(entry.hash);
|
||||||
|
const inRemote = remoteMap.has(entry.hash);
|
||||||
|
const syncStatus: GitHistorySyncStatus = inLocal && inRemote ? 'both' : inLocal ? 'local-only' : 'remote-only';
|
||||||
|
|
||||||
|
return {
|
||||||
|
hash: entry.hash,
|
||||||
|
shortHash: entry.hash.slice(0, 7),
|
||||||
|
date: entry.date,
|
||||||
|
subject: entry.message,
|
||||||
|
author: entry.author_name,
|
||||||
|
syncStatus,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const remoteOnlyEntries = classifiedEntries.filter((entry) => entry.syncStatus === 'remote-only');
|
||||||
|
const localAndSyncedEntries = classifiedEntries.filter((entry) => entry.syncStatus !== 'remote-only').slice(0, limit);
|
||||||
|
|
||||||
|
return [...localAndSyncedEntries, ...remoteOnlyEntries]
|
||||||
|
.sort((first, second) => new Date(second.date).getTime() - new Date(first.date).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
private async getCurrentBranchName(git: ReturnType<typeof simpleGit>): Promise<string | null> {
|
private async getCurrentBranchName(git: ReturnType<typeof simpleGit>): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const status = await git.status();
|
const status = await git.status();
|
||||||
@@ -508,7 +782,7 @@ export class GitEngine {
|
|||||||
|
|
||||||
async checkAvailability(): Promise<GitAvailability> {
|
async checkAvailability(): Promise<GitAvailability> {
|
||||||
try {
|
try {
|
||||||
const versionResult = await simpleGit().version();
|
const versionResult = await this.createNonInteractiveGit().version();
|
||||||
return {
|
return {
|
||||||
gitFound: true,
|
gitFound: true,
|
||||||
version: `${versionResult.major}.${versionResult.minor}.${versionResult.patch}`,
|
version: `${versionResult.major}.${versionResult.minor}.${versionResult.patch}`,
|
||||||
@@ -519,8 +793,17 @@ export class GitEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getRepoState(projectPath: string): Promise<RepoState> {
|
async getRepoState(projectPath: string): Promise<RepoState> {
|
||||||
const git = simpleGit(projectPath);
|
const git = this.createNonInteractiveGit(projectPath);
|
||||||
const isRepo = await git.checkIsRepo();
|
let isRepo;
|
||||||
|
try {
|
||||||
|
isRepo = await git.checkIsRepo();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error ?? '');
|
||||||
|
if (this.isSpawnBadFileDescriptorError(message)) {
|
||||||
|
return this.getRepoStateViaCli(projectPath);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isRepo) {
|
if (!isRepo) {
|
||||||
return {
|
return {
|
||||||
@@ -529,10 +812,20 @@ export class GitEngine {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const [rootPath, status] = await Promise.all([
|
let rootPath;
|
||||||
|
let status;
|
||||||
|
try {
|
||||||
|
[rootPath, status] = await Promise.all([
|
||||||
git.revparse(['--show-toplevel']),
|
git.revparse(['--show-toplevel']),
|
||||||
git.status(),
|
git.status(),
|
||||||
]);
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error ?? '');
|
||||||
|
if (this.isSpawnBadFileDescriptorError(message)) {
|
||||||
|
return this.getRepoStateViaCli(projectPath);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isRepo: true,
|
isRepo: true,
|
||||||
@@ -543,8 +836,17 @@ export class GitEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getStatus(projectPath: string): Promise<GitStatusDto> {
|
async getStatus(projectPath: string): Promise<GitStatusDto> {
|
||||||
const git = simpleGit(projectPath);
|
const git = this.createNonInteractiveGit(projectPath);
|
||||||
const status = await git.status();
|
let status;
|
||||||
|
try {
|
||||||
|
status = await git.status();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error ?? '');
|
||||||
|
if (this.isSpawnBadFileDescriptorError(message)) {
|
||||||
|
return this.getStatusViaCli(projectPath);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const files: GitStatusFile[] = [
|
const files: GitStatusFile[] = [
|
||||||
...status.not_added.map((filePath) => ({ path: filePath, status: 'untracked' as const })),
|
...status.not_added.map((filePath) => ({ path: filePath, status: 'untracked' as const })),
|
||||||
@@ -574,8 +876,18 @@ export class GitEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getDiff(projectPath: string, filePath: string): Promise<GitDiffDto> {
|
async getDiff(projectPath: string, filePath: string): Promise<GitDiffDto> {
|
||||||
const git = simpleGit(projectPath);
|
const git = this.createNonInteractiveGit(projectPath);
|
||||||
const patch = await git.diff(['--', filePath]);
|
let patch;
|
||||||
|
try {
|
||||||
|
patch = await git.diff(['--', filePath]);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error ?? '');
|
||||||
|
if (this.isSpawnBadFileDescriptorError(message)) {
|
||||||
|
patch = await this.runGitCli(projectPath, ['diff', '--', filePath]);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
filePath,
|
filePath,
|
||||||
@@ -584,10 +896,20 @@ export class GitEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getDiffContent(projectPath: string, filePath: string): Promise<GitDiffContentDto> {
|
async getDiffContent(projectPath: string, filePath: string): Promise<GitDiffContentDto> {
|
||||||
const git = simpleGit(projectPath);
|
const git = this.createNonInteractiveGit(projectPath);
|
||||||
|
|
||||||
const [original, modified] = await Promise.all([
|
const [original, modified] = await Promise.all([
|
||||||
git.show([`HEAD:${filePath}`]).catch(() => ''),
|
git.show([`HEAD:${filePath}`]).catch(async (error) => {
|
||||||
|
const message = error instanceof Error ? error.message : String(error ?? '');
|
||||||
|
if (this.isSpawnBadFileDescriptorError(message)) {
|
||||||
|
try {
|
||||||
|
return await this.runGitCli(projectPath, ['show', `HEAD:${filePath}`]);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}),
|
||||||
fsPromises.readFile(path.join(projectPath, filePath), 'utf8').catch(() => ''),
|
fsPromises.readFile(path.join(projectPath, filePath), 'utf8').catch(() => ''),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -599,8 +921,18 @@ export class GitEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getCommitDiffContent(projectPath: string, commitHash: string): Promise<GitCommitDiffContentDto> {
|
async getCommitDiffContent(projectPath: string, commitHash: string): Promise<GitCommitDiffContentDto> {
|
||||||
const git = simpleGit(projectPath);
|
const git = this.createNonInteractiveGit(projectPath);
|
||||||
const patch = await git.show(['--format=', '--patch', commitHash]);
|
let patch;
|
||||||
|
try {
|
||||||
|
patch = await git.show(['--format=', '--patch', commitHash]);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error ?? '');
|
||||||
|
if (this.isSpawnBadFileDescriptorError(message)) {
|
||||||
|
patch = await this.runGitCli(projectPath, ['show', '--format=', '--patch', commitHash]);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
const files = this.parseUnifiedPatchFiles(patch);
|
const files = this.parseUnifiedPatchFiles(patch);
|
||||||
|
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
@@ -723,8 +1055,27 @@ export class GitEngine {
|
|||||||
|
|
||||||
async getHistory(projectPath: string, limit = 20): Promise<GitHistoryEntry[]> {
|
async getHistory(projectPath: string, limit = 20): Promise<GitHistoryEntry[]> {
|
||||||
const git = this.createNonInteractiveGit(projectPath);
|
const git = this.createNonInteractiveGit(projectPath);
|
||||||
const status = await git.status();
|
let status;
|
||||||
const localHistory = await git.log({ maxCount: limit });
|
try {
|
||||||
|
status = await git.status();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error ?? '');
|
||||||
|
if (this.isSpawnBadFileDescriptorError(message)) {
|
||||||
|
return this.getHistoryViaCli(projectPath, limit);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let localHistory;
|
||||||
|
try {
|
||||||
|
localHistory = await git.log({ maxCount: limit });
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error ?? '');
|
||||||
|
if (this.isSpawnBadFileDescriptorError(message)) {
|
||||||
|
return this.getHistoryViaCli(projectPath, limit);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const mapLocalHistory = (): GitHistoryEntry[] => localHistory.all.map((entry) => ({
|
const mapLocalHistory = (): GitHistoryEntry[] => localHistory.all.map((entry) => ({
|
||||||
hash: entry.hash,
|
hash: entry.hash,
|
||||||
@@ -806,8 +1157,26 @@ export class GitEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getFileHistory(projectPath: string, filePath: string, limit = 50): Promise<GitHistoryEntry[]> {
|
async getFileHistory(projectPath: string, filePath: string, limit = 50): Promise<GitHistoryEntry[]> {
|
||||||
const git = simpleGit(projectPath);
|
const git = this.createNonInteractiveGit(projectPath);
|
||||||
const history = await git.log(['--max-count', String(limit), '--', filePath]);
|
let history;
|
||||||
|
try {
|
||||||
|
history = await git.log(['--max-count', String(limit), '--', filePath]);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error ?? '');
|
||||||
|
if (this.isSpawnBadFileDescriptorError(message)) {
|
||||||
|
const format = '%H%x1f%aI%x1f%s%x1f%aN%x1e';
|
||||||
|
const raw = await this.runGitCli(projectPath, ['log', `--pretty=format:${format}`, '--max-count', String(limit), '--', filePath]);
|
||||||
|
const cliEntries = this.parseGitLogCliOutput(raw);
|
||||||
|
return cliEntries.map((entry) => ({
|
||||||
|
hash: entry.hash,
|
||||||
|
shortHash: entry.hash.slice(0, 7),
|
||||||
|
date: entry.date,
|
||||||
|
subject: entry.message,
|
||||||
|
author: entry.author_name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
return history.all.map((entry) => ({
|
return history.all.map((entry) => ({
|
||||||
hash: entry.hash,
|
hash: entry.hash,
|
||||||
@@ -819,8 +1188,17 @@ export class GitEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getRemoteState(projectPath: string): Promise<GitRemoteStateDto> {
|
async getRemoteState(projectPath: string): Promise<GitRemoteStateDto> {
|
||||||
const git = simpleGit(projectPath);
|
const git = this.createNonInteractiveGit(projectPath);
|
||||||
const status = await git.status();
|
let status;
|
||||||
|
try {
|
||||||
|
status = await git.status();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error ?? '');
|
||||||
|
if (this.isSpawnBadFileDescriptorError(message)) {
|
||||||
|
return this.getRemoteStateViaCli(projectPath);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const localBranch = typeof status.current === 'string' && status.current.trim().length > 0
|
const localBranch = typeof status.current === 'string' && status.current.trim().length > 0
|
||||||
? status.current
|
? status.current
|
||||||
@@ -844,6 +1222,15 @@ export class GitEngine {
|
|||||||
await git.fetch(['--prune']);
|
await git.fetch(['--prune']);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error ?? '');
|
||||||
|
if (this.isSpawnBadFileDescriptorError(message)) {
|
||||||
|
try {
|
||||||
|
await this.runGitCli(projectPath, ['fetch', '--prune']);
|
||||||
|
return { success: true };
|
||||||
|
} catch {
|
||||||
|
// continue to action failure mapping below
|
||||||
|
}
|
||||||
|
}
|
||||||
return this.toActionFailure(git, error, 'Failed to fetch remote updates.');
|
return this.toActionFailure(git, error, 'Failed to fetch remote updates.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -854,6 +1241,15 @@ export class GitEngine {
|
|||||||
await git.pull();
|
await git.pull();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error ?? '');
|
||||||
|
if (this.isSpawnBadFileDescriptorError(message)) {
|
||||||
|
try {
|
||||||
|
await this.runGitCli(projectPath, ['pull']);
|
||||||
|
return { success: true };
|
||||||
|
} catch {
|
||||||
|
// continue to action failure mapping below
|
||||||
|
}
|
||||||
|
}
|
||||||
return this.toActionFailure(git, error, 'Failed to pull remote changes.');
|
return this.toActionFailure(git, error, 'Failed to pull remote changes.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -865,6 +1261,14 @@ export class GitEngine {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Failed to push local commits.';
|
const message = error instanceof Error ? error.message : 'Failed to push local commits.';
|
||||||
|
if (this.isSpawnBadFileDescriptorError(message)) {
|
||||||
|
try {
|
||||||
|
await this.runGitCli(projectPath, ['push']);
|
||||||
|
return { success: true };
|
||||||
|
} catch {
|
||||||
|
// continue with existing upstream handling below
|
||||||
|
}
|
||||||
|
}
|
||||||
if (this.isNoUpstreamError(message)) {
|
if (this.isNoUpstreamError(message)) {
|
||||||
const currentBranch = await this.getCurrentBranchName(git);
|
const currentBranch = await this.getCurrentBranchName(git);
|
||||||
if (currentBranch) {
|
if (currentBranch) {
|
||||||
@@ -889,15 +1293,28 @@ export class GitEngine {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const git = simpleGit(projectPath);
|
const git = this.createNonInteractiveGit(projectPath);
|
||||||
try {
|
try {
|
||||||
await git.add(['-A']);
|
await git.add(['-A']);
|
||||||
await git.commit(normalizedMessage);
|
await git.commit(normalizedMessage);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Failed to create commit.';
|
||||||
|
if (this.isSpawnBadFileDescriptorError(errorMessage)) {
|
||||||
|
try {
|
||||||
|
await this.runGitCli(projectPath, ['add', '-A']);
|
||||||
|
await this.runGitCli(projectPath, ['commit', '-m', normalizedMessage]);
|
||||||
|
return { success: true };
|
||||||
|
} catch (cliError) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : 'Failed to create commit.',
|
error: cliError instanceof Error ? cliError.message : 'Failed to create commit.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: errorMessage,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -949,7 +1366,7 @@ export class GitEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async pruneLfsCache(projectPath: string, options: GitLfsPruneOptions = {}): Promise<GitLfsPruneResult> {
|
async pruneLfsCache(projectPath: string, options: GitLfsPruneOptions = {}): Promise<GitLfsPruneResult> {
|
||||||
const git = simpleGit(projectPath);
|
const git = this.createNonInteractiveGit(projectPath);
|
||||||
const verifyRemote = options.verifyRemote ?? true;
|
const verifyRemote = options.verifyRemote ?? true;
|
||||||
const dryRun = options.dryRun ?? false;
|
const dryRun = options.dryRun ?? false;
|
||||||
const recentCommitsToKeep = Math.max(0, options.recentCommitsToKeep ?? 2);
|
const recentCommitsToKeep = Math.max(0, options.recentCommitsToKeep ?? 2);
|
||||||
@@ -986,7 +1403,17 @@ export class GitEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const output = await git.raw(args);
|
let output;
|
||||||
|
try {
|
||||||
|
output = await git.raw(args);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error ?? '');
|
||||||
|
if (this.isSpawnBadFileDescriptorError(message)) {
|
||||||
|
output = await this.runGitCli(projectPath, args);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
dryRun,
|
dryRun,
|
||||||
@@ -1026,7 +1453,7 @@ export class GitEngine {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const git = simpleGit(projectPath);
|
const git = this.createNonInteractiveGit(projectPath);
|
||||||
const isRepo = await git.checkIsRepo();
|
const isRepo = await git.checkIsRepo();
|
||||||
|
|
||||||
if (isRepo) {
|
if (isRepo) {
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ export const GitSidebar: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (repoState.hasRemote) {
|
if (repoState.hasRemote) {
|
||||||
await refreshRemoteState(resolvedProjectPath);
|
await refreshRemoteState(resolvedProjectPath, { fetchFirst: true });
|
||||||
if (!isCurrentRequest()) {
|
if (!isCurrentRequest()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ const mockPush = vi.fn();
|
|||||||
const mockGetRemotes = vi.fn();
|
const mockGetRemotes = vi.fn();
|
||||||
const mockAddRemote = vi.fn();
|
const mockAddRemote = vi.fn();
|
||||||
const mockRemote = vi.fn();
|
const mockRemote = vi.fn();
|
||||||
const { mockReadFile, mockStat, mockWriteFile } = vi.hoisted(() => ({
|
const { mockReadFile, mockStat, mockWriteFile, mockExecFile } = vi.hoisted(() => ({
|
||||||
mockReadFile: vi.fn(),
|
mockReadFile: vi.fn(),
|
||||||
mockStat: vi.fn(),
|
mockStat: vi.fn(),
|
||||||
mockWriteFile: vi.fn(),
|
mockWriteFile: vi.fn(),
|
||||||
|
mockExecFile: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('fs/promises', () => ({
|
vi.mock('fs/promises', () => ({
|
||||||
@@ -54,6 +55,13 @@ vi.mock('simple-git', () => ({
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('node:child_process', () => ({
|
||||||
|
execFile: mockExecFile,
|
||||||
|
default: {
|
||||||
|
execFile: mockExecFile,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
import { GitEngine } from '../../src/main/engine/GitEngine';
|
import { GitEngine } from '../../src/main/engine/GitEngine';
|
||||||
|
|
||||||
describe('GitEngine', () => {
|
describe('GitEngine', () => {
|
||||||
@@ -85,6 +93,9 @@ describe('GitEngine', () => {
|
|||||||
addRemote: mockAddRemote,
|
addRemote: mockAddRemote,
|
||||||
remote: mockRemote,
|
remote: mockRemote,
|
||||||
}));
|
}));
|
||||||
|
mockExecFile.mockImplementation((_command, _args, _options, callback) => {
|
||||||
|
callback(null, '', '');
|
||||||
|
});
|
||||||
gitEngine = new GitEngine();
|
gitEngine = new GitEngine();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -171,6 +182,29 @@ describe('GitEngine', () => {
|
|||||||
{ path: 'staged.md', status: 'staged' },
|
{ path: 'staged.md', status: 'staged' },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should recover status through CLI when status command fails with spawn EBADF', async () => {
|
||||||
|
mockStatus.mockRejectedValue(new Error('Error: spawn EBADF'));
|
||||||
|
mockExecFile.mockImplementation((command, args, _options, callback) => {
|
||||||
|
expect(command).toBe('git');
|
||||||
|
expect(args).toEqual(['status', '--porcelain=v1', '-z']);
|
||||||
|
callback(null, '?? new-file.md\0', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await gitEngine.getStatus('/tmp/project');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
files: [{ path: 'new-file.md', status: 'untracked' }],
|
||||||
|
counts: {
|
||||||
|
untracked: 1,
|
||||||
|
modified: 0,
|
||||||
|
deleted: 0,
|
||||||
|
renamed: 0,
|
||||||
|
staged: 0,
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getDiff', () => {
|
describe('getDiff', () => {
|
||||||
@@ -465,6 +499,53 @@ describe('GitEngine', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should recover with CLI history when local log fails with spawn EBADF', async () => {
|
||||||
|
mockStatus.mockResolvedValue({ current: 'main', tracking: 'origin/main' });
|
||||||
|
mockLog.mockRejectedValueOnce(new Error('Error: spawn EBADF'));
|
||||||
|
|
||||||
|
mockExecFile.mockImplementation((command, args, _options, callback) => {
|
||||||
|
expect(command).toBe('git');
|
||||||
|
const normalizedArgs = Array.isArray(args) ? args : [];
|
||||||
|
|
||||||
|
if (normalizedArgs[0] === 'log' && normalizedArgs.includes('--max-count') && normalizedArgs[normalizedArgs.length - 1] === '20') {
|
||||||
|
const local = 'abc123def456\x1f2026-02-16T10:00:00.000Z\x1ffeat: local via cli\x1fLocal Dev\x1e';
|
||||||
|
callback(null, local, '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedArgs[0] === 'rev-parse') {
|
||||||
|
callback(null, 'origin/main\n', '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedArgs[0] === 'rev-list') {
|
||||||
|
callback(null, '0\n', '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedArgs[0] === 'log' && normalizedArgs[normalizedArgs.length - 1] === 'origin/main') {
|
||||||
|
callback(null, '', '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callback(new Error(`Unexpected git args: ${normalizedArgs.join(' ')}`), '', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await gitEngine.getHistory('/tmp/project', 20);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
hash: 'abc123def456',
|
||||||
|
shortHash: 'abc123d',
|
||||||
|
date: '2026-02-16T10:00:00.000Z',
|
||||||
|
subject: 'feat: local via cli',
|
||||||
|
author: 'Local Dev',
|
||||||
|
syncStatus: 'local-only',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(mockExecFile).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getFileHistory', () => {
|
describe('getFileHistory', () => {
|
||||||
|
|||||||
@@ -750,7 +750,7 @@ describe('GitSidebar', () => {
|
|||||||
fireEvent.click(fetchButton);
|
fireEvent.click(fetchButton);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await screen.findByText(/authentication required/i)).toBeInTheDocument();
|
expect((await screen.findAllByText(/authentication required/i)).length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText(/create a github personal access token/i)).toHaveLength(1);
|
expect(screen.getAllByText(/create a github personal access token/i)).toHaveLength(1);
|
||||||
expect(screen.getByText(/retry with username \+ token/i)).toBeInTheDocument();
|
expect(screen.getByText(/retry with username \+ token/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -872,8 +872,8 @@ describe('GitSidebar', () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect((window as any).electronAPI.git.fetch).toHaveBeenCalledTimes(1);
|
||||||
expect((window as any).electronAPI.git.getRemoteState).toHaveBeenCalledTimes(1);
|
expect((window as any).electronAPI.git.getRemoteState).toHaveBeenCalledTimes(1);
|
||||||
expect((window as any).electronAPI.git.fetch).toHaveBeenCalledTimes(0);
|
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(30000);
|
vi.advanceTimersByTime(30000);
|
||||||
@@ -881,7 +881,7 @@ describe('GitSidebar', () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect((window as any).electronAPI.git.fetch).toHaveBeenCalledTimes(1);
|
expect((window as any).electronAPI.git.fetch).toHaveBeenCalledTimes(2);
|
||||||
expect((window as any).electronAPI.git.getRemoteState).toHaveBeenCalledTimes(2);
|
expect((window as any).electronAPI.git.getRemoteState).toHaveBeenCalledTimes(2);
|
||||||
} finally {
|
} finally {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
|||||||
Reference in New Issue
Block a user