diff --git a/CHANGELOG.md b/CHANGELOG.md index 3049789a..35a25059 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - OpenCode: Keep embedded agent terminals pinned to dark theme to avoid partial light/dark desynchronization. (#60) - Canvas: Stabilized auto input-mode detection to default to mouse semantics until high-confidence trackpad gestures are observed. (#47) - Worktree window: Fixed light theme text colors in the create/archive dialog. (#47) +- Worktree create: Detect repos without commits and show an actionable error instead of failing to create the worktree. (#120) - Task: Typing in the Task Name input no longer collapses Advanced Settings. (#48) - Improved canvas drag smoothness under heavy terminal output by throttling terminal screen writes during viewport interaction while keeping output live. (#50) - Normalized node resize and terminal selection drags while the canvas is zoomed. (#56) diff --git a/src/app/renderer/i18n/locales/en.ts b/src/app/renderer/i18n/locales/en.ts index 67cad049..73993324 100644 --- a/src/app/renderer/i18n/locales/en.ts +++ b/src/app/renderer/i18n/locales/en.ts @@ -448,6 +448,8 @@ export const en = { archiveNotes_other: '{{count}} notes', aiSuggestionFailed: 'AI suggestion failed: {{message}}', refreshFailed: 'Failed to load worktree info: {{message}}', + initialCommitRequired: + 'This repository has no commits yet. Create an initial commit (e.g. git commit --allow-empty -m "Initial commit") and try again.', archiveBranchDeleteFailed: 'Space archived, but the branch could not be deleted.', archiveDirectoryCleanupFailed: 'Space archived, but the worktree directory could not be removed. Close any process still using it, then delete the directory manually.', diff --git a/src/app/renderer/i18n/locales/zh-CN.ts b/src/app/renderer/i18n/locales/zh-CN.ts index 3c85db45..ddf43957 100644 --- a/src/app/renderer/i18n/locales/zh-CN.ts +++ b/src/app/renderer/i18n/locales/zh-CN.ts @@ -444,6 +444,8 @@ export const zhCN = { archiveNotes_other: '{{count}} 个便签', aiSuggestionFailed: 'AI 建议失败:{{message}}', refreshFailed: '加载 worktree 信息失败:{{message}}', + initialCommitRequired: + '该仓库还没有任何 commit。Git worktree 需要至少一个 commit 才能创建。请先创建首个 commit(例如:git commit --allow-empty -m "Initial commit"),然后重试。', archiveBranchDeleteFailed: 'Space 已归档,但分支未能删除。', archiveDirectoryCleanupFailed: 'Space 已归档,但 worktree 目录未能删除。请关闭仍在使用它的进程,然后手动删除该目录。', diff --git a/src/contexts/integration/infrastructure/github/GitHubGh.ts b/src/contexts/integration/infrastructure/github/GitHubGh.ts index fb3090be..9bc829aa 100644 --- a/src/contexts/integration/infrastructure/github/GitHubGh.ts +++ b/src/contexts/integration/infrastructure/github/GitHubGh.ts @@ -1,6 +1,6 @@ -import { spawn } from 'node:child_process' import process from 'node:process' import type { CommandResult } from './githubIntegration.shared' +import { runCommand as runProcessCommand } from '../../../../platform/process/runCommand' const DEFAULT_TIMEOUT_MS = 30_000 const HOST_CACHE_TTL_MS = 5 * 60_000 @@ -26,75 +26,10 @@ export async function runCommand( env?: NodeJS.ProcessEnv } = {}, ): Promise { - const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS - - return await new Promise((resolvePromise, reject) => { - const child = spawn(command, args, { - cwd, - env: options.env ?? process.env, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }) - - let stdout = '' - let stderr = '' - let settled = false - let timeoutHandle: ReturnType | null = null - - const finalize = (fn: () => void): void => { - if (settled) { - return - } - - settled = true - if (timeoutHandle) { - clearTimeout(timeoutHandle) - } - fn() - } - - timeoutHandle = setTimeout(() => { - try { - child.kill('SIGKILL') - } catch { - // Ignore kill errors (process may already be gone). - } - - finalize(() => { - reject(new Error(`${command} command timed out`)) - }) - }, timeoutMs) - - child.stdout.on('data', chunk => { - stdout += chunk.toString() - }) - - child.stderr.on('data', chunk => { - stderr += chunk.toString() - }) - - child.on('error', error => { - finalize(() => { - reject(error) - }) - }) - - child.on('close', exitCode => { - finalize(() => { - resolvePromise({ - exitCode: typeof exitCode === 'number' ? exitCode : 1, - stdout, - stderr, - }) - }) - }) - - const stdin = options.stdin - if (typeof stdin === 'string' && stdin.length > 0) { - child.stdin.write(stdin) - } - - child.stdin.end() + return await runProcessCommand(command, args, cwd, { + timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + stdin: options.stdin, + env: options.env ?? process.env, }) } diff --git a/src/contexts/worktree/infrastructure/git/GitWorktreeService.shared.ts b/src/contexts/worktree/infrastructure/git/GitWorktreeService.shared.ts index cc0a1885..b9700ff0 100644 --- a/src/contexts/worktree/infrastructure/git/GitWorktreeService.shared.ts +++ b/src/contexts/worktree/infrastructure/git/GitWorktreeService.shared.ts @@ -1,6 +1,7 @@ -import { spawn } from 'node:child_process' import { realpath } from 'node:fs/promises' import { basename, dirname, resolve } from 'node:path' +import process from 'node:process' +import { runCommand } from '../../../../platform/process/runCommand' const DEFAULT_GIT_TIMEOUT_MS = 30_000 @@ -26,50 +27,16 @@ export async function runGit( ): Promise { const timeoutMs = options.timeoutMs ?? DEFAULT_GIT_TIMEOUT_MS - return await new Promise((resolvePromise, reject) => { - const child = spawn('git', args, { - cwd, - env: process.env, - stdio: ['ignore', 'pipe', 'pipe'], - }) - - let stdout = '' - let stderr = '' - let timedOut = false - - const timeoutHandle = setTimeout(() => { - timedOut = true - child.kill('SIGKILL') - }, timeoutMs) - - child.stdout.on('data', chunk => { - stdout += chunk.toString() - }) - - child.stderr.on('data', chunk => { - stderr += chunk.toString() - }) - - child.on('error', error => { - clearTimeout(timeoutHandle) - reject(error) - }) - - child.on('close', exitCode => { - clearTimeout(timeoutHandle) - - if (timedOut) { - reject(new Error('git command timed out')) - return - } - - resolvePromise({ - exitCode: typeof exitCode === 'number' ? exitCode : 1, - stdout, - stderr, - }) - }) + const result = await runCommand('git', args, cwd, { + timeoutMs, + env: { + ...process.env, + // Prevent git from opening an interactive prompt (e.g. auth). + GIT_TERMINAL_PROMPT: '0', + }, }) + + return result } export async function ensureGitRepo(repoPath: string): Promise { diff --git a/src/contexts/worktree/infrastructure/git/GitWorktreeService.ts b/src/contexts/worktree/infrastructure/git/GitWorktreeService.ts index ba5503c5..d9e868f5 100644 --- a/src/contexts/worktree/infrastructure/git/GitWorktreeService.ts +++ b/src/contexts/worktree/infrastructure/git/GitWorktreeService.ts @@ -8,7 +8,7 @@ import { } from './GitWorktreeService.shared' import { mkdir, readdir, stat } from 'node:fs/promises' import { isAbsolute, resolve } from 'node:path' -import { createAppErrorDescriptor } from '../../../../shared/errors/appError' +import { createAppError, createAppErrorDescriptor } from '../../../../shared/errors/appError' import { cleanupResidualWorktreeDirectory, runGitWorktreeRemoveWithRetry, @@ -266,6 +266,19 @@ async function allocateWorktreePath({ throw new Error('Unable to allocate a unique worktree directory') } +async function ensureGitRepoHasCommits(repoPath: string): Promise { + // A repo can be a valid git working tree while still having an "unborn" HEAD (no commits yet). + // Worktree creation requires a commit-ish to check out, so surface an actionable error early. + const result = await runGit(['rev-parse', '--verify', '--quiet', 'HEAD'], repoPath) + if (result.exitCode === 0) { + return + } + + throw createAppError('worktree.repo_has_no_commits', { + debugMessage: 'git rev-parse --verify HEAD failed; repository has no commits yet', + }) +} + export async function createGitWorktree(input: CreateGitWorktreeInput): Promise { const normalizedRepoPath = input.repoPath.trim() const normalizedWorktreesRoot = input.worktreesRoot.trim() @@ -287,6 +300,7 @@ export async function createGitWorktree(input: CreateGitWorktreeInput): Promise< } await ensureGitRepo(normalizedRepoPath) + await ensureGitRepoHasCommits(normalizedRepoPath) const worktreesSnapshot = await listGitWorktrees({ repoPath: normalizedRepoPath }) diff --git a/src/contexts/worktree/presentation/renderer/windows/spaceWorktreeErrorMessage.ts b/src/contexts/worktree/presentation/renderer/windows/spaceWorktreeErrorMessage.ts index 6932a3be..e3b26e00 100644 --- a/src/contexts/worktree/presentation/renderer/windows/spaceWorktreeErrorMessage.ts +++ b/src/contexts/worktree/presentation/renderer/windows/spaceWorktreeErrorMessage.ts @@ -7,5 +7,9 @@ export function toSpaceWorktreeErrorMessage(error: unknown, t: TranslateFn): str return t('worktree.archiveUncommittedChangesWarning') } + if (error instanceof OpenCoveAppError && error.code === 'worktree.repo_has_no_commits') { + return t('worktree.initialCommitRequired') + } + return toErrorMessage(error) } diff --git a/src/platform/process/runCommand.ts b/src/platform/process/runCommand.ts new file mode 100644 index 00000000..c8639ed4 --- /dev/null +++ b/src/platform/process/runCommand.ts @@ -0,0 +1,90 @@ +import { spawn } from 'node:child_process' + +export interface CommandResult { + exitCode: number + stdout: string + stderr: string +} + +export async function runCommand( + command: string, + args: string[], + cwd: string, + options: { + timeoutMs?: number + stdin?: string + env?: NodeJS.ProcessEnv + windowsHide?: boolean + } = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? 30_000 + + return await new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + cwd, + env: options.env, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: options.windowsHide ?? true, + }) + + let stdout = '' + let stderr = '' + let settled = false + let timeoutHandle: ReturnType | null = null + + const finalize = (fn: () => void): void => { + if (settled) { + return + } + + settled = true + if (timeoutHandle) { + clearTimeout(timeoutHandle) + } + + fn() + } + + timeoutHandle = setTimeout(() => { + try { + child.kill('SIGKILL') + } catch { + // Ignore kill errors (process may already be gone). + } + + finalize(() => { + reject(new Error(`${command} command timed out`)) + }) + }, timeoutMs) + + child.stdout.on('data', chunk => { + stdout += chunk.toString() + }) + + child.stderr.on('data', chunk => { + stderr += chunk.toString() + }) + + child.on('error', error => { + finalize(() => { + reject(error) + }) + }) + + child.on('close', exitCode => { + finalize(() => { + resolvePromise({ + exitCode: typeof exitCode === 'number' ? exitCode : 1, + stdout, + stderr, + }) + }) + }) + + const stdin = options.stdin + if (typeof stdin === 'string' && stdin.length > 0) { + child.stdin.write(stdin) + } + child.stdin.end() + }) +} diff --git a/src/shared/contracts/dto/error.ts b/src/shared/contracts/dto/error.ts index ad7e5565..e650c925 100644 --- a/src/shared/contracts/dto/error.ts +++ b/src/shared/contracts/dto/error.ts @@ -28,6 +28,7 @@ export const APP_ERROR_CODES = [ 'worktree.list_worktrees_failed', 'worktree.status_summary_failed', 'worktree.get_default_branch_failed', + 'worktree.repo_has_no_commits', 'worktree.create_failed', 'worktree.remove_failed', 'worktree.remove_uncommitted_changes', diff --git a/src/shared/errors/appError.ts b/src/shared/errors/appError.ts index a61ad48b..51e2d447 100644 --- a/src/shared/errors/appError.ts +++ b/src/shared/errors/appError.ts @@ -33,6 +33,8 @@ function createMessageMap(): Record { 'worktree.list_worktrees_failed': 'Unable to load Git worktrees.', 'worktree.status_summary_failed': 'Unable to load Git status.', 'worktree.get_default_branch_failed': 'Unable to determine the default branch.', + 'worktree.repo_has_no_commits': + 'This Git repository has no commits yet. Create an initial commit to use worktrees.', 'worktree.create_failed': 'Unable to create the worktree.', 'worktree.remove_failed': 'Unable to archive the worktree.', 'worktree.remove_uncommitted_changes': diff --git a/tests/unit/contexts/gitWorktreeService.spec.ts b/tests/unit/contexts/gitWorktreeService.spec.ts index 851e81ff..1caefab4 100644 --- a/tests/unit/contexts/gitWorktreeService.spec.ts +++ b/tests/unit/contexts/gitWorktreeService.spec.ts @@ -37,6 +37,18 @@ async function createTempRepo(): Promise { return repoDir } +async function createTempRepoWithoutCommit(): Promise { + const repoDir = await mkdtemp(join(tmpdir(), 'cove-worktree-empty-')) + + await runGit(['init'], repoDir) + await runGit(['config', 'user.email', 'test@example.com'], repoDir) + await runGit(['config', 'user.name', 'OpenCove Test'], repoDir) + await runGit(['config', 'core.autocrlf', 'false'], repoDir) + await runGit(['config', 'core.safecrlf', 'false'], repoDir) + + return repoDir +} + describe('GitWorktreeService', () => { let repoDir = '' @@ -79,6 +91,30 @@ describe('GitWorktreeService', () => { GIT_WORKTREE_TEST_TIMEOUT_MS, ) + it( + 'refuses to create a worktree when the repo has no commits yet', + async () => { + repoDir = await createTempRepoWithoutCommit() + const canonicalRepoDir = await realpath(repoDir) + const worktreesRoot = join(repoDir, '.opencove', 'worktrees') + await mkdir(worktreesRoot, { recursive: true }) + + const { createGitWorktree } = + await import('../../../src/contexts/worktree/infrastructure/git/GitWorktreeService') + + await expect( + createGitWorktree({ + repoPath: canonicalRepoDir, + worktreesRoot, + branchMode: { kind: 'new', name: 'space-a', startPoint: 'HEAD' }, + }), + ).rejects.toMatchObject({ + code: 'worktree.repo_has_no_commits', + }) + }, + GIT_WORKTREE_TEST_TIMEOUT_MS, + ) + it( 'removes a created worktree and optionally deletes its branch', async () => { diff --git a/tests/unit/contexts/spaceWorktreeWindow.create-close.spec.tsx b/tests/unit/contexts/spaceWorktreeWindow.create-close.spec.tsx index 34247565..4b4a8c00 100644 --- a/tests/unit/contexts/spaceWorktreeWindow.create-close.spec.tsx +++ b/tests/unit/contexts/spaceWorktreeWindow.create-close.spec.tsx @@ -3,7 +3,13 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { DEFAULT_AGENT_SETTINGS } from '../../../src/contexts/settings/domain/agentSettings' import { SpaceWorktreeWindow } from '../../../src/contexts/workspace/presentation/renderer/components/workspaceCanvas/windows/SpaceWorktreeWindow' -import { clearWorktreeApi, createNodes, createSpaces } from './spaceWorktreeWindow.testUtils' +import { createAppError } from '../../../src/shared/errors/appError' +import { + clearWorktreeApi, + createNodes, + createSpaces, + installWorktreeApi, +} from './spaceWorktreeWindow.testUtils' describe('SpaceWorktreeWindow create flow', () => { afterEach(() => { @@ -100,4 +106,44 @@ describe('SpaceWorktreeWindow create flow', () => { expect(onClose).toHaveBeenCalledTimes(1) }) }) + + it('surfaces an actionable error when creating a worktree in a repo with no commits', async () => { + installWorktreeApi({ + create: vi.fn(async () => { + throw createAppError('worktree.repo_has_no_commits') + }), + }) + + const onClose = vi.fn() + const onUpdateSpaceDirectory = vi.fn() + + render( + ({ agentNodeIds: [], terminalNodeIds: [] })} + closeNodesById={async () => undefined} + />, + ) + + await waitFor(() => { + expect(screen.getByTestId('space-worktree-create')).not.toBeDisabled() + }) + + fireEvent.change(screen.getByTestId('space-worktree-branch-name'), { + target: { value: 'space/demo' }, + }) + fireEvent.click(screen.getByTestId('space-worktree-create')) + + expect(await screen.findByText(/no commits yet/i)).toBeVisible() + expect(onUpdateSpaceDirectory).not.toHaveBeenCalled() + expect(onClose).not.toHaveBeenCalled() + }) })