Skip to content

Commit 0342614

Browse files
committed
Add git merge and remote fallback handling
1 parent aaa5ddb commit 0342614

13 files changed

Lines changed: 529 additions & 89 deletions

File tree

electron/main.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import os from 'os';
66
import path from 'path';
77
import { addWorkspace, createWorkspace, listWorkspaces, loadLayout, loadRestoreState, removeWorkspace, saveLayout, saveRestoreState, updateWorkspace } from './workspaceStore';
88
import { createFile, createFolder, deletePath, pathExists, readDirectory, readFile, readFileDataUrl, renamePath, revealInExplorer, writeFile } from './fileService';
9-
import { addAll, commit, discardFile, fetch, getGitDiff, getGitFileContents, getGitStatus, getIgnoredFiles, listBranches, pull, push, stageFile, switchBranch, unstageFile } from '../extensions/builtin/git/main/gitService';
9+
import { abortMerge, addAll, commit, discardFile, fetch, getGitDiff, getGitFileContents, getGitStatus, getIgnoredFiles, listBranches, pull, pullMerge, push, stageFile, switchBranch, unstageFile } from '../extensions/builtin/git/main/gitService';
1010
import { createTerminal, forgetTerminalSnapshot, getTerminalProfiles, getTerminalSnapshot, killTerminal, loadOpenTerminalState, markTerminalReady, resizeTerminal, saveOpenTerminalState, setTerminalWindow, setVisibleTerminals, updateTerminalSession, writeTerminal } from './terminalManager';
1111
import { setBridgeWindow, startBrowserBridge } from './browserBridge';
1212
import { ensureDataDirs } from './storage';
@@ -234,6 +234,8 @@ function registerIpc() {
234234
ipcMain.handle('git:switchBranch', async (_event, targetPath: unknown, branch: unknown) => switchBranch(assertAbsolutePath(targetPath, 'targetPath'), assertNonEmptyString(branch, 'branch')));
235235
ipcMain.handle('git:push', async (_event, targetPath: unknown) => push(assertAbsolutePath(targetPath, 'targetPath')));
236236
ipcMain.handle('git:pull', async (_event, targetPath: unknown) => pull(assertAbsolutePath(targetPath, 'targetPath')));
237+
ipcMain.handle('git:pullMerge', async (_event, targetPath: unknown) => pullMerge(assertAbsolutePath(targetPath, 'targetPath')));
238+
ipcMain.handle('git:abortMerge', async (_event, targetPath: unknown) => abortMerge(assertAbsolutePath(targetPath, 'targetPath')));
237239
ipcMain.handle('git:fetch', async (_event, targetPath: unknown) => fetch(assertAbsolutePath(targetPath, 'targetPath')));
238240
ipcMain.handle('git:ignored', async (_event, targetPath: unknown, paths: unknown) => getIgnoredFiles(assertAbsolutePath(targetPath, 'targetPath'), Array.isArray(paths) ? paths.map((item) => assertNonEmptyString(item, 'path')) : []));
239241

electron/preload.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ const api: StackDockApi = {
6767
switchBranch: (targetPath, branch) => ipcRenderer.invoke('git:switchBranch', targetPath, branch),
6868
push: (targetPath) => ipcRenderer.invoke('git:push', targetPath),
6969
pull: (targetPath) => ipcRenderer.invoke('git:pull', targetPath),
70+
pullMerge: (targetPath) => ipcRenderer.invoke('git:pullMerge', targetPath),
71+
abortMerge: (targetPath) => ipcRenderer.invoke('git:abortMerge', targetPath),
7072
fetch: (targetPath) => ipcRenderer.invoke('git:fetch', targetPath),
7173
ignored: (targetPath, paths) => ipcRenderer.invoke('git:ignored', targetPath, paths),
7274
},

extensions/builtin/git/main/gitParser.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,23 @@ export interface ParsedGitFileStatus {
55
staged: boolean;
66
unstaged: boolean;
77
untracked: boolean;
8+
conflicted: boolean;
9+
conflictStatus?: string;
810
}
911

12+
const unmergedStatusCodes = new Set(['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU']);
13+
1014
export function parseStatusLine(line: string): ParsedGitFileStatus | null {
1115
if (line.startsWith('?? ')) {
1216
const filePath = line.slice(3).trim();
13-
return { path: filePath, indexStatus: '?', worktreeStatus: '?', staged: false, unstaged: true, untracked: true };
17+
return { path: filePath, indexStatus: '?', worktreeStatus: '?', staged: false, unstaged: true, untracked: true, conflicted: false };
1418
}
1519
if (line.length < 4) return null;
1620
const indexStatus = line[0] ?? ' ';
1721
const worktreeStatus = line[1] ?? ' ';
22+
const statusCode = `${indexStatus}${worktreeStatus}`;
1823
let filePath = line.slice(3).trim();
1924
if (filePath.includes(' -> ')) filePath = filePath.split(' -> ').pop() ?? filePath;
20-
return { path: filePath, indexStatus, worktreeStatus, staged: indexStatus !== ' ', unstaged: worktreeStatus !== ' ', untracked: false };
25+
if (unmergedStatusCodes.has(statusCode)) return { path: filePath, indexStatus, worktreeStatus, staged: false, unstaged: true, untracked: false, conflicted: true, conflictStatus: statusCode };
26+
return { path: filePath, indexStatus, worktreeStatus, staged: indexStatus !== ' ', unstaged: worktreeStatus !== ' ', untracked: false, conflicted: false };
2127
}

extensions/builtin/git/main/gitService.ts

Lines changed: 95 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,72 @@ import { parseStatusLine } from './gitParser';
77

88
const execFileAsync = promisify(execFile);
99

10-
async function runGit(cwd: string, args: string[], options?: { timeoutMs?: number }) {
11-
const { stdout } = await execFileAsync('git', ['-C', cwd, ...args], {
12-
maxBuffer: 1024 * 1024 * 10,
13-
timeout: options?.timeoutMs ?? 30000,
14-
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
15-
});
16-
return stdout.toString();
10+
interface GitResult { stdout: string; stderr: string }
11+
12+
export class GitCommandError extends Error {
13+
stdout: string;
14+
stderr: string;
15+
remoteErrorKind: 'auth' | 'terminal-required' | 'other';
16+
17+
constructor(message: string, stdout: string, stderr: string) {
18+
super(message);
19+
this.name = 'GitCommandError';
20+
this.stdout = stdout;
21+
this.stderr = stderr;
22+
this.remoteErrorKind = isAuthErrorText(`${stderr}\n${stdout}`) ? 'auth' : 'other';
23+
}
24+
}
25+
26+
function formatGitError(stdout: string, stderr: string) {
27+
const detail = `${stderr}\n${stdout}`.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(0, 8).join('\n');
28+
return detail || 'Git command failed';
29+
}
30+
31+
async function runGit(cwd: string, args: string[], options?: { timeoutMs?: number }): Promise<GitResult> {
32+
try {
33+
const { stdout, stderr } = await execFileAsync('git', ['-C', cwd, ...args], {
34+
maxBuffer: 1024 * 1024 * 10,
35+
timeout: options?.timeoutMs ?? 30000,
36+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
37+
});
38+
return { stdout: stdout.toString(), stderr: stderr.toString() };
39+
} catch (error) {
40+
const stdout = typeof error === 'object' && error && 'stdout' in error ? String((error as { stdout?: unknown }).stdout ?? '') : '';
41+
const stderr = typeof error === 'object' && error && 'stderr' in error ? String((error as { stderr?: unknown }).stderr ?? '') : '';
42+
throw new GitCommandError(formatGitError(stdout, stderr), stdout, stderr);
43+
}
44+
}
45+
46+
function isAuthErrorText(text: string) {
47+
return /Authentication failed|Credentials are incorrect or have expired|could not read Username|terminal prompts disabled|Permission denied \(publickey\)|Repository not found.*(Authentication|auth|credential|permission)/i.test(text);
48+
}
49+
50+
export function isAuthError(error: unknown) {
51+
if (error instanceof GitCommandError) return error.remoteErrorKind === 'auth';
52+
return error instanceof Error ? isAuthErrorText(error.message) : isAuthErrorText(String(error));
53+
}
54+
55+
async function gitOutput(cwd: string, args: string[], options?: { timeoutMs?: number }) {
56+
return (await runGit(cwd, args, options)).stdout;
57+
}
58+
59+
async function gitPathExists(cwd: string, gitPath: string) {
60+
try {
61+
const resolved = (await gitOutput(cwd, ['rev-parse', '--git-path', gitPath])).trim();
62+
if (!resolved) return false;
63+
const target = path.isAbsolute(resolved) ? resolved : path.join(cwd, resolved);
64+
await fs.access(target);
65+
return true;
66+
} catch {
67+
return false;
68+
}
69+
}
70+
71+
async function detectOperation(cwd: string): Promise<GitStatus['operation']> {
72+
if (await gitPathExists(cwd, 'MERGE_HEAD')) return 'merge';
73+
if (await gitPathExists(cwd, 'REBASE_HEAD') || await gitPathExists(cwd, 'rebase-merge') || await gitPathExists(cwd, 'rebase-apply')) return 'rebase';
74+
if (await gitPathExists(cwd, 'CHERRY_PICK_HEAD')) return 'cherry-pick';
75+
return undefined;
1776
}
1877

1978
function assertSafeGitRef(ref: string) {
@@ -25,7 +84,7 @@ function assertSafeGitRef(ref: string) {
2584

2685
export async function listBranches(cwd: string): Promise<string[]> {
2786
try {
28-
const output = await runGit(cwd, ['branch', '--format=%(refname:short)']);
87+
const output = await gitOutput(cwd, ['branch', '--format=%(refname:short)']);
2988
return [...new Set(output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean))];
3089
} catch {
3190
return [];
@@ -34,7 +93,7 @@ export async function listBranches(cwd: string): Promise<string[]> {
3493

3594
export async function getGitStatus(cwd: string): Promise<GitStatus> {
3695
try {
37-
const output = await runGit(cwd, ['status', '--porcelain=v1', '-b']);
96+
const output = await gitOutput(cwd, ['status', '--porcelain=v1', '-b']);
3897
const lines = output.trim().split(/\r?\n/).filter(Boolean);
3998
const branchLine = lines.shift() ?? '';
4099
const status: GitStatus = { isRepo: true, files: [] };
@@ -49,6 +108,9 @@ export async function getGitStatus(cwd: string): Promise<GitStatus> {
49108
}
50109
status.files = lines.map(parseStatusLine).filter(Boolean) as GitFileStatus[];
51110
status.branches = await listBranches(cwd);
111+
status.operation = await detectOperation(cwd);
112+
status.conflicts = status.files.filter((file) => file.conflicted).length;
113+
status.mergeReady = status.operation === 'merge' && status.conflicts === 0;
52114
return status;
53115
} catch {
54116
return { isRepo: false, files: [] };
@@ -60,70 +122,38 @@ export async function getGitDiff(cwd: string, filePath?: string, staged = false)
60122
const args = ['diff'];
61123
if (staged) args.push('--staged');
62124
if (filePath) args.push('--', filePath);
63-
return await runGit(cwd, args);
125+
return await gitOutput(cwd, args);
64126
} catch (error) {
65127
return error instanceof Error ? error.message : String(error);
66128
}
67129
}
68130

69131
async function readGitObject(cwd: string, revision: string, filePath: string) {
70-
try {
71-
return await runGit(cwd, ['show', `${revision}:${filePath}`]);
72-
} catch {
73-
return '';
74-
}
132+
try { return await gitOutput(cwd, ['show', `${revision}:${filePath}`]); } catch { return ''; }
75133
}
76-
77134
async function readIndexObject(cwd: string, filePath: string) {
78-
try {
79-
return await runGit(cwd, ['show', `:${filePath}`]);
80-
} catch {
81-
return '';
82-
}
135+
try { return await gitOutput(cwd, ['show', `:${filePath}`]); } catch { return ''; }
83136
}
84-
85137
async function readWorkingFile(cwd: string, filePath: string) {
86-
try {
87-
return await fs.readFile(path.join(cwd, filePath), 'utf8');
88-
} catch {
89-
return '';
90-
}
138+
try { return await fs.readFile(path.join(cwd, filePath), 'utf8'); } catch { return ''; }
91139
}
92140

93141
export async function getGitFileContents(cwd: string, filePath: string, staged = false): Promise<GitFileContents> {
94142
if (staged) {
95-
const [original, modified] = await Promise.all([
96-
readGitObject(cwd, 'HEAD', filePath),
97-
readIndexObject(cwd, filePath),
98-
]);
143+
const [original, modified] = await Promise.all([readGitObject(cwd, 'HEAD', filePath), readIndexObject(cwd, filePath)]);
99144
return { path: filePath, original, modified };
100145
}
101-
102146
const indexContent = await readIndexObject(cwd, filePath);
103147
const original = indexContent || await readGitObject(cwd, 'HEAD', filePath);
104148
const modified = await readWorkingFile(cwd, filePath);
105149
return { path: filePath, original, modified };
106150
}
107151

108-
export async function stageFile(cwd: string, filePath: string) {
109-
await runGit(cwd, ['add', '--', filePath]);
110-
}
111-
112-
export async function unstageFile(cwd: string, filePath: string) {
113-
await runGit(cwd, ['restore', '--staged', '--', filePath]);
114-
}
115-
116-
export async function discardFile(cwd: string, filePath: string) {
117-
await runGit(cwd, ['restore', '--', filePath]);
118-
}
119-
120-
export async function commit(cwd: string, message: string) {
121-
await runGit(cwd, ['commit', '-m', message]);
122-
}
123-
124-
export async function addAll(cwd: string) {
125-
await runGit(cwd, ['add', '.']);
126-
}
152+
export async function stageFile(cwd: string, filePath: string) { await runGit(cwd, ['add', '--', filePath]); }
153+
export async function unstageFile(cwd: string, filePath: string) { await runGit(cwd, ['restore', '--staged', '--', filePath]); }
154+
export async function discardFile(cwd: string, filePath: string) { await runGit(cwd, ['restore', '--', filePath]); }
155+
export async function commit(cwd: string, message: string) { await runGit(cwd, ['commit', '-m', message]); }
156+
export async function addAll(cwd: string) { await runGit(cwd, ['add', '.']); }
127157

128158
export async function switchBranch(cwd: string, branch: string) {
129159
const safeBranch = assertSafeGitRef(branch);
@@ -132,26 +162,28 @@ export async function switchBranch(cwd: string, branch: string) {
132162
await runGit(cwd, ['switch', safeBranch]);
133163
}
134164

135-
export async function push(cwd: string) {
136-
await runGit(cwd, ['push'], { timeoutMs: 120000 });
137-
}
138-
139-
export async function pull(cwd: string) {
140-
await runGit(cwd, ['pull', '--ff-only'], { timeoutMs: 120000 });
141-
}
142-
143-
export async function fetch(cwd: string) {
144-
await runGit(cwd, ['fetch'], { timeoutMs: 120000 });
165+
export async function push(cwd: string) { await runGit(cwd, ['push'], { timeoutMs: 120000 }); }
166+
export async function pull(cwd: string) { await runGit(cwd, ['pull', '--ff-only'], { timeoutMs: 120000 }); }
167+
export async function pullMerge(cwd: string) {
168+
try {
169+
await runGit(cwd, ['pull', '--no-rebase', '--no-edit'], { timeoutMs: 120000 });
170+
} catch (error) {
171+
const status = await getGitStatus(cwd);
172+
if (status.operation === 'merge' && (status.conflicts ?? 0) > 0) throw new Error('Merge has conflicts. Resolve them, stage the files, then commit the merge.');
173+
throw error;
174+
}
145175
}
176+
export async function abortMerge(cwd: string) { await runGit(cwd, ['merge', '--abort']); }
177+
export async function fetch(cwd: string) { await runGit(cwd, ['fetch'], { timeoutMs: 120000 }); }
146178

147179
export async function getIgnoredFiles(cwd: string, filePaths: string[]): Promise<string[]> {
148180
if (!filePaths.length) return [];
149181
const relativePaths = filePaths.map((filePath) => path.relative(cwd, path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath)).replace(/\\/g, '/'));
150182
try {
151-
const output = await runGit(cwd, ['check-ignore', ...relativePaths]);
183+
const output = await gitOutput(cwd, ['check-ignore', ...relativePaths]);
152184
return output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
153185
} catch (error) {
154-
const output = typeof error === 'object' && error && 'stdout' in error ? String((error as { stdout?: unknown }).stdout ?? '') : '';
186+
const output = error instanceof GitCommandError ? error.stdout : '';
155187
return output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
156188
}
157189
}

0 commit comments

Comments
 (0)