Skip to content
Closed
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
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ node_modules/
.worktrees/
.claude/
.letta/
.planning/
CLAUDE.md
package-lock.json
*.AppImage
*.deb
Expand Down
1 change: 1 addition & 0 deletions electron/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export enum IPC {
RebaseTask = 'rebase_task',
GetMainBranch = 'get_main_branch',
GetCurrentBranch = 'get_current_branch',
CheckoutBranch = 'checkout_branch',
GetBranches = 'get_branches',
CheckIsGitRepo = 'check_is_git_repo',
CommitAll = 'commit_all',
Expand Down
118 changes: 118 additions & 0 deletions electron/ipc/git.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { promisify } from 'util';

vi.mock('child_process', () => {
const mockExecFile = vi.fn();
// Attach custom promisify handler so promisify(execFile) resolves with
// { stdout, stderr } rather than just the first callback argument.
(mockExecFile as unknown as Record<symbol, unknown>)[promisify.custom] = (
file: unknown,
args: unknown,
opts: unknown,
): Promise<{ stdout: string; stderr: string }> =>
new Promise((resolve, reject) => {
mockExecFile(file, args, opts, (err: Error | null, stdout: string, stderr: string) => {
if (err) reject(err);
else resolve({ stdout, stderr });
});
});

return {
execFile: mockExecFile,
spawn: vi.fn(() => ({
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn(),
})),
};
});

import { execFile } from 'child_process';
import {
getAllFileDiffsFromBranch,
getChangedFilesFromBranch,
getFileDiffFromBranch,
} from './git.js';

type ExecFileCallback = (err: Error | null, stdout: string, stderr: string) => void;
type MockHandler = (args: string[], cb: ExecFileCallback) => void;

/** Configure the mocked execFile — double cast avoids execFile's 12 overloads */
function setupMock(calls: string[][], handler: MockHandler): void {
const impl = (_cmd: string, args: string[], _opts: unknown, cb: ExecFileCallback) => {
calls.push(args);
handler(args, cb);
};
vi.mocked(execFile).mockImplementation(impl as unknown as typeof execFile);
}

describe('baseBranch fallback to detectMainBranch', () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe('getAllFileDiffsFromBranch', () => {
it('falls back to detected main branch when baseBranch is undefined', async () => {
const calls: string[][] = [];
setupMock(calls, (argsArr, cb) => {
const isDiff = argsArr[0] === 'diff';
cb(isDiff ? null : new Error('no remote'), '', '');
});

await getAllFileDiffsFromBranch('/repo', 'feature', undefined);

const diffCall = calls.find((a) => a[0] === 'diff');
expect(diffCall).toBeDefined();
const refSpec = diffCall?.[2] ?? '';
expect(refSpec).toMatch(/^main\.\.\./);
});

it("uses the provided baseBranch when it is 'develop'", async () => {
const calls: string[][] = [];
setupMock(calls, (_argsArr, cb) => {
cb(null, '', '');
});

await getAllFileDiffsFromBranch('/repo', 'feature', 'develop');

const diffCall = calls.find((a) => a[0] === 'diff');
expect(diffCall).toBeDefined();
const refSpec = diffCall?.[2] ?? '';
expect(refSpec).toBe('develop...feature');
const detectionCalls = calls.filter((a) =>
['symbolic-ref', 'rev-parse', 'config', 'remote'].includes(a[0]),
);
expect(detectionCalls).toHaveLength(0);
});
});

describe('getChangedFilesFromBranch', () => {
it("uses 'develop' directly when baseBranch is 'develop'", async () => {
const calls: string[][] = [];
setupMock(calls, (_argsArr, cb) => {
cb(null, '', '');
});

await getChangedFilesFromBranch('/repo', 'feature', 'develop');

const diffCall = calls.find((a) => a[0] === 'diff');
const tripleRef = diffCall?.find((arg) => arg.includes('...')) ?? '';
expect(tripleRef).toBe('develop...feature');
});
});

describe('getFileDiffFromBranch', () => {
it("uses 'develop' directly when baseBranch is 'develop'", async () => {
const calls: string[][] = [];
setupMock(calls, (_argsArr, cb) => {
cb(null, '', '');
});

await getFileDiffFromBranch('/repo', 'feature', 'src/foo.ts', 'develop');

const diffCall = calls.find((a) => a[0] === 'diff');
const tripleRef = diffCall?.find((arg) => arg.includes('...')) ?? '';
expect(tripleRef).toBe('develop...feature');
});
});
});
110 changes: 23 additions & 87 deletions electron/ipc/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,79 +192,30 @@ async function getCurrentBranchName(repoRoot: string): Promise<string> {
return stdout.trim();
}

