feat(source-control): commit action in sidebar panel (#1373)

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Alexander Saavedra <mralexsaavedra@gmail.com>
This commit is contained in:
Brennan Benson 2026-05-03 17:19:52 -07:00 committed by GitHub
parent f3c4adba94
commit 9802a7f59f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1179 additions and 226 deletions

View File

@ -10,6 +10,6 @@ export default defineConfig({
},
test: {
environment: 'node',
include: ['src/**/*.test.ts']
include: ['src/**/*.test.ts', 'src/**/*.test.tsx']
}
})

View File

@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn()
}))
vi.mock('./runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
gitExecFileAsyncBuffer: vi.fn()
}))
import { commitChanges } from './status'
describe('commitChanges', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
})
it('returns success when git commit completes', async () => {
gitExecFileAsyncMock.mockResolvedValue({ stdout: '[main abc123] message\n', stderr: '' })
const result = await commitChanges('/repo', 'feat: add commit action')
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['commit', '-m', 'feat: add commit action'], {
cwd: '/repo'
})
expect(result).toEqual({ success: true })
})
it('returns stderr when commit fails (e.g. pre-commit hook)', async () => {
gitExecFileAsyncMock.mockRejectedValue({
stderr: 'pre-commit hook failed: lint errors\n'
})
const result = await commitChanges('/repo', 'feat: commit with lint errors')
expect(result).toEqual({
success: false,
error: 'pre-commit hook failed: lint errors\n'
})
})
it('returns stdout when git writes to stdout (e.g. nothing to commit)', async () => {
gitExecFileAsyncMock.mockRejectedValue({
stdout: 'nothing to commit, working tree clean\n',
stderr: ''
})
const result = await commitChanges('/repo', 'feat: empty commit')
expect(result).toEqual({
success: false,
error: 'nothing to commit, working tree clean\n'
})
})
it('prefers stderr over stdout when both are present', async () => {
gitExecFileAsyncMock.mockRejectedValue({
stdout: 'some stdout output\n',
stderr: 'hook rejected\n'
})
const result = await commitChanges('/repo', 'feat: both channels')
expect(result).toEqual({
success: false,
error: 'hook rejected\n'
})
})
it('falls back to Error.message when stdout/stderr are empty', async () => {
gitExecFileAsyncMock.mockRejectedValue(new Error('spawn git ENOENT'))
const result = await commitChanges('/repo', 'feat: missing git')
expect(result).toEqual({
success: false,
error: 'spawn git ENOENT'
})
})
})

View File

@ -659,6 +659,34 @@ export async function unstageFile(worktreePath: string, filePath: string): Promi
await gitExecFileAsync(['restore', '--staged', '--', filePath], { cwd: worktreePath })
}
export async function commitChanges(
worktreePath: string,
message: string
): Promise<{ success: boolean; error?: string }> {
try {
await gitExecFileAsync(['commit', '-m', message], { cwd: worktreePath })
return { success: true }
} catch (error) {
// Why: surface whichever channel carries the useful message. Pre-commit/GPG
// hook failures write to stderr; "nothing to commit, working tree clean"
// writes to stdout. Try stderr first, fall back to stdout, then error.message.
const readStringField = (field: string): string | null => {
if (typeof error === 'object' && error && field in error) {
const v = (error as Record<string, unknown>)[field]
if (typeof v === 'string' && v.length > 0) {
return v
}
}
return null
}
const errorMessage =
readStringField('stderr') ??
readStringField('stdout') ??
(error instanceof Error ? error.message : 'Commit failed')
return { success: false, error: errorMessage }
}
}
/**
* Discard working tree changes for a file.
*/

View File

