273 lines
10 KiB
TypeScript
273 lines
10 KiB
TypeScript
import type { BrowserWindow } from 'electron'
|
|
import { ipcMain } from 'electron'
|
|
import { execFileSync } from 'child_process'
|
|
import { rm } from 'fs/promises'
|
|
import type { Store } from '../persistence'
|
|
import type {
|
|
CreateWorktreeArgs,
|
|
CreateWorktreeResult,
|
|
Worktree,
|
|
WorktreeMeta
|
|
} from '../../shared/types'
|
|
import { getPRForBranch } from '../github/client'
|
|
import { listWorktrees, addWorktree, removeWorktree } from '../git/worktree'
|
|
import { getGitUsername, getDefaultBaseRef, getBranchConflictKind } from '../git/repo'
|
|
import {
|
|
createSetupRunnerScript,
|
|
getEffectiveHooks,
|
|
loadHooks,
|
|
runHook,
|
|
hasHooksFile,
|
|
shouldRunSetupForCreate
|
|
} from '../hooks'
|
|
import {
|
|
sanitizeWorktreeName,
|
|
computeBranchName,
|
|
computeWorktreePath,
|
|
ensurePathWithinWorkspace,
|
|
shouldSetDisplayName,
|
|
mergeWorktree,
|
|
parseWorktreeId,
|
|
areWorktreePathsEqual,
|
|
formatWorktreeRemovalError,
|
|
isOrphanedWorktreeError
|
|
} from './worktree-logic'
|
|
import { rebuildAuthorizedRootsCache, ensureAuthorizedRootsCache } from './filesystem-auth'
|
|
|
|
export function registerWorktreeHandlers(mainWindow: BrowserWindow, store: Store): void {
|
|
// Remove any previously registered handlers so we can re-register them
|
|
// (e.g. when macOS re-activates the app and creates a new window).
|
|
ipcMain.removeHandler('worktrees:listAll')
|
|
ipcMain.removeHandler('worktrees:list')
|
|
ipcMain.removeHandler('worktrees:create')
|
|
ipcMain.removeHandler('worktrees:remove')
|
|
ipcMain.removeHandler('worktrees:updateMeta')
|
|
ipcMain.removeHandler('hooks:check')
|
|
|
|
ipcMain.handle('worktrees:listAll', async () => {
|
|
// Why: use ensureAuthorizedRootsCache (not rebuild) to avoid redundantly
|
|
// listing git worktrees when the cache is already fresh — the handler
|
|
// itself calls listWorktrees for every repo below.
|
|
await ensureAuthorizedRootsCache(store)
|
|
const repos = store.getRepos()
|
|
const allWorktrees: Worktree[] = []
|
|
|
|
for (const repo of repos) {
|
|
const gitWorktrees = await listWorktrees(repo.path)
|
|
for (const gw of gitWorktrees) {
|
|
const worktreeId = `${repo.id}::${gw.path}`
|
|
const meta = store.getWorktreeMeta(worktreeId)
|
|
allWorktrees.push(mergeWorktree(repo.id, gw, meta))
|
|
}
|
|
}
|
|
|
|
return allWorktrees
|
|
})
|
|
|
|
ipcMain.handle('worktrees:list', async (_event, args: { repoId: string }) => {
|
|
// Why: use ensureAuthorizedRootsCache (not rebuild) to avoid redundantly
|
|
// listing git worktrees when the cache is already fresh — the handler
|
|
// itself calls listWorktrees below.
|
|
await ensureAuthorizedRootsCache(store)
|
|
const repo = store.getRepo(args.repoId)
|
|
if (!repo) {
|
|
return []
|
|
}
|
|
|
|
const gitWorktrees = await listWorktrees(repo.path)
|
|
return gitWorktrees.map((gw) => {
|
|
const worktreeId = `${repo.id}::${gw.path}`
|
|
const meta = store.getWorktreeMeta(worktreeId)
|
|
return mergeWorktree(repo.id, gw, meta)
|
|
})
|
|
})
|
|
|
|
ipcMain.handle(
|
|
'worktrees:create',
|
|
async (_event, args: CreateWorktreeArgs): Promise<CreateWorktreeResult> => {
|
|
const repo = store.getRepo(args.repoId)
|
|
if (!repo) {
|
|
throw new Error(`Repo not found: ${args.repoId}`)
|
|
}
|
|
|
|
const settings = store.getSettings()
|
|
|
|
const requestedName = args.name
|
|
const sanitizedName = sanitizeWorktreeName(args.name)
|
|
|
|
// Compute branch name with prefix
|
|
const username = getGitUsername(repo.path)
|
|
const branchName = computeBranchName(sanitizedName, settings, username)
|
|
|
|
const branchConflictKind = await getBranchConflictKind(repo.path, branchName)
|
|
if (branchConflictKind) {
|
|
throw new Error(
|
|
`Branch "${branchName}" already exists ${branchConflictKind === 'local' ? 'locally' : 'on a remote'}. Pick a different worktree name.`
|
|
)
|
|
}
|
|
|
|
// Why: the UI resolves PR status by branch name alone. Reusing a historical
|
|
// PR head name would make a fresh worktree inherit that old merged/closed PR
|
|
// immediately, so we reject the name instead of silently suffixing it.
|
|
// The lookup is best-effort — don't block creation if GitHub is unreachable.
|
|
let existingPR: Awaited<ReturnType<typeof getPRForBranch>> | null = null
|
|
try {
|
|
existingPR = await getPRForBranch(repo.path, branchName)
|
|
} catch {
|
|
// GitHub API may be unreachable, rate-limited, or token missing
|
|
}
|
|
if (existingPR) {
|
|
throw new Error(
|
|
`Branch "${branchName}" already has PR #${existingPR.number}. Pick a different worktree name.`
|
|
)
|
|
}
|
|
|
|
// Compute worktree path
|
|
let worktreePath = computeWorktreePath(sanitizedName, repo.path, settings)
|
|
worktreePath = ensurePathWithinWorkspace(worktreePath, settings.workspaceDir)
|
|
|
|
// Determine base branch
|
|
const baseBranch = args.baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path)
|
|
const setupScript = getEffectiveHooks(repo)?.scripts.setup
|
|
// Why: `ask` is a pre-create choice gate, not a post-create side effect.
|
|
// Resolve it before mutating git state so missing UI input cannot strand
|
|
// a real worktree on disk while the renderer reports "create failed".
|
|
const shouldLaunchSetup = setupScript
|
|
? shouldRunSetupForCreate(repo, args.setupDecision)
|
|
: false
|
|
|
|
// Fetch latest from remote so the worktree starts with up-to-date content
|
|
const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin'
|
|
try {
|
|
execFileSync('git', ['fetch', remote], {
|
|
cwd: repo.path,
|
|
encoding: 'utf-8',
|
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
})
|
|
} catch {
|
|
// Fetch is best-effort — don't block worktree creation if offline
|
|
}
|
|
|
|
addWorktree(repo.path, worktreePath, branchName, baseBranch)
|
|
|
|
// Re-list to get the freshly created worktree info
|
|
const gitWorktrees = await listWorktrees(repo.path)
|
|
const created = gitWorktrees.find((gw) => areWorktreePathsEqual(gw.path, worktreePath))
|
|
if (!created) {
|
|
throw new Error('Worktree created but not found in listing')
|
|
}
|
|
|
|
const worktreeId = `${repo.id}::${created.path}`
|
|
const metaUpdates: Partial<WorktreeMeta> = {
|
|
// Stamp activity so the worktree sorts into its final position
|
|
// immediately — prevents scroll-to-reveal racing with a later
|
|
// bumpWorktreeActivity that would re-sort the list.
|
|
lastActivityAt: Date.now(),
|
|
...(shouldSetDisplayName(requestedName, branchName, sanitizedName)
|
|
? { displayName: requestedName }
|
|
: {})
|
|
}
|
|
const meta = store.setWorktreeMeta(worktreeId, metaUpdates)
|
|
const worktree = mergeWorktree(repo.id, created, meta)
|
|
await rebuildAuthorizedRootsCache(store)
|
|
|
|
let setup: CreateWorktreeResult['setup']
|
|
if (setupScript && shouldLaunchSetup) {
|
|
try {
|
|
// Why: setup now runs in a visible terminal owned by the renderer so users
|
|
// can inspect failures, answer prompts, and rerun it. The main process only
|
|
// resolves policy and writes the runner script; it must not execute setup
|
|
// itself anymore or we would reintroduce the hidden background-hook behavior.
|
|
//
|
|
// Why: the git worktree already exists at this point. If runner generation
|
|
// fails, surfacing the error as a hard create failure would lie to the UI
|
|
// about the underlying git state and strand a real worktree on disk.
|
|
// Degrade to "created without setup launch" instead.
|
|
setup = createSetupRunnerScript(repo, worktreePath, setupScript)
|
|
} catch (error) {
|
|
console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error)
|
|
}
|
|
}
|
|
|
|
notifyWorktreesChanged(mainWindow, repo.id)
|
|
return {
|
|
worktree,
|
|
...(setup ? { setup } : {})
|
|
}
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'worktrees:remove',
|
|
async (_event, args: { worktreeId: string; force?: boolean }) => {
|
|
const { repoId, worktreePath } = parseWorktreeId(args.worktreeId)
|
|
const repo = store.getRepo(repoId)
|
|
if (!repo) {
|
|
throw new Error(`Repo not found: ${repoId}`)
|
|
}
|
|
|
|
// Run archive hook before removal
|
|
const hooks = getEffectiveHooks(repo)
|
|
if (hooks?.scripts.archive) {
|
|
const result = await runHook('archive', worktreePath, repo)
|
|
if (!result.success) {
|
|
console.error(`[hooks] archive hook failed for ${worktreePath}:`, result.output)
|
|
}
|
|
}
|
|
|
|
try {
|
|
await removeWorktree(repo.path, worktreePath, args.force ?? false)
|
|
} catch (error) {
|
|
// If git no longer tracks this worktree, clean up the directory and metadata
|
|
if (isOrphanedWorktreeError(error)) {
|
|
console.warn(`[worktrees] Orphaned worktree detected at ${worktreePath}, cleaning up`)
|
|
await rm(worktreePath, { recursive: true, force: true }).catch(() => {})
|
|
store.removeWorktreeMeta(args.worktreeId)
|
|
await rebuildAuthorizedRootsCache(store)
|
|
notifyWorktreesChanged(mainWindow, repoId)
|
|
return
|
|
}
|
|
throw new Error(formatWorktreeRemovalError(error, worktreePath, args.force ?? false))
|
|
}
|
|
store.removeWorktreeMeta(args.worktreeId)
|
|
await rebuildAuthorizedRootsCache(store)
|
|
|
|
notifyWorktreesChanged(mainWindow, repoId)
|
|
}
|
|
)
|
|
|
|
ipcMain.handle(
|
|
'worktrees:updateMeta',
|
|
(_event, args: { worktreeId: string; updates: Partial<WorktreeMeta> }) => {
|
|
const meta = store.setWorktreeMeta(args.worktreeId, args.updates)
|
|
// Do NOT call notifyWorktreesChanged here. The renderer applies meta
|
|
// updates optimistically before calling this IPC, so a notification
|
|
// would trigger a redundant fetchWorktrees round-trip that bumps
|
|
// sortEpoch and reorders the sidebar — the exact bug PR #209 tried
|
|
// to fix (clicking a card would clear isUnread → updateMeta →
|
|
// worktrees:changed → fetchWorktrees → sortEpoch++ → re-sort).
|
|
return meta
|
|
}
|
|
)
|
|
|
|
ipcMain.handle('hooks:check', (_event, args: { repoId: string }) => {
|
|
const repo = store.getRepo(args.repoId)
|
|
if (!repo) {
|
|
return { hasHooks: false, hooks: null }
|
|
}
|
|
|
|
const has = hasHooksFile(repo.path)
|
|
const hooks = has ? loadHooks(repo.path) : null
|
|
return {
|
|
hasHooks: has,
|
|
hooks
|
|
}
|
|
})
|
|
}
|
|
|
|
function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void {
|
|
if (!mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send('worktrees:changed', { repoId })
|
|
}
|
|
}
|