Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions apps/emdash-desktop/src/main/core/git/legacy/git-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { err, ok, type Result } from '@emdash/shared';
import type { IDisposable } from '@emdash/shared';
import type { IExecutionContext } from '@main/core/execution-context/types';
import type { FileSystemProvider } from '@main/core/fs/types';
import { isRecoverableSshTransportError } from '@main/core/ssh/transport-errors';
import { GIT_EXECUTABLE } from '@main/core/utils/exec';
import { log } from '@main/lib/logger';
import {
Expand All @@ -56,6 +57,11 @@ const STATUS_FINGERPRINT_TIMEOUT_MS: Record<GitStatusUntrackedMode, number> = {
normal: 10_000,
};

function emptyStdoutUnlessRecoverable(error: unknown): { stdout: string } {
if (isRecoverableSshTransportError(error)) throw error;
return { stdout: '' };
}

function classifyFetchError(stderr: string): FetchError {
if (
stderr.includes('Authentication failed') ||
Expand Down Expand Up @@ -204,10 +210,8 @@ export class GitService implements IDisposable {
const parser = new StatusParser();
const [, stagedRes, unstagedRes, head] = await Promise.all([
this._runStatusZ(parser),
this.ctx.exec('git', ['diff', '--numstat', '--cached']).catch(() => ({
stdout: '',
})),
this.ctx.exec('git', ['diff', '--numstat']).catch(() => ({ stdout: '' })),
this.ctx.exec('git', ['diff', '--numstat', '--cached']).catch(emptyStdoutUnlessRecoverable),
this.ctx.exec('git', ['diff', '--numstat']).catch(emptyStdoutUnlessRecoverable),
this._getHeadInfo(),
]);

Expand All @@ -221,6 +225,7 @@ export class GitService implements IDisposable {
return await this._buildFullGitStatus(parser.status, stagedNumstat, unstagedNumstat, head);
} catch (e) {
if (e instanceof TooManyFilesChangedError) throw e;
if (isRecoverableSshTransportError(e)) throw e;
return {
staged: [],
unstaged: [],
Expand Down Expand Up @@ -997,7 +1002,8 @@ export class GitService implements IDisposable {
try {
const { stdout } = await this.ctx.exec('git', ['rev-parse', '--symbolic-full-name', 'HEAD']);
ref = stdout.trim();
} catch {
} catch (error) {
if (isRecoverableSshTransportError(error)) throw error;
return this._getUnbornHeadInfo();
}

Expand All @@ -1024,7 +1030,8 @@ export class GitService implements IDisposable {
if (ref.startsWith('heads/'))
return { kind: 'branch', name: ref.slice('heads/'.length), oid };
return { kind: 'branch', name: ref, oid };
} catch {
} catch (error) {
if (isRecoverableSshTransportError(error)) throw error;
return this._getUnbornHeadInfo();
}
}
Expand All @@ -1033,7 +1040,8 @@ export class GitService implements IDisposable {
try {
const { stdout: symOut } = await this.ctx.exec('git', ['symbolic-ref', '--short', 'HEAD']);
return { kind: 'unborn', name: symOut.trim() };
} catch {
} catch (error) {
if (isRecoverableSshTransportError(error)) throw error;
return { kind: 'unborn', name: 'main' };
}
}
Expand Down Expand Up @@ -1162,7 +1170,8 @@ export class GitService implements IDisposable {
}
}
return remotes;
} catch {
} catch (error) {
if (isRecoverableSshTransportError(error)) throw error;
return [];
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto';
import type { GitPathInspection } from '@emdash/core/git';
import { err, ok } from '@emdash/shared';
import { sql } from 'drizzle-orm';
import { projectEvents } from '@main/core/projects/project-events';
Expand Down Expand Up @@ -101,7 +102,7 @@ export async function getLocalProjectPathStatus(path: string): Promise<ProjectPa

const runtimeLease = await runtimeManager.acquire({ kind: 'local' });
try {
const inspection = await runtimeLease.value.git.inspectPath(path);
const inspection: GitPathInspection = await runtimeLease.value.git.inspectPath(path);
if (inspection.kind === 'inspect-failed') {
return {
isDirectory: true,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { randomUUID } from 'node:crypto';
import type { GitPathInspection } from '@emdash/core/git';
import { err, ok } from '@emdash/shared';
import { sql } from 'drizzle-orm';
import { SshFileSystem } from '@main/core/fs/impl/ssh-fs';
Expand Down Expand Up @@ -102,7 +103,7 @@ export async function getSshProjectPathStatus(

const runtimeLease = await runtimeManager.acquire({ kind: 'ssh', connectionId });
try {
const inspection = await runtimeLease.value.git.inspectPath(path);
const inspection: GitPathInspection = await runtimeLease.value.git.inspectPath(path);
if (inspection.kind === 'inspect-failed') {
return {
isDirectory: true,
Expand Down
28 changes: 28 additions & 0 deletions apps/emdash-desktop/src/main/core/pty/local-pty.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { log } from '@main/lib/logger';
import { LocalPtySession } from './local-pty';
import type { PosixPtyTerminator } from './posix-pty-terminator';

vi.mock('node-pty', () => ({
spawn: vi.fn(),
}));

vi.mock('@main/lib/logger', () => ({
log: {
error: vi.fn(),
info: vi.fn(),
},
}));

describe('LocalPtySession', () => {
type MockPtyProcess = ConstructorParameters<typeof LocalPtySession>[1];

Expand Down Expand Up @@ -63,6 +71,26 @@ describe('LocalPtySession', () => {
expect(mockProc.kill).toHaveBeenCalledTimes(1);
});

it('catches errors thrown by onExit handlers', () => {
let exitHandler: ((info: { exitCode: number; signal: number }) => void) | undefined;
vi.mocked(mockProc.onExit).mockImplementation((handler) => {
exitHandler = handler;
return { dispose: vi.fn() };
});

pty.onExit(() => {
throw new Error('cleanup failed');
});

expect(() => exitHandler?.({ exitCode: 1, signal: 15 })).not.toThrow();
expect(log.error).toHaveBeenCalledWith('LocalPtySession:onExit handler failed', {
id: 'test-id',
exitCode: 1,
signal: 'SIGTERM',
error: 'cleanup failed',
});
});

it('kill() does not use POSIX termination on Windows', () => {
setPlatform('win32');

Expand Down
14 changes: 12 additions & 2 deletions apps/emdash-desktop/src/main/core/pty/local-pty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,18 @@ export class LocalPtySession implements Pty {

onExit(handler: (info: PtyExitInfo) => void): void {
this.proc.onExit(({ exitCode, signal }) => {
this.posixTerminator.markExited();
handler({ exitCode, signal: normalizeSignal(signal) });
try {
this.posixTerminator.markExited();
handler({ exitCode, signal: normalizeSignal(signal) });
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
log.error('LocalPtySession:onExit handler failed', {
id: this.id,
exitCode,
signal: normalizeSignal(signal),
error: message,
});
}
});
}

Expand Down
133 changes: 133 additions & 0 deletions apps/emdash-desktop/src/main/core/runtime/legacy/ssh-git.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import type { LocalBranch } from '@emdash/core/git';
import { describe, expect, it, vi } from 'vitest';
import type { IExecutionContext } from '@main/core/execution-context/types';
import type { FileSystemProvider } from '@main/core/fs/types';
import { GitService } from '@main/core/git/legacy/git-service';
import { log } from '@main/lib/logger';
import { LegacySshGitRepository, LegacySshGitWorktree } from './ssh-git';

describe('GitService SSH refresh failures', () => {
function createRejectingGitService(error: Error): GitService {
const ctx = {
root: '/repo/worktree',
supportsLocalSpawn: false,
exec: vi.fn(async () => {
throw error;
}),
execStreaming: vi.fn(async () => {
throw error;
}),
dispose: vi.fn(),
} as unknown as IExecutionContext;
const fs = {} as FileSystemProvider;
return new GitService(ctx, fs);
}

it('propagates recoverable SSH failures from status fallback paths', async () => {
const git = createRejectingGitService(new Error('SSH connection is not available'));

await expect(git.getFullStatus()).rejects.toThrow('SSH connection is not available');
});

it('propagates recoverable SSH failures from head fallback paths', async () => {
const git = createRejectingGitService(
Object.assign(new Error('read ETIMEDOUT'), { code: 'ETIMEDOUT' })
);

await expect(git.getHeadInfo()).rejects.toThrow('read ETIMEDOUT');
});
});

describe('LegacySshGitRepository', () => {
it('treats unavailable SSH connections during background refs refresh as recoverable', async () => {
vi.useFakeTimers();
const warn = vi.spyOn(log, 'warn').mockImplementation(() => {});
const firstRefs: LocalBranch[] = [{ type: 'local', branch: 'main', oid: 'abc123' }];
const getBranches = vi
.fn<() => Promise<LocalBranch[]>>()
.mockResolvedValueOnce(firstRefs)
.mockRejectedValueOnce(new Error('SSH connection is not available'));
const git = {
getBranches,
getRemotes: vi.fn(async () => []),
dispose: vi.fn(),
} as unknown as GitService;
const repository = new LegacySshGitRepository(git, '/repo/.git');

try {
await expect(repository.getRefs()).resolves.toEqual({ branches: firstRefs });
await expect(repository.getRemotes()).resolves.toEqual({ remotes: [] });
const updates: unknown[] = [];
const unsubscribe = repository.subscribe((update) => updates.push(update));

await vi.advanceTimersByTimeAsync(15_001);

expect(getBranches).toHaveBeenCalledTimes(2);
expect(updates).toEqual([]);

unsubscribe();
} finally {
repository.dispose();
warn.mockRestore();
vi.useRealTimers();
}
});

it('treats unavailable SSH connections during background status refresh as recoverable', async () => {
vi.useFakeTimers();
const warn = vi.spyOn(log, 'warn').mockImplementation(() => {});
const getFullStatus = vi
.fn<() => Promise<Awaited<ReturnType<GitService['getFullStatus']>>>>()
.mockResolvedValueOnce({
staged: [],
unstaged: [],
currentBranch: 'main',
headKind: 'branch',
shortHash: 'abc123',
totalAdded: 0,
totalDeleted: 0,
})
.mockRejectedValueOnce(new Error('SSH connection is not available'));
const git = {
getFullStatus,
getHeadInfo: vi.fn(async () => ({ kind: 'branch', name: 'main', oid: 'abc123' })),
getStatusFingerprint: vi
.fn<() => Promise<{ hash: string }>>()
.mockResolvedValueOnce({ hash: 'h1' })
.mockResolvedValueOnce({ hash: 'h2' }),
dispose: vi.fn(),
} as unknown as GitService;
const worktree = new LegacySshGitWorktree(git, '/repo/worktree', {} as LegacySshGitRepository);

try {
await expect(worktree.getStatus()).resolves.toEqual({
kind: 'ok',
staged: [],
unstaged: [],
stagedAdded: 0,
stagedDeleted: 0,
});
await expect(worktree.getHead()).resolves.toEqual({
kind: 'branch',
name: 'main',
oid: 'abc123',
});
const updates: unknown[] = [];
const unsubscribe = worktree.subscribe((update) => updates.push(update));

await vi.advanceTimersByTimeAsync(20_001);

expect(getFullStatus).toHaveBeenCalledTimes(2);
expect(updates).toEqual([]);
expect(warn).toHaveBeenCalledWith('LegacySshGitWorktree: status refresh failed', {
error: expect.any(Error),
});

unsubscribe();
} finally {
worktree.dispose();
warn.mockRestore();
vi.useRealTimers();
}
});
});
Loading
Loading