@ -13,6 +13,7 @@ const {
openMock,
realpathMock,
lstatMock,
commitChangesMock,
getStatusMock,
getDiffMock,
getBranchCompareMock,
@ -23,7 +24,8 @@ const {
bulkUnstageFilesMock,
discardChangesMock,
listWorktreesMock,
getSshFilesystemProviderMock
getSshFilesystemProviderMock,
getSshGitProviderMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
trashItemMock: vi.fn(),
@ -34,6 +36,7 @@ const {
openMock: vi.fn(),
realpathMock: vi.fn(),
lstatMock: vi.fn(),
commitChangesMock: vi.fn(),
getStatusMock: vi.fn(),
getDiffMock: vi.fn(),
getBranchCompareMock: vi.fn(),
@ -44,7 +47,8 @@ const {
bulkUnstageFilesMock: vi.fn(),
discardChangesMock: vi.fn(),
listWorktreesMock: vi.fn(),
getSshFilesystemProviderMock: vi.fn()
getSshFilesystemProviderMock: vi.fn(),
getSshGitProviderMock: vi.fn()
}))
vi.mock('electron', () => ({
@ -67,6 +71,7 @@ vi.mock('fs/promises', () => ({
}))
vi.mock('../git/status', () => ({
commitChanges: commitChangesMock,
getStatus: getStatusMock,
getDiff: getDiffMock,
getBranchCompare: getBranchCompareMock,
@ -87,7 +92,7 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: vi.fn().mockReturnValue(null)
getSshGitProvider: getSshGitProviderMock
}))
import { registerFilesystemHandlers } from './filesystem'
@ -148,6 +153,7 @@ describe('registerFilesystemHandlers', () => {
openMock,
realpathMock,
lstatMock,
commitChangesMock,
getStatusMock,
getDiffMock,
getBranchCompareMock,
@ -158,7 +164,8 @@ describe('registerFilesystemHandlers', () => {
bulkUnstageFilesMock,
discardChangesMock,
listWorktreesMock,
getSshFilesystemProviderMock
getSshFilesystemProviderMock,
getSshGitProviderMock
]) {
mock.mockReset()
}
@ -182,6 +189,7 @@ describe('registerFilesystemHandlers', () => {
}
])
trashItemMock.mockResolvedValue(undefined)
getSshGitProviderMock.mockReturnValue(null)
statMock.mockResolvedValue({ size: 10, isDirectory: () => false, mtimeMs: 123 })
openMock.mockResolvedValue({
read: vi.fn(async (buffer: Buffer) => {
@ -545,6 +553,95 @@ describe('registerFilesystemHandlers', () => {
expect(getBranchCompareMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, 'origin/main')
})
it('routes local git:commit through commitChanges and returns success', async () => {
commitChangesMock.mockResolvedValue({ success: true })
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:commit')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
message: 'feat: ship commit'
})
).resolves.toEqual({ success: true })
expect(commitChangesMock).toHaveBeenCalledWith(WORKTREE_FEATURE_PATH, 'feat: ship commit')
})
it('returns local commit hook failure payload from git:commit', async () => {
commitChangesMock.mockResolvedValue({ success: false, error: 'hook failed' })
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:commit')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
message: 'feat: ship commit'
})
).resolves.toEqual({ success: false, error: 'hook failed' })
})
it('routes ssh git:commit through the SSH provider instead of local commitChanges', async () => {
const sshCommitMock = vi.fn().mockResolvedValue({ success: true })
getSshGitProviderMock.mockReturnValue({ commit: sshCommitMock })
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:commit')!(null, {
worktreePath: '/remote/repo',
message: 'feat: remote commit',
connectionId: 'conn-1'
})
).resolves.toEqual({ success: true })
expect(sshCommitMock).toHaveBeenCalledWith('/remote/repo', 'feat: remote commit')
expect(commitChangesMock).not.toHaveBeenCalled()
})
it('rejects git:commit with empty message and does not call commitChanges', async () => {
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:commit')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
message: ''
})
).rejects.toThrow('Commit message is required')
expect(commitChangesMock).not.toHaveBeenCalled()
})
it('rejects git:commit with whitespace-only message and does not call commitChanges', async () => {
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:commit')!(null, {
worktreePath: WORKTREE_FEATURE_PATH,
message: ' '
})
).rejects.toThrow('Commit message is required')
expect(commitChangesMock).not.toHaveBeenCalled()
})
it('rejects git:commit with whitespace-only message before SSH dispatch', async () => {
const sshCommitMock = vi.fn().mockResolvedValue({ success: true })
getSshGitProviderMock.mockReturnValue({ commit: sshCommitMock })
registerFilesystemHandlers(store as never)
await expect(
handlers.get('git:commit')!(null, {
worktreePath: '/remote/repo',
message: '\n',
connectionId: 'conn-1'
})
).rejects.toThrow('Commit message is required')
expect(sshCommitMock).not.toHaveBeenCalled()
})
it('allows git operations on worktrees outside repo/workspace roots', async () => {
// Linked worktrees can live anywhere on disk (e.g. ~/.codex/worktrees/).
// As long as the path matches a worktree reported by `git worktree list`

View File

@ -28,6 +28,7 @@ import {
getStatus,
detectConflictOperation,
getDiff,
commitChanges,
stageFile,
unstageFile,
bulkStageFiles,
@ -505,6 +506,28 @@ export function registerFilesystemHandlers(store: Store): void {
}
)
ipcMain.handle(
'git:commit',
async (
_event,
args: { worktreePath: string; message: string; connectionId?: string }
): Promise<{ success: boolean; error?: string }> => {
// Why: validate at the IPC boundary so the renderer gets a clear error instead of an opaque execFile failure.
if (typeof args.message !== 'string' || args.message.trim().length === 0) {
throw new Error('Commit message is required')
}
if (args.connectionId) {
const provider = getSshGitProvider(args.connectionId)
if (!provider) {
throw new Error(`No git provider for connection "${args.connectionId}"`)
}
return provider.commit(args.worktreePath, args.message)
}
const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store)
return commitChanges(worktreePath, args.message)
}
)
ipcMain.handle(
'git:branchCompare',
async (

View File

@ -41,6 +41,19 @@ describe('SshGitProvider', () => {
expect(result).toEqual(statusResult)
})
it('commit sends git.commit request', async () => {
const commitResult = { success: true }
mux.request.mockResolvedValue(commitResult)
const result = await provider.commit('/home/user/repo', 'feat: add source control commit')
expect(mux.request).toHaveBeenCalledWith('git.commit', {
worktreePath: '/home/user/repo',
message: 'feat: add source control commit'
})
expect(result).toEqual(commitResult)
})
it('getDiff sends git.diff request', async () => {
const diffResult = { kind: 'text', originalContent: '', modifiedContent: 'hello' }
mux.request.mockResolvedValue(diffResult)

View File

@ -26,6 +26,16 @@ export class SshGitProvider implements IGitProvider {
return (await this.mux.request('git.status', { worktreePath })) as GitStatusResult
}
async commit(
worktreePath: string,
message: string
): Promise<{ success: boolean; error?: string }> {
return (await this.mux.request('git.commit', {
worktreePath,
message
})) as { success: boolean; error?: string }
}
async getDiff(
worktreePath: string,
filePath: string,

View File

@ -132,6 +132,7 @@ export type IFilesystemProvider = {
export type IGitProvider = {
getStatus(worktreePath: string): Promise<GitStatusResult>
commit(worktreePath: string, message: string): Promise<{ success: boolean; error?: string }>
getDiff(
worktreePath: string,
filePath: string,

View File

@ -19,7 +19,7 @@ import type {
GitBranchCompareResult,
GitConflictOperation,
GitDiffResult,
GitStatusEntry,
GitStatusResult,
GitHubAssignableUser,
GitHubPRFile,
GitHubPRFileContents,
@ -759,10 +759,7 @@ export type PreloadApi = {
onFsChanged: (callback: (payload: FsChangedPayload) => void) => () => void
}
git: {
status: (args: {
worktreePath: string
connectionId?: string
}) => Promise<{ entries: GitStatusEntry[] }>
status: (args: { worktreePath: string; connectionId?: string }) => Promise<GitStatusResult>
conflictOperation: (args: {
worktreePath: string
connectionId?: string
@ -791,6 +788,11 @@ export type PreloadApi = {
oldPath?: string
connectionId?: string
}) => Promise<GitDiffResult>
commit: (args: {
worktreePath: string
message: string
connectionId?: string
}) => Promise<{ success: boolean; error?: string }>
stage: (args: {
worktreePath: string
filePath: string

View File

@ -1272,6 +1272,11 @@ const api = {
oldPath?: string
connectionId?: string
}): Promise<unknown> => ipcRenderer.invoke('git:branchDiff', args),
commit: (args: {
worktreePath: string
message: string
connectionId?: string
}): Promise<{ success: boolean; error?: string }> => ipcRenderer.invoke('git:commit', args),
stage: (args: {
worktreePath: string
filePath: string

View File

@ -0,0 +1,126 @@
/**
* Tests for GitHandler commit and bulk-staging operations.
*
* Why: split from git-handler.test.ts to stay under the oxlint max-lines (300) limit.
*/
import { describe, expect, it, beforeEach, afterEach } from 'vitest'
import * as path from 'path'
import * as fs from 'fs/promises'
import { mkdtempSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { execFileSync } from 'child_process'
import { GitHandler } from './git-handler'
import { RelayContext } from './context'
import {
createMockDispatcher,
gitInit,
gitCommit,
type MockDispatcher,
type RelayDispatcher
} from './git-handler-test-setup'
describe('GitHandler — commit & staging', () => {
let dispatcher: MockDispatcher
let tmpDir: string
beforeEach(() => {
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-git-staging-'))
dispatcher = createMockDispatcher()
const ctx = new RelayContext()
ctx.registerRoot(tmpDir)
// eslint-disable-next-line no-new
new GitHandler(dispatcher as unknown as RelayDispatcher, ctx)
})
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true })
})
describe('commit', () => {
it('commits staged changes and returns success', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'file.txt'), 'content')
gitCommit(tmpDir, 'initial')
writeFileSync(path.join(tmpDir, 'file.txt'), 'changed')
execFileSync('git', ['add', 'file.txt'], { cwd: tmpDir, stdio: 'pipe' })
const result = (await dispatcher.callRequest('git.commit', {
worktreePath: tmpDir,
message: 'feat: relay commit'
})) as { success: boolean; error?: string }
expect(result).toEqual({ success: true })
const latestMessage = execFileSync('git', ['log', '-1', '--format=%s'], {
cwd: tmpDir,
encoding: 'utf-8'
}).trim()
expect(latestMessage).toBe('feat: relay commit')
})
// Why: covers the error-extraction path in commitChangesRelay
// (git-handler-worktree-ops.ts). Running `git commit` with nothing staged
// exits non-zero and writes a "nothing to commit" message; we assert the
// relay surfaces a non-empty error string so the UI can display it.
it('returns a non-empty error when the commit fails', async () => {
gitInit(tmpDir)
const result = (await dispatcher.callRequest('git.commit', {
worktreePath: tmpDir,
message: 'no changes'
})) as { success: boolean; error?: string }
expect(result.success).toBe(false)
expect(typeof result.error).toBe('string')
expect((result.error ?? '').length).toBeGreaterThan(0)
// Why: exact phrasing can vary across git versions, so match the
// stable substring "nothing" rather than the full "nothing to commit".
expect((result.error ?? '').toLowerCase()).toContain('nothing')
})
})
describe('bulkStage and bulkUnstage', () => {
it('stages multiple files', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'a.txt'), 'a')
writeFileSync(path.join(tmpDir, 'b.txt'), 'b')
gitCommit(tmpDir, 'initial')
writeFileSync(path.join(tmpDir, 'a.txt'), 'a-modified')
writeFileSync(path.join(tmpDir, 'b.txt'), 'b-modified')
await dispatcher.callRequest('git.bulkStage', {
worktreePath: tmpDir,
filePaths: ['a.txt', 'b.txt']
})
const output = execFileSync('git', ['diff', '--cached', '--name-only'], {
cwd: tmpDir,
encoding: 'utf-8'
})
expect(output).toContain('a.txt')
expect(output).toContain('b.txt')
})
it('unstages multiple files', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'a.txt'), 'a')
writeFileSync(path.join(tmpDir, 'b.txt'), 'b')
gitCommit(tmpDir, 'initial')
writeFileSync(path.join(tmpDir, 'a.txt'), 'changed')
writeFileSync(path.join(tmpDir, 'b.txt'), 'changed')
execFileSync('git', ['add', '.'], { cwd: tmpDir, stdio: 'pipe' })
await dispatcher.callRequest('git.bulkUnstage', {
worktreePath: tmpDir,
filePaths: ['a.txt', 'b.txt']
})
const output = execFileSync('git', ['diff', '--cached', '--name-only'], {
cwd: tmpDir,
encoding: 'utf-8'
})
expect(output.trim()).toBe('')
})
})
})

View File

@ -0,0 +1,77 @@
/**
* Status and conflict-detection operations extracted from git-handler.ts.
*
* Why: oxlint max-lines (300) requires splitting large files.
* These functions are pure data operations on git state no class coupling.
*/
import * as path from 'path'
import { existsSync } from 'fs'
import { readFile } from 'fs/promises'
import { parseStatusOutput, parseUnmergedEntry } from './git-handler-utils'
import type { GitExec } from './git-handler-ops'
export async function resolveGitDir(worktreePath: string): Promise<string> {
const dotGitPath = path.join(worktreePath, '.git')
try {
const contents = await readFile(dotGitPath, 'utf-8')
const match = contents.match(/^gitdir:\s*(.+)\s*$/m)
if (match) {
return path.resolve(worktreePath, match[1])
}
} catch {
// .git is a directory, not a file
}
return dotGitPath
}
export async function detectConflictOperation(worktreePath: string): Promise<string> {
const gitDir = await resolveGitDir(worktreePath)
try {
if (existsSync(path.join(gitDir, 'MERGE_HEAD'))) {
return 'merge'
}
if (
existsSync(path.join(gitDir, 'rebase-merge')) ||
existsSync(path.join(gitDir, 'rebase-apply'))
) {
return 'rebase'
}
if (existsSync(path.join(gitDir, 'CHERRY_PICK_HEAD'))) {
return 'cherry-pick'
}
} catch {
// fs error — treat as no conflict operation
}
return 'unknown'
}
export async function getStatusOp(
git: GitExec,
validatePath: (p: string) => void,
params: Record<string, unknown>
): Promise<{ entries: Record<string, unknown>[]; conflictOperation: string }> {
const worktreePath = params.worktreePath as string
validatePath(worktreePath)
const conflictOperation = await detectConflictOperation(worktreePath)
const entries: Record<string, unknown>[] = []
try {
const { stdout } = await git(
['status', '--porcelain=v2', '--untracked-files=all'],
worktreePath
)
const parsed = parseStatusOutput(stdout)
entries.push(...parsed.entries)
for (const uLine of parsed.unmergedLines) {
const entry = parseUnmergedEntry(worktreePath, uLine)
if (entry) {
entries.push(entry)
}
}
} catch {
// not a git repo or git not available
}
return { entries, conflictOperation }
}

View File

@ -0,0 +1,76 @@
/**
* Shared test utilities for git-handler tests.
*
* Why: oxlint max-lines (300) requires splitting large test suites.
* This module exports the mock dispatcher factory and git helpers
* so multiple test files can reuse them without duplication.
*/
import { vi } from 'vitest'
import { execFileSync } from 'child_process'
import type { RelayDispatcher } from './dispatcher'
// Why: declare an explicit type so the inferred return type of
// createMockDispatcher doesn't transitively reference `@vitest/spy`'s
// internal `Procedure` type (from `vi.fn(...)`). Without this annotation,
// TS2883 fires under `pnpm run tc:node` because the generated .d.ts would
// need to name a type that isn't portably resolvable from this module.
export type MockDispatcher = {
onRequest: (
method: string,
handler: (
params: Record<string, unknown>,
context: { isStale: () => boolean }
) => Promise<unknown>
) => void
onNotification: (method: string, handler: (params: Record<string, unknown>) => void) => void
notify: (method: string, params?: Record<string, unknown>) => void
_requestHandlers: Map<
string,
(params: Record<string, unknown>, context: { isStale: () => boolean }) => Promise<unknown>
>
callRequest(method: string, params?: Record<string, unknown>): Promise<unknown>
}
export function createMockDispatcher(): MockDispatcher {
const requestHandlers = new Map<
string,
(params: Record<string, unknown>, context: { isStale: () => boolean }) => Promise<unknown>
>()
return {
onRequest: vi.fn(
(
method: string,
handler: (
params: Record<string, unknown>,
context: { isStale: () => boolean }
) => Promise<unknown>
) => {
requestHandlers.set(method, handler)
}
),
onNotification: vi.fn(),
notify: vi.fn(),
_requestHandlers: requestHandlers,
async callRequest(method: string, params: Record<string, unknown> = {}) {
const handler = requestHandlers.get(method)
if (!handler) {
throw new Error(`No handler for ${method}`)
}
return handler(params, { isStale: () => false })
}
}
}
export function gitInit(dir: string): void {
execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' })
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'pipe' })
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'pipe' })
}
export function gitCommit(dir: string, message: string): void {
execFileSync('git', ['add', '.'], { cwd: dir, stdio: 'pipe' })
execFileSync('git', ['commit', '-m', message, '--allow-empty'], { cwd: dir, stdio: 'pipe' })
}
export type { RelayDispatcher }

