Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src/app/renderer/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
2 changes: 2 additions & 0 deletions src/app/renderer/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 目录未能删除。请关闭仍在使用它的进程,然后手动删除该目录。',
Expand Down
75 changes: 5 additions & 70 deletions src/contexts/integration/infrastructure/github/GitHubGh.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -26,75 +26,10 @@ export async function runCommand(
env?: NodeJS.ProcessEnv
} = {},
): Promise<CommandResult> {
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<typeof setTimeout> | 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,
})
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -26,50 +27,16 @@ export async function runGit(
): Promise<GitCommandResult> {
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<void> {
Expand Down
16 changes: 15 additions & 1 deletion src/contexts/worktree/infrastructure/git/GitWorktreeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -266,6 +266,19 @@ async function allocateWorktreePath({
throw new Error('Unable to allocate a unique worktree directory')
}

async function ensureGitRepoHasCommits(repoPath: string): Promise<void> {
// 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<GitWorktreeEntry> {
const normalizedRepoPath = input.repoPath.trim()
const normalizedWorktreesRoot = input.worktreesRoot.trim()
Expand All @@ -287,6 +300,7 @@ export async function createGitWorktree(input: CreateGitWorktreeInput): Promise<
}

await ensureGitRepo(normalizedRepoPath)
await ensureGitRepoHasCommits(normalizedRepoPath)

const worktreesSnapshot = await listGitWorktrees({ repoPath: normalizedRepoPath })

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
90 changes: 90 additions & 0 deletions src/platform/process/runCommand.ts
Original file line number Diff line number Diff line change
@@ -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<CommandResult> {
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<typeof setTimeout> | 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()
})
}
1 change: 1 addition & 0 deletions src/shared/contracts/dto/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions src/shared/errors/appError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ function createMessageMap(): Record<AppErrorCode, string> {
'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':
Expand Down
36 changes: 36 additions & 0 deletions tests/unit/contexts/gitWorktreeService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ async function createTempRepo(): Promise<string> {
return repoDir
}

async function createTempRepoWithoutCommit(): Promise<string> {
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 = ''

Expand Down Expand Up @@ -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 () => {
Expand Down
Loading
Loading