/**
* Resolve a branch name to whichever ref is further ahead for comparisons:
* local branch or its remote-tracking counterpart. Using the most advanced
* ref prevents diffs from showing files already present on the other side.
* Falls back to the bare name when no remote ref exists (local-only repos).
*
* Note: for merge-base computation, use detectMergeBase() directly — it
* compares both local and remote merge-bases to pick the closest one.
*/
async function resolveComparisonRef(repoRoot: string, branch: string): Promise<string> {
if (branch.includes('/')) return branch;
if (!(await remoteTrackingRefExists(repoRoot, branch))) return branch;

const remote = `origin/${branch}`;
try {
const { stdout } = await exec('git', ['rev-list', '--count', `${branch}..${remote}`], {
cwd: repoRoot,
});
const originAhead = parseInt(stdout.trim(), 10) || 0;
return originAhead > 0 ? remote : branch;
} catch {
return branch;
}
}

async function detectMergeBase(
repoRoot: string,
head?: string,
baseBranch?: string,
): Promise<string> {
const branch = baseBranch ?? (await detectMainBranch(repoRoot));
const headRef = head ?? 'HEAD';
const key = `${cacheKey(repoRoot)}:${branch}`;
const mainBranch = baseBranch ?? (await detectMainBranch(repoRoot));
const key = `${cacheKey(repoRoot)}:${mainBranch}`;
const cached = mergeBaseCache.get(key);
if (cached) {
if (cached.expiresAt > Date.now()) return cached.value;
mergeBaseCache.delete(key);
}

// When a remote-tracking ref exists, compute merge-base against both the
// local branch and origin/<branch>, then pick whichever is closer to HEAD.
// This avoids showing extra files when local and remote have diverged.
const refs = [branch];
if (!branch.includes('/') && (await remoteTrackingRefExists(repoRoot, branch))) {
refs.push(`origin/${branch}`);
}

let best: string | null = null;
for (const ref of refs) {
try {
const { stdout } = await exec('git', ['merge-base', ref, headRef], { cwd: repoRoot });
const mb = stdout.trim();
if (!mb) continue;
if (!best) {
best = mb;
continue;
}
if (mb === best) continue;
// Two different merge-bases: pick the descendant (closer to HEAD).
// `--is-ancestor A B` succeeds when A is reachable from B.
const aIsAncestor = await exec('git', ['merge-base', '--is-ancestor', best, mb], {
cwd: repoRoot,
}).then(
() => true,
() => false,
);
if (aIsAncestor) best = mb;
} catch {
/* ref may not resolve — skip */
}
let result: string;
try {
const { stdout } = await exec('git', ['merge-base', mainBranch, head ?? 'HEAD'], {
cwd: repoRoot,
});
const hash = stdout.trim();
result = hash || mainBranch;
} catch {
result = mainBranch;
}

const result = best || branch;
mergeBaseCache.set(key, { value: result, expiresAt: Date.now() + MERGE_BASE_TTL });
return result;
}
Expand Down Expand Up @@ -533,6 +484,10 @@ export async function getCurrentBranch(projectRoot: string): Promise<string> {
return getCurrentBranchName(projectRoot);
}

export async function checkoutBranch(projectRoot: string, branchName: string): Promise<void> {
await exec('git', ['checkout', branchName], { cwd: projectRoot });
}

export async function getBranches(projectRoot: string): Promise<string[]> {
const { stdout } = await exec('git', ['branch', '--list', '--format=%(refname:short)'], {
cwd: projectRoot,
Expand Down Expand Up @@ -722,10 +677,7 @@ export async function getAllFileDiffsFromBranch(
branchName: string,
baseBranch?: string,
): Promise<string> {
const mainBranch = await resolveComparisonRef(
projectRoot,
baseBranch ?? (await detectMainBranch(projectRoot)),
);
const mainBranch = baseBranch ?? (await detectMainBranch(projectRoot));
try {
const { stdout } = await exec('git', ['diff', '-U3', `${mainBranch}...${branchName}`], {
cwd: projectRoot,
Expand Down Expand Up @@ -873,10 +825,7 @@ export async function getWorktreeStatus(
});
const hasUncommittedChanges = statusOut.trim().length > 0;

const mainBranch = await resolveComparisonRef(
worktreePath,
baseBranch ?? (await detectMainBranch(worktreePath).catch(() => 'HEAD')),
);
const mainBranch = baseBranch ?? (await detectMainBranch(worktreePath).catch(() => 'HEAD'));
let hasCommittedChanges = false;
try {
const { stdout: logOut } = await exec('git', ['log', `${mainBranch}..HEAD`, '--oneline'], {
Expand Down Expand Up @@ -909,10 +858,7 @@ export async function checkMergeStatus(
worktreePath: string,
baseBranch?: string,
): Promise<{ main_ahead_count: number; conflicting_files: string[] }> {
const mainBranch = await resolveComparisonRef(
worktreePath,
baseBranch ?? (await detectMainBranch(worktreePath)),
);
const mainBranch = baseBranch ?? (await detectMainBranch(worktreePath));

let mainAheadCount = 0;
try {
Expand Down Expand Up @@ -953,10 +899,9 @@ export async function mergeTask(

return withWorktreeLock(lockKey, async () => {
const mainBranch = baseBranch ?? (await detectMainBranch(projectRoot));
const comparisonRef = await resolveComparisonRef(projectRoot, mainBranch);
const { linesAdded, linesRemoved } = await computeBranchDiffStats(
projectRoot,
comparisonRef,
mainBranch,
branchName,
);

Expand All @@ -971,7 +916,7 @@ export async function mergeTask(

const originalBranch = await getCurrentBranchName(projectRoot).catch(() => null);

// Checkout main (bare branch name, not remote-tracking ref)
// Checkout main
await exec('git', ['checkout', mainBranch], { cwd: projectRoot });

const restoreBranch = async () => {
Expand Down Expand Up @@ -1029,10 +974,7 @@ export async function mergeTask(
}

export async function getBranchLog(worktreePath: string, baseBranch?: string): Promise<string> {
const mainBranch = await resolveComparisonRef(
worktreePath,
baseBranch ?? (await detectMainBranch(worktreePath).catch(() => 'HEAD')),
);
const mainBranch = baseBranch ?? (await detectMainBranch(worktreePath).catch(() => 'HEAD'));
try {
const { stdout } = await exec(
'git',
Expand All @@ -1053,10 +995,7 @@ export async function getChangedFilesFromBranch(
branchName: string,
baseBranch?: string,
): Promise<ChangedFile[]> {
const mainBranch = await resolveComparisonRef(
projectRoot,
baseBranch ?? (await detectMainBranch(projectRoot)),
);
const mainBranch = baseBranch ?? (await detectMainBranch(projectRoot));

let diffStr = '';
try {
Expand Down Expand Up @@ -1095,10 +1034,7 @@ export async function getFileDiffFromBranch(
filePath: string,
baseBranch?: string,
): Promise<FileDiffResult> {
const mainBranch = await resolveComparisonRef(
projectRoot,
baseBranch ?? (await detectMainBranch(projectRoot)),
);
const mainBranch = baseBranch ?? (await detectMainBranch(projectRoot));

let diff = '';
try {
Expand Down
6 changes: 6 additions & 0 deletions electron/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
removeWorktree,
isGitRepo,
getBranches,
checkoutBranch,
} from './git.js';
import { createTask, deleteTask } from './tasks.js';
import { listAgents } from './agents.js';
Expand Down Expand Up @@ -327,6 +328,11 @@ export function registerAllHandlers(win: BrowserWindow): void {
validatePath(args.projectRoot, 'projectRoot');
return getCurrentBranch(args.projectRoot);
});
ipcMain.handle(IPC.CheckoutBranch, (_e, args) => {
validatePath(args.projectRoot, 'projectRoot');
validateBranchName(args.branchName, 'branchName');
return checkoutBranch(args.projectRoot, args.branchName);
});
ipcMain.handle(IPC.CheckIsGitRepo, (_e, args) => {
validatePath(args.path, 'path');
return isGitRepo(args.path);
Expand Down
1 change: 1 addition & 0 deletions electron/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const ALLOWED_CHANNELS = new Set([
'rebase_task',
'get_main_branch',
'get_current_branch',
'checkout_branch',
'get_branches',
'check_is_git_repo',
// Persistence
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading