diff --git a/e2e/file-explorer-sidebar.spec.ts b/e2e/file-explorer-sidebar.spec.ts index ce953f6..83d6d59 100644 --- a/e2e/file-explorer-sidebar.spec.ts +++ b/e2e/file-explorer-sidebar.spec.ts @@ -351,6 +351,11 @@ test('git sidebar pane renders a nested tree and offers a push menu', async ({ await execFileAsync('git', ['config', 'user.email', 'terminay@example.com'], { cwd: workspace.rootDir }) await execFileAsync('git', ['add', '.'], { cwd: workspace.rootDir }) await execFileAsync('git', ['commit', '-m', 'initial'], { cwd: workspace.rootDir }) + const defaultBranch = ( + await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: workspace.rootDir }) + ).stdout.trim() + const featureBranch = 'feature/push-menu' + await execFileAsync('git', ['checkout', '-b', featureBranch], { cwd: workspace.rootDir }) await workspace.writeText('src/lib/util.ts', 'export const x = 2\n') await workspace.writeText('src/lib/new.ts', 'export const y = 3\n') @@ -390,10 +395,16 @@ test('git sidebar pane renders a nested tree and offers a push menu', async ({ await pushButton.click() const pushMenu = mainWindow.locator('.context-menu') + const pushMenuHeadings = pushMenu.locator('.context-menu__heading') + await expect(pushMenuHeadings.getByText(`Current Branch (${featureBranch})`, { exact: true })).toBeVisible() await expect(pushMenu.getByText('Push to current branch', { exact: true })).toBeVisible() - await expect(pushMenu.getByText('Push to current branch + PR')).toBeVisible() + await expect(pushMenu.getByText('Push to current branch + create PR')).toBeVisible() + await expect(pushMenuHeadings.getByText('New Branch', { exact: true })).toBeVisible() await expect(pushMenu.getByText('Push to new branch', { exact: true })).toBeVisible() - await expect(pushMenu.getByText('Push to new branch + PR')).toBeVisible() + await expect(pushMenu.getByText('Push to new branch + create PR')).toBeVisible() + await expect(pushMenuHeadings.getByText(`Default Branch (${defaultBranch})`, { exact: true })).toBeVisible() + await expect(pushMenu.getByText('Push to default branch')).toBeVisible() + await expect(pushMenu.locator('.context-menu__trailing')).toHaveCount(5) await mainWindow.keyboard.press('Escape') await expect(pushMenu).toHaveCount(0) diff --git a/electron/fileViewer/gitDiffService.ts b/electron/fileViewer/gitDiffService.ts index 6187889..c571cf3 100644 --- a/electron/fileViewer/gitDiffService.ts +++ b/electron/fileViewer/gitDiffService.ts @@ -152,6 +152,7 @@ export class GitDiffService { return { gitAvailable: false, repoRoot: null, + defaultBranch: null, worktrees: [], } } @@ -159,6 +160,7 @@ export class GitDiffService { return { gitAvailable: true, repoRoot: null, + defaultBranch: null, worktrees: [], } } @@ -167,6 +169,7 @@ export class GitDiffService { return { gitAvailable: true, repoRoot: null, + defaultBranch: null, worktrees: [], } } @@ -176,6 +179,7 @@ export class GitDiffService { }) const worktrees = parseWorktreeList(stdout, repoRoot) + const defaultBranch = await this.resolveDefaultBranch(workingDirectory) const withEntries = await Promise.all( worktrees.map(async (worktree): Promise => { @@ -234,6 +238,7 @@ export class GitDiffService { return { gitAvailable: true, repoRoot, + defaultBranch, worktrees: withEntries, } } @@ -278,6 +283,38 @@ export class GitDiffService { } } + private async resolveDefaultBranch(cwd: string): Promise { + try { + const result = await execFileAsync('git', ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], { + cwd, + }) + const remoteHead = result.stdout.trim() + if (remoteHead.startsWith('origin/')) { + return remoteHead.slice('origin/'.length) + } + if (remoteHead) { + return remoteHead + } + } catch {} + + for (const branch of ['main', 'master']) { + try { + await execFileAsync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], { cwd }) + return branch + } catch {} + } + + try { + const result = await execFileAsync('git', ['branch', '--format=%(refname:short)'], { cwd }) + return result.stdout + .split(/\r?\n/) + .map((branch) => branch.trim()) + .find((branch) => branch.length > 0) ?? null + } catch { + return null + } + } + private async getAheadOfMainCount(cwd: string): Promise { try { const result = await execFileAsync('git', ['rev-list', '--count', 'main..HEAD'], { cwd }) diff --git a/electron/quickPush/service.ts b/electron/quickPush/service.ts index 9c80c4f..5990abd 100644 --- a/electron/quickPush/service.ts +++ b/electron/quickPush/service.ts @@ -32,6 +32,7 @@ type PorcelainEntry = { type QuickPushContext = { repoRoot: string branch: string + defaultBranch: string changedFiles: string[] statusText: string diffText: string @@ -105,6 +106,36 @@ function actionNeedsPullRequest(action: QuickPushAction): boolean { return action === 'current-pr' || action === 'new-pr' } +async function resolveDefaultBranch(cwd: string): Promise { + try { + const result = await runGit(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd) + const remoteHead = result.trim() + if (remoteHead.startsWith('origin/')) { + return remoteHead.slice('origin/'.length) + } + if (remoteHead) { + return remoteHead + } + } catch {} + + for (const branch of ['main', 'master']) { + try { + await runGit(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], cwd) + return branch + } catch {} + } + + try { + const result = await runGit(['branch', '--format=%(refname:short)'], cwd) + return result + .split(/\r?\n/) + .map((branch) => branch.trim()) + .find((branch) => branch.length > 0) ?? null + } catch { + return null + } +} + /** * Extract the first balanced top-level JSON object from raw model output and * repair the mistakes models commonly make: code fences, raw control characters @@ -287,7 +318,7 @@ export function parseQuickPushPlan( return { branchName, pullRequest, commits, uncoveredFiles, warnings } } -function describeAction(action: QuickPushAction, branch: string): string { +function describeAction(action: QuickPushAction, branch: string, defaultBranch: string): string { switch (action) { case 'current': return `Commit all of the changes onto the current branch "${branch}". Do not set "branchName" or "pullRequest".` @@ -297,6 +328,8 @@ function describeAction(action: QuickPushAction, branch: string): string { return `Commit all of the changes onto a new, descriptively named branch. Set "branchName" to a short kebab-case branch name (you may include a "feat/" or "fix/" style prefix). Do not set "pullRequest".` case 'new-pr': return `Commit all of the changes onto a new, descriptively named branch, then open a pull request. Set "branchName" to a short kebab-case branch name and set "pullRequest" with a title and body.` + case 'default': + return `Commit all of the changes onto the default branch "${defaultBranch}". Do not set "branchName" or "pullRequest".` default: return 'Commit all of the changes.' } @@ -321,7 +354,10 @@ function buildPrompt(context: QuickPushContext, action: QuickPushAction): string '- Use the file paths EXACTLY as shown under "Changed files" (relative to the repo root).', '- Group related changes together; split unrelated changes into separate commits.', '', - `Task: ${describeAction(action, context.branch)}`, + `Task: ${describeAction(action, context.branch, context.defaultBranch)}`, + '', + `Current branch: ${context.branch}`, + `Default branch: ${context.defaultBranch}`, '', 'Changed files:', context.changedFiles.length > 0 ? context.changedFiles.map((file) => `- ${file}`).join('\n') : '(none)', @@ -400,6 +436,7 @@ async function gatherContext(cwd: string): Promise { if (!branch || branch === 'HEAD') { branch = 'the current branch' } + const defaultBranch = (await resolveDefaultBranch(repoRoot)) ?? 'main' const porcelain = await runGit( ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignored=no'], @@ -432,6 +469,7 @@ async function gatherContext(cwd: string): Promise { return { repoRoot, branch, + defaultBranch, changedFiles, statusText: statusTextRaw, diffText, @@ -608,6 +646,16 @@ export class QuickPushService { } await run(`Create branch ${branchName}`, ['checkout', '-b', branchName]) branch = branchName + } else if (request.action === 'default') { + const defaultBranch = await resolveDefaultBranch(repoRoot) + if (!defaultBranch) { + throw new Error('Unable to resolve the repository default branch.') + } + const currentBranch = (await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot)).trim() + if (currentBranch !== defaultBranch) { + await run(`Checkout default branch ${defaultBranch}`, ['checkout', defaultBranch]) + } + branch = defaultBranch } else { branch = (await runGit(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot)).trim() } diff --git a/src/App.css b/src/App.css index 71655ba..d27e91e 100644 --- a/src/App.css +++ b/src/App.css @@ -2855,6 +2855,16 @@ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); } +.context-menu__heading { + padding: 8px 10px 4px; + color: #8d96a6; + font-size: 11px; + font-weight: 600; + line-height: 1.2; + text-transform: uppercase; + white-space: nowrap; +} + .context-menu__item { all: unset; display: flex; diff --git a/src/App.tsx b/src/App.tsx index 52e91c4..bcd63d5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -105,6 +105,7 @@ import type { GitChangeEntry, GitWorktreeStatus, RemoteAccessStatus, + QuickPushAction, TerminalRecordingStartMetadata, TerminalRecordingState, WorktreePanelStatus, @@ -312,51 +313,139 @@ function formatDictationTranscriptForTerminal(text: string): string { return /[\r\n]/.test(text) ? formatBracketedPaste(text) : text; } -type GitPushAgentAction = 'current' | 'current-pr' | 'new' | 'new-pr'; +type GitPushAgentAction = QuickPushAction; +type GitPushAgentActionGroup = 'current' | 'new' | 'default'; const GIT_PUSH_AGENT_ACTIONS: Array<{ action: GitPushAgentAction; + group: GitPushAgentActionGroup; label: string; task: string; + quickPush?: boolean; }> = [ { action: 'current', + group: 'current', label: 'Push to current branch', task: 'Commit all of my current changes and push them to the current branch.', + quickPush: true, }, { action: 'current-pr', - label: 'Push to current branch + PR', + group: 'current', + label: 'Push to current branch + create PR', task: 'Commit all of my current changes, push them to the current branch, and open a pull request.', + quickPush: true, }, { action: 'new', + group: 'new', label: 'Push to new branch', task: 'Commit all of my current changes onto a new, descriptively named branch and push it.', + quickPush: true, }, { action: 'new-pr', - label: 'Push to new branch + PR', + group: 'new', + label: 'Push to new branch + create PR', task: 'Commit all of my current changes onto a new, descriptively named branch, push it, and open a pull request.', + quickPush: true, + }, + { + action: 'default', + group: 'default', + label: 'Push to default branch', + task: 'Commit all of my current changes onto the default branch and push it.', + quickPush: true, }, ]; type GitPushMenuTarget = { branch: string | null | undefined; cwd: string; + defaultBranch?: string | null; worktreePath?: string; }; +function formatGitPushBranchLabel(branch: string | null | undefined): string { + return branch?.trim() || 'unknown'; +} + +function getGitPushActionIcon(action: GitPushAgentAction): ReactNode { + if (action === 'new') { + return