View File

@ -0,0 +1,110 @@
/**
* Worktree management and commit operations for the relay git handler.
*
* Why: extracted from git-handler-ops.ts to keep all relay files under
* the oxlint max-lines (300) limit.
*/
import * as path from 'path'
import type { GitExec } from './git-handler-ops'
// ─── Worktree management ─────────────────────────────────────────────
export async function addWorktreeOp(
git: GitExec,
validatePath: (p: string) => void,
params: Record<string, unknown>
): Promise<void> {
const repoPath = params.repoPath as string
validatePath(repoPath)
const branchName = params.branchName as string
const targetDir = params.targetDir as string
validatePath(targetDir)
const base = params.base as string | undefined
const track = params.track as boolean | undefined
// Why: a branchName starting with '-' would be interpreted as a git flag,
// potentially changing the command's semantics (e.g. "--detach").
if (branchName.startsWith('-') || (base && base.startsWith('-'))) {
throw new Error('Branch name and base ref must not start with "-"')
}
const args = ['worktree', 'add']
if (track) {
args.push('--track')
}
args.push('-b', branchName, targetDir)
if (base) {
args.push(base)
}
await git(args, repoPath)
}
export async function removeWorktreeOp(
git: GitExec,
validatePath: (p: string) => void,
params: Record<string, unknown>
): Promise<void> {
const worktreePath = params.worktreePath as string
validatePath(worktreePath)
const force = params.force as boolean | undefined
let repoPath = worktreePath
try {
const { stdout } = await git(['rev-parse', '--git-common-dir'], worktreePath)
const commonDir = stdout.trim()
if (commonDir && commonDir !== '.git') {
repoPath = path.resolve(worktreePath, commonDir, '..')
}
} catch {
// fall through with worktreePath as repo
}
const args = ['worktree', 'remove']
if (force) {
args.push('--force')
}
args.push(worktreePath)
await git(args, repoPath)
await git(['worktree', 'prune'], repoPath)
}
// ─── Commit ──────────────────────────────────────────────────────────
export async function commitChangesRelay(
git: GitExec,
worktreePath: string,
message: string
): Promise<{ success: boolean; error?: string }> {
// Why: defense-in-depth. The IPC handler at src/main/ipc/filesystem.ts validates
// the message, but a relay caller (future automation, or an SSH client connecting
// to the relay directly) could bypass that path. Reject empty/whitespace messages
// here so we surface a clear error instead of git's opaque failure.
if (typeof message !== 'string' || message.trim().length === 0) {
return { success: false, error: 'Commit message is required' }
}
try {
await git(['commit', '-m', message], worktreePath)
return { success: true }
} catch (error) {
// Why: surface whichever channel carries the useful message. Pre-commit/GPG
// hook failures write to stderr; "nothing to commit, working tree clean"
// writes to stdout. Try stderr first, fall back to stdout, then error.message.
// Mirrors commitChanges in src/main/git/status.ts — keep the two paths in sync.
const readStringField = (field: string): string | null => {
if (typeof error === 'object' && error && field in error) {
const v = (error as Record<string, unknown>)[field]
if (typeof v === 'string' && v.length > 0) {
return v
}
}
return null
}
const errorMessage =
readStringField('stderr') ??
readStringField('stdout') ??
(error instanceof Error ? error.message : 'Commit failed')
return { success: false, error: errorMessage }
}
}

View File

@ -1,51 +1,21 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { describe, expect, it, beforeEach, afterEach } from 'vitest'
import { GitHandler } from './git-handler'
import { RelayContext } from './context'
import type { RelayDispatcher } from './dispatcher'
import * as fs from 'fs/promises'
import * as path from 'path'
import { mkdtempSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { execFileSync } from 'child_process'
function createMockDispatcher() {
const requestHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>()
const notificationHandlers = new Map<string, (params: Record<string, unknown>) => void>()
return {
onRequest: vi.fn(
(method: string, handler: (params: Record<string, unknown>) => Promise<unknown>) => {
requestHandlers.set(method, handler)
}
),
onNotification: vi.fn((method: string, handler: (params: Record<string, unknown>) => void) => {
notificationHandlers.set(method, handler)
}),
notify: vi.fn(),
_requestHandlers: requestHandlers,
async callRequest(method: string, params: Record<string, unknown> = {}) {
const handler = requestHandlers.get(method)
if (!handler) {
throw new Error(`No handler for ${method}`)
}
return handler(params)
}
}
}
function gitInit(dir: string): void {
execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' })
execFileSync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir, stdio: 'pipe' })
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: dir, stdio: 'pipe' })
}
function gitCommit(dir: string, message: string): void {
execFileSync('git', ['add', '.'], { cwd: dir, stdio: 'pipe' })
execFileSync('git', ['commit', '-m', message, '--allow-empty'], { cwd: dir, stdio: 'pipe' })
}
import {
createMockDispatcher,
gitInit,
gitCommit,
type MockDispatcher,
type RelayDispatcher
} from './git-handler-test-setup'
describe('GitHandler', () => {
let dispatcher: ReturnType<typeof createMockDispatcher>
let dispatcher: MockDispatcher
let tmpDir: string
beforeEach(() => {
@ -53,6 +23,7 @@ describe('GitHandler', () => {
dispatcher = createMockDispatcher()
const ctx = new RelayContext()
ctx.registerRoot(tmpDir)
// eslint-disable-next-line no-new
new GitHandler(dispatcher as unknown as RelayDispatcher, ctx)
})
@ -63,6 +34,7 @@ describe('GitHandler', () => {
it('registers all expected handlers', () => {
const methods = Array.from(dispatcher._requestHandlers.keys())
expect(methods).toContain('git.status')
expect(methods).toContain('git.commit')
expect(methods).toContain('git.diff')
expect(methods).toContain('git.stage')
expect(methods).toContain('git.unstage')
@ -75,6 +47,8 @@ describe('GitHandler', () => {
expect(methods).toContain('git.listWorktrees')
expect(methods).toContain('git.addWorktree')
expect(methods).toContain('git.removeWorktree')
expect(methods).toContain('git.exec')
expect(methods).toContain('git.isGitRepo')
})
describe('status', () => {
@ -284,50 +258,4 @@ describe('GitHandler', () => {
expect(result[0].isMainWorktree).toBe(true)
})
})
describe('bulkStage and bulkUnstage', () => {
it('stages multiple files', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'a.txt'), 'a')
writeFileSync(path.join(tmpDir, 'b.txt'), 'b')
gitCommit(tmpDir, 'initial')
writeFileSync(path.join(tmpDir, 'a.txt'), 'a-modified')
writeFileSync(path.join(tmpDir, 'b.txt'), 'b-modified')
await dispatcher.callRequest('git.bulkStage', {
worktreePath: tmpDir,
filePaths: ['a.txt', 'b.txt']
})
const output = execFileSync('git', ['diff', '--cached', '--name-only'], {
cwd: tmpDir,
encoding: 'utf-8'
})
expect(output).toContain('a.txt')
expect(output).toContain('b.txt')
})
it('unstages multiple files', async () => {
gitInit(tmpDir)
writeFileSync(path.join(tmpDir, 'a.txt'), 'a')
writeFileSync(path.join(tmpDir, 'b.txt'), 'b')
gitCommit(tmpDir, 'initial')
writeFileSync(path.join(tmpDir, 'a.txt'), 'changed')
writeFileSync(path.join(tmpDir, 'b.txt'), 'changed')
execFileSync('git', ['add', '.'], { cwd: tmpDir, stdio: 'pipe' })
await dispatcher.callRequest('git.bulkUnstage', {
worktreePath: tmpDir,
filePaths: ['a.txt', 'b.txt']
})
const output = execFileSync('git', ['diff', '--cached', '--name-only'], {
cwd: tmpDir,
encoding: 'utf-8'
})
expect(output.trim()).toBe('')
})
})
})

View File

@ -1,23 +1,19 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import { existsSync } from 'fs'
import { readFile, rm } from 'fs/promises'
import { rm } from 'fs/promises'
import * as path from 'path'
import type { RelayDispatcher } from './dispatcher'
import type { RelayContext } from './context'
import { expandTilde } from './context'
import {
parseStatusOutput,
parseUnmergedEntry,
parseBranchDiff,
parseWorktreeList
} from './git-handler-utils'
import { parseBranchDiff, parseWorktreeList } from './git-handler-utils'
import {
computeDiff,
branchCompare as branchCompareOp,
branchDiffEntries,
validateGitExecArgs
} from './git-handler-ops'
import { commitChangesRelay, addWorktreeOp, removeWorktreeOp } from './git-handler-worktree-ops'
import { detectConflictOperation, getStatusOp } from './git-handler-status-ops'
const execFileAsync = promisify(execFile)
const MAX_GIT_BUFFER = 10 * 1024 * 1024
@ -35,6 +31,7 @@ export class GitHandler {
private registerHandlers(): void {
this.dispatcher.onRequest('git.status', (p) => this.getStatus(p))
this.dispatcher.onRequest('git.commit', (p) => this.commit(p))
this.dispatcher.onRequest('git.diff', (p) => this.getDiff(p))
this.dispatcher.onRequest('git.stage', (p) => this.stage(p))
this.dispatcher.onRequest('git.unstage', (p) => this.unstage(p))
@ -73,66 +70,7 @@ export class GitHandler {
}
private async getStatus(params: Record<string, unknown>) {
const worktreePath = params.worktreePath as string
this.context.validatePath(worktreePath)
const conflictOperation = await this.detectConflictOperation(worktreePath)
const entries: Record<string, unknown>[] = []
try {
const { stdout } = await this.git(
['status', '--porcelain=v2', '--untracked-files=all'],
worktreePath
)
const parsed = parseStatusOutput(stdout)
entries.push(...parsed.entries)
for (const uLine of parsed.unmergedLines) {
const entry = parseUnmergedEntry(worktreePath, uLine)
if (entry) {
entries.push(entry)
}
}
} catch {
// Not a git repo or git not available
}
return { entries, conflictOperation }
}
private async detectConflictOperation(worktreePath: string): Promise<string> {
const gitDir = await this.resolveGitDir(worktreePath)
try {
if (existsSync(path.join(gitDir, 'MERGE_HEAD'))) {
return 'merge'
}
if (
existsSync(path.join(gitDir, 'rebase-merge')) ||
existsSync(path.join(gitDir, 'rebase-apply'))
) {
return 'rebase'
}
if (existsSync(path.join(gitDir, 'CHERRY_PICK_HEAD'))) {
return 'cherry-pick'
}
} catch {
// fs error
}
return 'unknown'
}
private async resolveGitDir(worktreePath: string): Promise<string> {
const dotGitPath = path.join(worktreePath, '.git')
try {
const contents = await readFile(dotGitPath, 'utf-8')
const match = contents.match(/^gitdir:\s*(.+)\s*$/m)
if (match) {
return path.resolve(worktreePath, match[1])
}
} catch {
// .git is a directory
}
return dotGitPath
return getStatusOp(this.git.bind(this), this.context.validatePath.bind(this.context), params)
}
private async getDiff(params: Record<string, unknown>) {
@ -162,6 +100,15 @@ export class GitHandler {
await this.git(['add', '--', filePath], worktreePath)
}
private async commit(
params: Record<string, unknown>
): Promise<{ success: boolean; error?: string }> {
const worktreePath = params.worktreePath as string
this.context.validatePath(worktreePath)
const message = params.message as string
return commitChangesRelay(this.git.bind(this), worktreePath, message)
}
private async unstage(params: Record<string, unknown>) {
const worktreePath = params.worktreePath as string
this.context.validatePath(worktreePath)
@ -224,7 +171,7 @@ export class GitHandler {
private async conflictOperation(params: Record<string, unknown>) {
const worktreePath = params.worktreePath as string
this.context.validatePath(worktreePath)
return this.detectConflictOperation(worktreePath)
return detectConflictOperation(worktreePath)
}
private async branchCompare(params: Record<string, unknown>) {
@ -301,54 +248,14 @@ export class GitHandler {
}
private async addWorktree(params: Record<string, unknown>) {
const repoPath = params.repoPath as string
this.context.validatePath(repoPath)
const branchName = params.branchName as string
const targetDir = params.targetDir as string
this.context.validatePath(targetDir)
const base = params.base as string | undefined
const track = params.track as boolean | undefined
// Why: a branchName starting with '-' would be interpreted as a git flag,
// potentially changing the command's semantics (e.g. "--detach").
if (branchName.startsWith('-') || (base && base.startsWith('-'))) {
throw new Error('Branch name and base ref must not start with "-"')
}
const args = ['worktree', 'add']
if (track) {
args.push('--track')
}
args.push('-b', branchName, targetDir)
if (base) {
args.push(base)
}
await this.git(args, repoPath)
return addWorktreeOp(this.git.bind(this), this.context.validatePath.bind(this.context), params)
}
private async removeWorktree(params: Record<string, unknown>) {
const worktreePath = params.worktreePath as string
this.context.validatePath(worktreePath)
const force = params.force as boolean | undefined
let repoPath = worktreePath
try {
const { stdout } = await this.git(['rev-parse', '--git-common-dir'], worktreePath)
const commonDir = stdout.trim()
if (commonDir && commonDir !== '.git') {
repoPath = path.resolve(worktreePath, commonDir, '..')
}
} catch {
// Fall through with worktreePath as repo
}
const args = ['worktree', 'remove']
if (force) {
args.push('--force')
}
args.push(worktreePath)
await this.git(args, repoPath)
await this.git(['worktree', 'prune'], repoPath)
return removeWorktreeOp(
this.git.bind(this),
this.context.validatePath.bind(this.context),
params
)
}
}

View File

@ -0,0 +1,188 @@
import { describe, expect, it, vi } from 'vitest'
import { CommitArea } from './SourceControl'
import { Button } from '@/components/ui/button'
type ReactElementLike = {
type: unknown
props: Record<string, unknown>
}
function visit(node: unknown, cb: (node: ReactElementLike) => void): void {
if (node == null || typeof node === 'string' || typeof node === 'number') {
return
}
if (Array.isArray(node)) {
node.forEach((entry) => visit(entry, cb))
return
}
const element = node as ReactElementLike
cb(element)
if (element.props?.children) {
visit(element.props.children, cb)
}
}
function findTextarea(node: unknown): ReactElementLike {
let found: ReactElementLike | null = null
visit(node, (entry) => {
if (entry.type === 'textarea') {
found = entry
}
})
if (!found) {
throw new Error('textarea not found')
}
return found
}
function findCommitButton(node: unknown): ReactElementLike {
let found: ReactElementLike | null = null
visit(node, (entry) => {
if (entry.type === Button) {
found = entry
}
})
if (!found) {
throw new Error('commit button not found')
}
return found
}
function hasText(node: unknown, text: string): boolean {
let found = false
visit(node, (entry) => {
const children = entry.props?.children
if (typeof children === 'string' && children.includes(text)) {
found = true
}
})
return found
}
function flushPromises(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 0))
}
const baseProps = {
stagedCount: 1,
hasUnresolvedConflicts: false,
commitMessage: 'feat: add commit area',
commitError: null as string | null,
isCommitting: false,
onCommitMessageChange: vi.fn(),
onCommitSuccess: vi.fn()
}
describe('CommitArea', () => {
it('disables commit button when no staged files', () => {
const element = CommitArea({ ...baseProps, stagedCount: 0 })
const button = findCommitButton(element)
expect(button.props.disabled).toBe(true)
})
it('disables commit button when message is empty', () => {
const element = CommitArea({ ...baseProps, commitMessage: ' ' })
const button = findCommitButton(element)
expect(button.props.disabled).toBe(true)
})
it('disables commit button when unresolved conflicts exist', () => {
const element = CommitArea({ ...baseProps, hasUnresolvedConflicts: true })
const button = findCommitButton(element)
expect(button.props.disabled).toBe(true)
})
it('enables commit button with staged files, message, and no conflicts', () => {
const element = CommitArea(baseProps)
const button = findCommitButton(element)
expect(button.props.disabled).toBe(false)
})
it('triggers commit when the button is clicked', () => {
const onCommitSuccess = vi.fn()
const element = CommitArea({ ...baseProps, onCommitSuccess })
const button = findCommitButton(element)
;(button.props.onClick as () => void)()
expect(onCommitSuccess).toHaveBeenCalledTimes(1)
})
it('clears message and keeps error hidden after successful commit lifecycle', async () => {
let commitMessage = 'feat: add commit area'
let commitError: string | null = null
let isCommitting = false
const runCommit = vi.fn(async () => {
isCommitting = true
commitError = null
await Promise.resolve()
commitMessage = ''
isCommitting = false
})
const render = () =>
CommitArea({
...baseProps,
commitMessage,
commitError,
isCommitting,
onCommitSuccess: () => {
void runCommit()
}
})
const button = findCommitButton(render())
;(button.props.onClick as () => void)()
await flushPromises()
const updated = render()
expect(findTextarea(updated).props.value).toBe('')
expect(hasText(updated, 'failed')).toBe(false)
expect(runCommit).toHaveBeenCalledTimes(1)
})
it('preserves message and shows error after failed commit lifecycle', async () => {
const initialMessage = 'feat: add commit area'
let commitMessage = initialMessage
let commitError: string | null = null
let isCommitting = false
const runCommit = vi.fn(async () => {
isCommitting = true
commitError = null
await Promise.resolve()
commitError = 'pre-commit hook failed'
isCommitting = false
})
const render = () =>
CommitArea({
...baseProps,
commitMessage,
commitError,
isCommitting,
onCommitSuccess: () => {
void runCommit()
}
})
const button = findCommitButton(render())
;(button.props.onClick as () => void)()
await flushPromises()
const updated = render()
expect(findTextarea(updated).props.value).toBe(initialMessage)
expect(hasText(updated, 'pre-commit hook failed')).toBe(true)
expect(runCommit).toHaveBeenCalledTimes(1)
})
it('locks the button while commit is in flight', () => {
const element = CommitArea({ ...baseProps, isCommitting: true })
const button = findCommitButton(element)
expect(button.props.disabled).toBe(true)
})
it('shows an inline error message when commit fails', () => {
const element = CommitArea({ ...baseProps, commitError: 'pre-commit hook failed' })
expect(hasText(element, 'pre-commit hook failed')).toBe(true)
})
})

View File

@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { readCommitDraftForWorktree, writeCommitDraftForWorktree } from './SourceControl'
describe('SourceControl commit drafts by worktree', () => {
it('returns an empty draft when the selected worktree has no message', () => {
expect(readCommitDraftForWorktree({}, 'wt-a')).toBe('')
})
it('restores each worktree draft when switching between worktrees', () => {
let drafts = {}
drafts = writeCommitDraftForWorktree(drafts, 'wt-a', 'feat: message for A')
expect(readCommitDraftForWorktree(drafts, 'wt-a')).toBe('feat: message for A')
drafts = writeCommitDraftForWorktree(drafts, 'wt-b', 'fix: message for B')
expect(readCommitDraftForWorktree(drafts, 'wt-b')).toBe('fix: message for B')
// Why: switching back must keep the prior draft for that worktree rather
// than leaking the active worktree's text into all worktree views.
expect(readCommitDraftForWorktree(drafts, 'wt-a')).toBe('feat: message for A')
})
})

View File

@ -25,7 +25,7 @@ import {
X
} from 'lucide-react'
import { useAppStore } from '@/store'
import { useActiveWorktree, useRepoById } from '@/store/selectors'
import { useActiveWorktree, useRepoById, useWorktreeMap } from '@/store/selectors'
import { detectLanguage } from '@/lib/language-detect'
import { basename, dirname, joinPath } from '@/lib/path'
import { cn } from '@/lib/utils'
@ -93,6 +93,23 @@ const SECTION_LABELS: Record<(typeof SECTION_ORDER)[number], string> = {
const BRANCH_REFRESH_INTERVAL_MS = 5000
type CommitDraftsByWorktree = Record<string, string>
export function readCommitDraftForWorktree(
drafts: CommitDraftsByWorktree,
worktreeId: string | null | undefined
): string {
return drafts[worktreeId ?? ''] ?? ''
}
export function writeCommitDraftForWorktree(
drafts: CommitDraftsByWorktree,
worktreeId: string,
value: string
): CommitDraftsByWorktree {
return { ...drafts, [worktreeId]: value }
}
const CONFLICT_KIND_LABELS: Record<GitConflictKind, string> = {
both_modified: 'Both modified',
both_added: 'Both added',
@ -105,8 +122,14 @@ const CONFLICT_KIND_LABELS: Record<GitConflictKind, string> = {
function SourceControlInner(): React.JSX.Element {
const sourceControlRef = useRef<HTMLDivElement>(null)
// Why: React setState is async, so a rapid double-click on the Commit
// button can both pass the isCommitting state guard before the disabled
// state re-renders. A ref flipped synchronously at the start of
// handleCommit gives us a true single-flight lock.
const commitInFlightRef = useRef<Record<string, boolean>>({})
const activeWorktree = useActiveWorktree()
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const worktreeMap = useWorktreeMap()
const rightSidebarTab = useAppStore((s) => s.rightSidebarTab)
const activeRepo = useRepoById(activeWorktree?.repoId ?? null)
const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree)
@ -118,6 +141,7 @@ function SourceControlInner(): React.JSX.Element {
const updateRepo = useAppStore((s) => s.updateRepo)
const beginGitBranchCompareRequest = useAppStore((s) => s.beginGitBranchCompareRequest)
const setGitBranchCompareResult = useAppStore((s) => s.setGitBranchCompareResult)
const setGitStatus = useAppStore((s) => s.setGitStatus)
const revealInExplorer = useAppStore((s) => s.revealInExplorer)
const trackConflictPath = useAppStore((s) => s.trackConflictPath)
const openDiff = useAppStore((s) => s.openDiff)
@ -181,7 +205,21 @@ function SourceControlInner(): React.JSX.Element {
// falsy until we have a real answer from the main process.
const [defaultBaseRef, setDefaultBaseRef] = useState<string | null>(null)
const [filterQuery, setFilterQuery] = useState('')
// Why: commit drafts/errors are worktree-scoped during the mounted session,
// so switching worktrees restores each draft instead of wiping it.
const [commitDrafts, setCommitDrafts] = useState<CommitDraftsByWorktree>({})
const [commitErrors, setCommitErrors] = useState<Record<string, string | null>>({})
// Why: keep commit-in-flight state per-worktree. A single boolean would be
// cleared when the user switched worktrees, letting them double-click Commit
// on worktree A after briefly navigating to B and back while A's original
// commit is still running.
const [commitInFlightByWorktree, setCommitInFlightByWorktree] = useState<Record<string, boolean>>(
{}
)
const isCommitting = commitInFlightByWorktree[activeWorktreeId ?? ''] ?? false
const filterInputRef = useRef<HTMLInputElement>(null)
const commitMessage = readCommitDraftForWorktree(commitDrafts, activeWorktreeId)
const commitError = commitErrors[activeWorktreeId ?? ''] ?? null
const isFolder = activeRepo ? isFolderRepo(activeRepo) : false
const worktreePath = activeWorktree?.path ?? null
@ -312,6 +350,48 @@ function SourceControlInner(): React.JSX.Element {
const [isExecutingBulk, setIsExecutingBulk] = useState(false)
const unresolvedConflicts = useMemo(
() => entries.filter((entry) => entry.conflictStatus === 'unresolved' && entry.conflictKind),
[entries]
)
const unresolvedConflictReviewEntries = useMemo(
() =>
unresolvedConflicts.map((entry) => ({
path: entry.path,
conflictKind: entry.conflictKind!
})),
[unresolvedConflicts]
)
// Why: orphaned draft/error/in-flight entries accumulate when worktrees are
// removed from the store (long sessions with many create/destroy cycles).
// Prune them so a deleted-then-reused worktree ID doesn't inherit stale
// state — especially commitInFlightRef, which would permanently disable
// Commit for that ID if left stuck at `true`.
useEffect(() => {
const pruneRecord = <T,>(prev: Record<string, T>): Record<string, T> => {
let changed = false
const next: Record<string, T> = {}
for (const key of Object.keys(prev)) {
if (worktreeMap.has(key)) {
next[key] = prev[key]
} else {
changed = true
}
}
return changed ? next : prev
}
setCommitDrafts((prev) => pruneRecord(prev))
setCommitErrors((prev) => pruneRecord(prev))
setCommitInFlightByWorktree((prev) => pruneRecord(prev))
// Refs don't need setState — mutate in place to drop stale keys.
for (const key of Object.keys(commitInFlightRef.current)) {
if (!worktreeMap.has(key)) {
delete commitInFlightRef.current[key]
}
}
}, [worktreeMap])
// Why: the sidebar no longer uses key={activeWorktreeId} to force a full
// remount on worktree switch (that caused an IPC storm on Windows).
// Instead, reset worktree-specific local state here so the previous
@ -329,8 +409,90 @@ function SourceControlInner(): React.JSX.Element {
// repos and back to re-trigger the resolver.
setFilterQuery('')
setIsExecutingBulk(false)
// Why: no reset for commit-in-flight state — it now lives in a per-worktree
// map, so it cannot leak across worktrees. Resetting here would actually
// clear in-flight state for the *incoming* worktree if the user is coming
// back to a worktree mid-commit, re-enabling the button while the commit
// still runs.
}, [activeWorktreeId])
const handleCommit = useCallback(async (): Promise<void> => {
if (!activeWorktreeId || !worktreePath) {
return
}
const message = commitMessage.trim()
if (!message || grouped.staged.length === 0 || unresolvedConflicts.length > 0) {
return
}
if (commitInFlightRef.current[activeWorktreeId]) {
return
}
commitInFlightRef.current[activeWorktreeId] = true
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
setCommitInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: true }))
setCommitErrors((prev) => ({ ...prev, [activeWorktreeId]: null }))
try {
const commitResult = await window.api.git.commit({
worktreePath,
message,
connectionId
})
if (!commitResult.success) {
setCommitErrors((prev) => ({
...prev,
[activeWorktreeId]: commitResult.error ?? 'Commit failed'
}))
return
}
// Why: the textarea stays enabled during the in-flight commit (only the
// button is disabled), so the user can keep typing after clicking Commit.
// Unconditionally clearing the draft here would silently discard those
// in-progress edits — the commit used the OLD `message` captured in this
// closure, so the dropped text would never have been committed either.
// Only clear when the current draft still matches what we committed.
setCommitDrafts((prev) => {
const current = prev[activeWorktreeId]
if (current !== undefined && current.trim() !== message) {
// User typed more after submit — preserve their in-progress edits.
return prev
}
return writeCommitDraftForWorktree(prev, activeWorktreeId, '')
})
setCommitErrors((prev) => ({ ...prev, [activeWorktreeId]: null }))
// Why: the commit already succeeded. If the follow-up status refresh fails
// (e.g., transient IPC error), log it but do NOT overwrite the cleared
// commitError with a misleading "Commit failed" — the existing status poll
// in useGitStatusPolling will refresh the UI shortly anyway.
try {
const status = await window.api.git.status({
worktreePath,
connectionId
})
setGitStatus(activeWorktreeId, status)
} catch (refreshError) {
console.error('[SourceControl] post-commit status refresh failed', refreshError)
}
} catch (error) {
setCommitErrors((prev) => ({
...prev,
[activeWorktreeId]: error instanceof Error ? error.message : 'Commit failed'
}))
} finally {
setCommitInFlightByWorktree((prev) => ({ ...prev, [activeWorktreeId]: false }))
commitInFlightRef.current[activeWorktreeId] = false
}
}, [
activeWorktreeId,
commitMessage,
grouped.staged.length,
unresolvedConflicts.length,
setGitStatus,
worktreePath
])
const handleOpenDiff = useCallback(
(entry: GitStatusEntry) => {
if (!activeWorktreeId || !worktreePath) {
@ -455,19 +617,6 @@ function SourceControlInner(): React.JSX.Element {
}
}, [worktreePath, bulkUnstagePaths, clearSelection, activeWorktreeId])
const unresolvedConflicts = useMemo(
() => entries.filter((entry) => entry.conflictStatus === 'unresolved' && entry.conflictKind),
[entries]
)
const unresolvedConflictReviewEntries = useMemo(
() =>
unresolvedConflicts.map((entry) => ({
path: entry.path,
conflictKind: entry.conflictKind!
})),
[unresolvedConflicts]
)
const refreshBranchCompare = useCallback(async () => {
if (!activeWorktreeId || !worktreePath || !effectiveBaseRef || isFolder) {
return
@ -717,8 +866,12 @@ function SourceControlInner(): React.JSX.Element {
{/* Why: Diff-comments live on the worktree and apply across every diff
view the user opens. The header row expands inline to show per-file
comment previews plus a Copy-all action so the user can hand the
set off to whichever tool they want without leaving the sidebar. */}
{activeWorktreeId && worktreePath && (
set off to whichever tool they want without leaving the sidebar.
Hidden when count is 0: notes are created from the diff view, so
an empty Notes shelf in the sidebar is pure chrome it adds a
border, a row of space, and an expand control that only reveals
a redirect hint. */}
{activeWorktreeId && worktreePath && diffCommentCount > 0 && (
<div className="border-b border-border">
<div className="flex items-center gap-1 pl-3 pr-2 py-1.5">
<button
@ -856,6 +1009,27 @@ function SourceControlInner(): React.JSX.Element {
/>
)}
{(scope === 'all' || scope === 'uncommitted') && (
<CommitArea
stagedCount={grouped.staged.length}
hasUnresolvedConflicts={unresolvedConflicts.length > 0}
commitMessage={commitMessage}
commitError={commitError}
isCommitting={isCommitting}
onCommitMessageChange={(value) => {
if (!activeWorktreeId) {
return
}
setCommitDrafts((prev) =>
writeCommitDraftForWorktree(prev, activeWorktreeId, value)
)
}}
onCommitSuccess={() => {
void handleCommit()
}}
/>
)}
{(scope === 'all' || scope === 'uncommitted') && hasFilteredUncommittedEntries && (
<>
{SECTION_ORDER.map((area) => {
@ -1031,6 +1205,91 @@ function SourceControlInner(): React.JSX.Element {
const SourceControl = React.memo(SourceControlInner)
export default SourceControl
type CommitAreaProps = {
stagedCount: number
hasUnresolvedConflicts: boolean
commitMessage: string
commitError: string | null
isCommitting: boolean
onCommitMessageChange: (message: string) => void
onCommitSuccess: () => void
}
export function CommitArea({
stagedCount,
hasUnresolvedConflicts,
commitMessage,
commitError,
isCommitting,
onCommitMessageChange,
onCommitSuccess
}: CommitAreaProps): React.JSX.Element {
// Why: cap at 12 rows so a pasted multi-page commit message doesn't push
// the Commit button off-screen. The textarea keeps `resize-none` (matching
// the existing style) — the browser scrolls internally past 12 rows.
const rows = Math.min(12, Math.max(2, commitMessage.split('\n').length))
const hasMessage = commitMessage.trim().length > 0
const isCommitDisabled =
isCommitting || !hasMessage || stagedCount === 0 || hasUnresolvedConflicts
// Why: when the button is disabled, the title surfaces the reason so the
// user doesn't have to guess why Commit is greyed out. Part-2 may extend
// this into a split button (primary action + dropdown for Push / Sync /
// Commit & Push); the label stays as a plain "Commit" here so the shape
// lines up cleanly with the forthcoming "Remote Updates" section beneath it.
let disabledReason: string | undefined
if (isCommitting) {
disabledReason = 'Commit in progress…'
} else if (hasUnresolvedConflicts) {
disabledReason = 'Resolve conflicts before committing'
} else if (stagedCount === 0) {
disabledReason = 'Stage at least one file to commit'
} else if (!hasMessage) {
disabledReason = 'Enter a commit message to commit'
}
return (
<div className="px-3 pb-2">
<textarea
rows={rows}
value={commitMessage}
onChange={(e) => onCommitMessageChange(e.target.value)}
placeholder="Message"
aria-label="Commit message"
aria-describedby={commitError ? 'commit-area-error' : undefined}
className="mt-0.5 w-full resize-none rounded-md border border-border bg-background px-2 py-1.5 text-xs text-foreground outline-none placeholder:text-muted-foreground/70 focus-visible:ring-1 focus-visible:ring-ring"
/>
{/* Why: match the "Squash and merge" button in PRActions
(size="xs", px-3 text-[11px]) so the sidebar has a consistent
action-button shape across Source Control and Checks. */}
<Button
type="button"
size="xs"
disabled={isCommitDisabled}
onClick={() => onCommitSuccess()}
className="w-full px-3 text-[11px]"
title={disabledReason}
>
{isCommitting && <RefreshCw className="size-3.5 animate-spin" />}
Commit
</Button>
{commitError && (
// Why: role="alert" + aria-live="polite" lets screen readers announce
// commit failures; the id ties the message to the textarea via
// aria-describedby so assistive tech associates the two.
<p
id="commit-area-error"
role="alert"
aria-live="polite"
className="mt-1 text-[11px] text-destructive"
>
{commitError}
</p>
)}
</div>
)
}
function CompareSummary({
summary,
onChangeBaseRef,