feat(sidebar): add "create new project" option to Add Repo dialog (#929)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Kiran Annadata <kiranannadata@macbookair.mynetworksettings.com>
Co-authored-by: brennanb2025 <brennankbenson@gmail.com>
This commit is contained in:
kiranannadatha8
2026-05-04 00:08:46 -07:00
committed by GitHub
co-authored by Claude Opus 4.7 Orca Kiran Annadata brennanb2025
parent 375a3e6351
commit 98b484eb9b
12 changed files with 1146 additions and 103 deletions
+379
View File
@@ -0,0 +1,379 @@
/**
* Unit tests for repos:create (orca#763).
*
* Pins the invariants that matter here:
* - Name validation catches empty/slash/./.. before any fs I/O.
* - Empty pre-existing directories are accepted; non-empty ones are not.
* - Only directories we create ourselves are removed on rollback — a folder
* the user picked must survive a failure so they can retry.
* - Git repos get an empty initial commit; without it, HEAD has no branch.
*/
import { describe, expect, it, vi, beforeEach } from 'vitest'
const {
handleMock,
removeHandlerMock,
mockStore,
mkdirMock,
accessMock,
readdirMock,
rmMock,
gitExecFileAsyncMock,
rebuildAuthorizedRootsCacheMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
removeHandlerMock: vi.fn(),
mockStore: {
getRepos: vi.fn().mockReturnValue([]),
addRepo: vi.fn(),
removeRepo: vi.fn(),
getRepo: vi.fn(),
updateRepo: vi.fn()
},
mkdirMock: vi.fn(),
accessMock: vi.fn(),
readdirMock: vi.fn(),
rmMock: vi.fn(),
gitExecFileAsyncMock: vi.fn(),
rebuildAuthorizedRootsCacheMock: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('electron', () => ({
dialog: { showOpenDialog: vi.fn() },
ipcMain: {
handle: handleMock,
removeHandler: removeHandlerMock
}
}))
vi.mock('fs/promises', () => ({
mkdir: mkdirMock,
access: accessMock,
readdir: readdirMock,
rm: rmMock
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
gitSpawn: vi.fn()
}))
vi.mock('../git/repo', () => ({
isGitRepo: vi.fn().mockReturnValue(true),
getGitUsername: vi.fn().mockReturnValue(''),
getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()),
getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'),
searchBaseRefs: vi.fn().mockResolvedValue([])
}))
vi.mock('./filesystem-auth', () => ({
rebuildAuthorizedRootsCache: rebuildAuthorizedRootsCacheMock
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: vi.fn()
}))
vi.mock('./ssh', () => ({
getActiveMultiplexer: vi.fn()
}))
import { registerRepoHandlers } from './repos'
type CreateArgs = { parentPath: string; name: string; kind: 'git' | 'folder' }
type CreateResult =
| { repo: { id: string; path: string; kind: 'git' | 'folder' } }
| { error: string }
describe('repos:create', () => {
const handlers = new Map<string, (event: unknown, args: CreateArgs) => Promise<CreateResult>>()
const mockWindow = {
isDestroyed: () => false,
webContents: { send: vi.fn() }
}
const callCreate = (args: CreateArgs): Promise<CreateResult> => {
const handler = handlers.get('repos:create')
if (!handler) {
throw new Error('repos:create handler was never registered')
}
return handler(null, args)
}
beforeEach(() => {
handlers.clear()
handleMock.mockReset()
handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => {
handlers.set(channel, handler as (event: unknown, args: CreateArgs) => Promise<CreateResult>)
})
removeHandlerMock.mockReset()
mockStore.getRepos.mockReset().mockReturnValue([])
mockStore.addRepo.mockReset()
mockWindow.webContents.send.mockReset()
rebuildAuthorizedRootsCacheMock.mockReset().mockResolvedValue(undefined)
// Default baseline: target does NOT exist yet, mkdir succeeds, git OK.
accessMock.mockReset().mockRejectedValue(new Error('ENOENT'))
readdirMock.mockReset().mockResolvedValue([])
mkdirMock.mockReset().mockResolvedValue(undefined)
rmMock.mockReset().mockResolvedValue(undefined)
gitExecFileAsyncMock.mockReset().mockResolvedValue({ stdout: '', stderr: '' })
registerRepoHandlers(mockWindow as never, mockStore as never)
})
it('registers the repos:create handler', () => {
expect(handlers.has('repos:create')).toBe(true)
})
it('unregisters any previously-registered repos:create handler', () => {
// registerRepoHandlers must call removeHandler('repos:create') before
// ipcMain.handle to avoid the "second handler for same channel" throw
// when this module is re-registered (e.g., after a reload).
expect(removeHandlerMock).toHaveBeenCalledWith('repos:create')
})
// ── input validation ──────────────────────────────────────────────
it('rejects empty names', async () => {
const result = await callCreate({ parentPath: '/tmp', name: ' ', kind: 'git' })
expect(result).toEqual({ error: 'Name cannot be empty' })
expect(mkdirMock).not.toHaveBeenCalled()
})
it('rejects names containing a forward slash', async () => {
const result = await callCreate({ parentPath: '/tmp', name: 'foo/bar', kind: 'git' })
expect(result).toMatchObject({ error: expect.stringContaining('slash') })
expect(mkdirMock).not.toHaveBeenCalled()
})
it('rejects names containing a backslash', async () => {
const result = await callCreate({ parentPath: '/tmp', name: 'foo\\bar', kind: 'git' })
expect(result).toMatchObject({ error: expect.stringContaining('slash') })
expect(mkdirMock).not.toHaveBeenCalled()
})
it('rejects "." and ".." as names', async () => {
for (const name of ['.', '..']) {
mkdirMock.mockClear()
const result = await callCreate({ parentPath: '/tmp', name, kind: 'git' })
expect(result).toMatchObject({ error: expect.stringContaining('slash') })
expect(mkdirMock).not.toHaveBeenCalled()
}
})
it('rejects empty parent path', async () => {
const result = await callCreate({ parentPath: ' ', name: 'project', kind: 'git' })
expect(result).toEqual({ error: 'Parent directory is required' })
expect(mkdirMock).not.toHaveBeenCalled()
})
// ── existing-directory handling ───────────────────────────────────
it('rejects a non-empty existing directory without calling mkdir', async () => {
accessMock.mockResolvedValueOnce(undefined) // exists
readdirMock.mockResolvedValueOnce(['README.md', '.DS_Store'])
const result = await callCreate({ parentPath: '/tmp', name: 'busy', kind: 'git' })
expect(result).toMatchObject({ error: expect.stringContaining('not empty') })
expect(mkdirMock).not.toHaveBeenCalled()
expect(mockStore.addRepo).not.toHaveBeenCalled()
})
it('accepts an empty existing directory and does not call mkdir', async () => {
accessMock.mockResolvedValueOnce(undefined) // exists
readdirMock.mockResolvedValueOnce([])
const result = await callCreate({ parentPath: '/tmp', name: 'empty', kind: 'folder' })
expect(mkdirMock).not.toHaveBeenCalled()
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({ path: '/tmp/empty', kind: 'folder' })
)
expect(result).toHaveProperty('repo.kind', 'folder')
})
it('creates a missing directory with mkdir', async () => {
// accessMock rejects by default → path does not exist
await callCreate({ parentPath: '/tmp', name: 'brand-new', kind: 'folder' })
expect(mkdirMock).toHaveBeenCalledWith('/tmp/brand-new', { recursive: false })
})
// ── plain folder happy path ───────────────────────────────────────
it('creates a plain folder without running any git commands', async () => {
const result = await callCreate({ parentPath: '/tmp', name: 'plain', kind: 'folder' })
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({
path: '/tmp/plain',
displayName: 'plain',
kind: 'folder'
})
)
expect(result).toHaveProperty('repo.kind', 'folder')
})
// ── git repo happy path ───────────────────────────────────────────
it('creates a git repo with an empty initial commit (in order)', async () => {
const result = await callCreate({ parentPath: '/tmp', name: 'gitproj', kind: 'git' })
expect(mkdirMock).toHaveBeenCalledWith('/tmp/gitproj', { recursive: false })
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['init'], { cwd: '/tmp/gitproj' })
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
['commit', '--allow-empty', '-m', 'Initial commit'],
{ cwd: '/tmp/gitproj' }
)
expect(mockStore.addRepo).toHaveBeenCalledWith(
expect.objectContaining({
path: '/tmp/gitproj',
displayName: 'gitproj',
kind: 'git'
})
)
expect(result).toHaveProperty('repo.kind', 'git')
})
// ── rollback semantics ────────────────────────────────────────────
it('rolls back the directory it just created when git init fails', async () => {
gitExecFileAsyncMock.mockReset().mockRejectedValueOnce(new Error('git init blew up'))
const result = await callCreate({ parentPath: '/tmp', name: 'broken', kind: 'git' })
expect(rmMock).toHaveBeenCalledWith('/tmp/broken', { recursive: true, force: true })
expect(mockStore.addRepo).not.toHaveBeenCalled()
expect(result).toMatchObject({ error: expect.stringContaining('Failed to initialize') })
})
it('does NOT rm a pre-existing empty directory when git init fails', async () => {
// Pretend the directory already existed (and is empty) — user pre-created it.
accessMock.mockResolvedValueOnce(undefined)
readdirMock.mockResolvedValueOnce([])
gitExecFileAsyncMock.mockReset().mockRejectedValueOnce(new Error('git init blew up'))
const result = await callCreate({ parentPath: '/tmp', name: 'preexisting', kind: 'git' })
expect(rmMock).not.toHaveBeenCalled()
expect(mockStore.addRepo).not.toHaveBeenCalled()
expect(result).toMatchObject({ error: expect.stringContaining('Failed to initialize') })
})
it('surfaces an "initialize"-flavored error when git init itself fails', async () => {
// First git call (init) rejects — the commit step never runs.
gitExecFileAsyncMock.mockReset().mockRejectedValueOnce(new Error('init broke'))
const result = await callCreate({ parentPath: '/tmp', name: 'initfail', kind: 'git' })
expect(rmMock).toHaveBeenCalledWith('/tmp/initfail', { recursive: true, force: true })
expect(mockStore.addRepo).not.toHaveBeenCalled()
// Loose match — handler distinguishes init vs commit failures, and we want
// to tolerate small wording tweaks as long as it still mentions "initialize".
expect(result).toMatchObject({ error: expect.stringContaining('initialize') })
})
it('rolls back directory when git commit fails (not just init)', async () => {
// init resolves, commit rejects — the failure must still trigger rollback
// and surface a commit-flavored error (distinct from the init-failure path).
gitExecFileAsyncMock
.mockReset()
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockRejectedValueOnce(new Error('commit broke'))
const result = await callCreate({ parentPath: '/tmp', name: 'commitfail', kind: 'git' })
expect(rmMock).toHaveBeenCalledWith('/tmp/commitfail', { recursive: true, force: true })
expect(mockStore.addRepo).not.toHaveBeenCalled()
expect(result).toMatchObject({ error: expect.stringContaining('commit') })
})
it('strips only .git/ when commit fails in a pre-existing empty folder', async () => {
// User pre-created an empty folder; git init succeeded, commit failed.
// The folder itself must survive (user owns it) but the half-init'd
// .git/ should be removed so the folder looks untouched.
accessMock.mockResolvedValueOnce(undefined)
readdirMock.mockResolvedValueOnce([])
gitExecFileAsyncMock
.mockReset()
.mockResolvedValueOnce({ stdout: '', stderr: '' })
.mockRejectedValueOnce(new Error('commit broke'))
const result = await callCreate({ parentPath: '/tmp', name: 'pre-existing', kind: 'git' })
expect(rmMock).toHaveBeenCalledWith('/tmp/pre-existing/.git', { recursive: true, force: true })
expect(rmMock).not.toHaveBeenCalledWith('/tmp/pre-existing', { recursive: true, force: true })
expect(mockStore.addRepo).not.toHaveBeenCalled()
expect(result).toMatchObject({ error: expect.stringContaining('commit') })
})
// ── friendly messaging ────────────────────────────────────────────
it('surfaces a friendly message when git author identity is missing', async () => {
gitExecFileAsyncMock
.mockReset()
.mockResolvedValueOnce({ stdout: '', stderr: '' }) // git init
.mockRejectedValueOnce(
new Error('Please tell me who you are. Run git config --global user.email ...')
)
const result = await callCreate({ parentPath: '/tmp', name: 'authorless', kind: 'git' })
expect(rmMock).toHaveBeenCalledWith('/tmp/authorless', { recursive: true, force: true })
expect(mockStore.addRepo).not.toHaveBeenCalled()
expect(result).toMatchObject({
error: expect.stringContaining('Git author identity is not configured')
})
})
// ── renderer notification ─────────────────────────────────────────
it('notifies the renderer via repos:changed after a successful create', async () => {
await callCreate({ parentPath: '/tmp', name: 'notified', kind: 'folder' })
expect(mockWindow.webContents.send).toHaveBeenCalledWith('repos:changed')
})
// ── authorized-roots cache refresh ────────────────────────────────
it('rebuilds the authorized-roots cache after a successful folder create', async () => {
// Roots cache must be refreshed so the renderer can read the new path
// without waiting for the next full reconciliation.
await callCreate({ parentPath: '/tmp', name: 'rooted', kind: 'folder' })
expect(rebuildAuthorizedRootsCacheMock).toHaveBeenCalledTimes(1)
})
it('does NOT rebuild the authorized-roots cache on a validation failure', async () => {
const result = await callCreate({ parentPath: '/tmp', name: ' ', kind: 'git' })
expect(result).toEqual({ error: 'Name cannot be empty' })
expect(rebuildAuthorizedRootsCacheMock).not.toHaveBeenCalled()
})
it('does NOT rebuild the authorized-roots cache when dedup short-circuits', async () => {
const existing = { id: 'abc', path: '/tmp/dupe2', displayName: 'dupe2', kind: 'git' }
mockStore.getRepos.mockReturnValue([existing])
await callCreate({ parentPath: '/tmp', name: 'dupe2', kind: 'git' })
expect(rebuildAuthorizedRootsCacheMock).not.toHaveBeenCalled()
})
// ── dedup-by-path ─────────────────────────────────────────────────
it('returns the existing repo when one already lives at the target path', async () => {
const existing = { id: 'abc', path: '/tmp/dupe', displayName: 'dupe', kind: 'git' }
mockStore.getRepos.mockReturnValue([existing])
const result = await callCreate({ parentPath: '/tmp', name: 'dupe', kind: 'git' })
expect(result).toEqual({ repo: existing })
// Short-circuit before any fs or git work.
expect(mkdirMock).not.toHaveBeenCalled()
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(mockStore.addRepo).not.toHaveBeenCalled()
})
})
+191 -3
View File
@@ -10,9 +10,9 @@ import { isFolderRepo } from '../../shared/repo-kind'
import { REPO_COLORS } from '../../shared/constants'
import { rebuildAuthorizedRootsCache } from './filesystem-auth'
import type { ChildProcess } from 'child_process'
import { rm } from 'fs/promises'
import { gitSpawn } from '../git/runner'
import { join, basename } from 'path'
import { access, mkdir, readdir, rm } from 'fs/promises'
import { gitExecFileAsync, gitSpawn } from '../git/runner'
import { basename, isAbsolute, join } from 'path'
import {
isGitRepo,
getGitUsername,
@@ -51,6 +51,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
ipcMain.removeHandler('repos:getBaseRefDefault')
ipcMain.removeHandler('repos:searchBaseRefs')
ipcMain.removeHandler('repos:addRemote')
ipcMain.removeHandler('repos:create')
ipcMain.removeHandler('sparsePresets:list')
ipcMain.removeHandler('sparsePresets:save')
ipcMain.removeHandler('sparsePresets:remove')
@@ -198,6 +199,193 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
}
)
// Creates a new repo or folder from scratch (orca#763). An empty initial
// commit is required for git repos so HEAD has a branch ref — Orca's
// worktree features all need one.
ipcMain.handle(
'repos:create',
async (
_event,
args: { parentPath: string; name: string; kind: 'git' | 'folder' }
): Promise<{ repo: Repo } | { error: string }> => {
const name = args.name?.trim() ?? ''
const parentPath = args.parentPath?.trim() ?? ''
// Why: IPC input is untrusted — coerce to the narrow union so a bogus
// string (e.g. "x") can't skip git init yet persist as kind: "x" in the
// store. Mirrors the coercion in repos:add above.
const repoKind: 'git' | 'folder' = args.kind === 'folder' ? 'folder' : 'git'
if (!name) {
return { error: 'Name cannot be empty' }
}
// Block slashes and ./.. so the name can't escape the chosen parent.
// The UI already disables submit in these cases; this guards direct IPC use.
if (/[\\/]/.test(name) || name === '.' || name === '..') {
return { error: 'Name cannot contain slashes or be "." / ".."' }
}
if (!parentPath) {
return { error: 'Parent directory is required' }
}
// Why: blocks CWD-relative paths from slipping through the IPC boundary;
// the UI uses pickDirectory which returns absolute paths, this guards
// direct IPC use (and keeps targetPath stable across process cwd changes).
if (!isAbsolute(parentPath)) {
return { error: 'Parent directory must be an absolute path' }
}
const targetPath = join(parentPath, name)
// Dedup by path (same as repos:add) so a double-click on Create doesn't
// produce two sidebar entries pointing at the same folder. This is the
// first of three dedup checks; see the pre-addRepo check below for why
// the race matters even after this one passes.
const existing = store.getRepos().find((r) => r.path === targetPath)
if (existing) {
return { repo: existing }
}
// Empty pre-existing directories are allowed (e.g. one the user made in
// Finder first). Non-empty ones are rejected so we don't overwrite files.
let createdDir = false
let targetExists = false
try {
await access(targetPath)
targetExists = true
} catch (err) {
// Why: only ENOENT means "the path is free to use". Other codes
// (EACCES, ENOTDIR, EPERM, ELOOP, ...) mean something is in the way
// that mkdir can't fix — surface a precise error instead of falling
// through to mkdir and returning a misleading "Failed to create
// directory" message.
//
// Why the message fallback: fs.promises.access always attaches a
// NodeJS.ErrnoException code in production, but plain Error objects
// thrown in tests / non-Node contexts won't — treat a message that
// reads like ENOENT as one so we don't over-reject.
const code =
err && typeof err === 'object' && 'code' in err
? (err as NodeJS.ErrnoException).code
: undefined
const looksLikeEnoent =
code === 'ENOENT' ||
(code === undefined && err instanceof Error && /ENOENT/.test(err.message))
if (!looksLikeEnoent) {
const message = err instanceof Error ? err.message : String(err)
return { error: `Cannot access target path: ${message}` }
}
}
if (targetExists) {
try {
const entries = await readdir(targetPath)
if (entries.length > 0) {
return {
error: `"${name}" already exists at this location and is not empty.`
}
}
} catch (err) {
// Why: access succeeded but readdir failed — the path exists but we
// can't inspect it (e.g. it's a file, not a directory; or perms).
// mkdir would definitely fail here too, so return a distinct error.
const message = err instanceof Error ? err.message : String(err)
return { error: `Failed to read directory: ${message}` }
}
} else {
try {
await mkdir(targetPath, { recursive: false })
createdDir = true
} catch (err) {
// Why: EEXIST here means another concurrent repos:create for the
// same path won the mkdir race. If they already added the repo to
// the store, return that entry instead of a confusing error. This
// is the second dedup check; see the pre-addRepo check below for
// the full race explanation.
const code =
err && typeof err === 'object' && 'code' in err
? (err as NodeJS.ErrnoException).code
: undefined
const isEexist = code === 'EEXIST' || (err instanceof Error && /EEXIST/.test(err.message))
if (isEexist) {
const raceWinner = store.getRepos().find((r) => r.path === targetPath)
if (raceWinner) {
return { repo: raceWinner }
}
}
const message = err instanceof Error ? err.message : String(err)
return { error: `Failed to create directory: ${message}` }
}
}
if (repoKind === 'git') {
// Why: track which git step is running so the catch can attribute the
// failure correctly. The identity-hint regex is only meaningful during
// commit — git init itself never produces "Please tell me who you are".
let step: 'init' | 'commit' = 'init'
try {
await gitExecFileAsync(['init'], { cwd: targetPath })
step = 'commit'
await gitExecFileAsync(['commit', '--allow-empty', '-m', 'Initial commit'], {
cwd: targetPath
})
} catch (err) {
// Only remove the directory if we made it. A pre-existing folder the
// user picked must survive so they can retry after fixing git config.
// Why: if we didn't make the directory but `git init` created `.git/`
// inside it, strip just `.git/` so the user's folder looks the way
// they left it. Retrying works either way, but leaving a half-init'd
// repo behind is confusing if they choose to skip the retry.
if (createdDir) {
await rm(targetPath, { recursive: true, force: true }).catch(() => {})
} else if (step === 'commit') {
await rm(join(targetPath, '.git'), { recursive: true, force: true }).catch(() => {})
}
const message = err instanceof Error ? err.message : String(err)
if (
step === 'commit' &&
/Please tell me who you are|user\.name|user\.email/i.test(message)
) {
return {
error:
'Git author identity is not configured. Run `git config --global user.name "Your Name"` and `git config --global user.email "you@example.com"`, then try again.'
}
}
const stepLabel =
step === 'init'
? 'Failed to initialize git repository'
: 'Failed to create initial commit'
return { error: `${stepLabel}: ${message}` }
}
}
// Why: ipcMain.handle doesn't serialize concurrent calls; re-running the
// dedup lookup here closes the window between the first check and
// addRepo. A second repos:create for the same path that raced past the
// initial dedup now returns the entry the first call persisted.
const raceWinner = store.getRepos().find((r) => r.path === targetPath)
if (raceWinner) {
// Why: do NOT rm even if this invocation created the directory — the
// other invocation is using it. Leaking a freshly-made empty folder on
// a rare race is strictly safer than deleting a directory the winning
// call (and the user) now owns.
return { repo: raceWinner }
}
const repo: Repo = {
id: randomUUID(),
path: targetPath,
displayName: name,
badgeColor: REPO_COLORS[store.getRepos().length % REPO_COLORS.length],
addedAt: Date.now(),
kind: repoKind
}
store.addRepo(repo)
await rebuildAuthorizedRootsCache(store)
notifyReposChanged(mainWindow)
return { repo }
}
)
ipcMain.handle('repos:remove', async (_event, args: { repoId: string }) => {
store.removeRepo(args.repoId)
await rebuildAuthorizedRootsCache(store)
+4 -1
View File
@@ -27,7 +27,10 @@ import { registerTelemetryHandlers } from './telemetry'
function captureHandlers(): void {
handlers.clear()
for (const call of handleMock.mock.calls) {
const [channel, handler] = call as [string, typeof handlers extends Map<string, infer V> ? V : never]
const [channel, handler] = call as [
string,
typeof handlers extends Map<string, infer V> ? V : never
]
handlers.set(channel, handler)
}
}
+21 -27
View File
@@ -25,34 +25,28 @@ import { setOptIn, track } from '../telemetry/client'
import type { EventName, EventProps } from '../../shared/telemetry-events'
export function registerTelemetryHandlers(): void {
ipcMain.handle(
'telemetry:track',
(_event, name: unknown, props: unknown): void => {
// Strict input typing: non-string names are dropped at the boundary
// before the validator even sees them. The validator would also drop
// (unknown event name), but the main-side narrow keeps the attack
// surface minimal — a flood of bogus payloads does not exercise the
// Zod parser for no reason.
if (typeof name !== 'string') {
return
}
// `props` may legitimately be omitted; treat `undefined`/`null` as an
// empty object before the validator. Anything else non-object (e.g.
// a string, a number) is a boundary violation.
if (props !== null && props !== undefined && typeof props !== 'object') {
return
}
// The casts to `EventName` / `EventProps<EventName>` here are
// pass-through only — this file does NOT pretend the renderer's
// name/props are type-safe. The validator inside `track()` is the
// single enforcement point at runtime; these casts only feed the
// typed channel that the validator will re-check.
track(
name as EventName,
(props ?? {}) as EventProps<EventName>
)
ipcMain.handle('telemetry:track', (_event, name: unknown, props: unknown): void => {
// Strict input typing: non-string names are dropped at the boundary
// before the validator even sees them. The validator would also drop
// (unknown event name), but the main-side narrow keeps the attack
// surface minimal — a flood of bogus payloads does not exercise the
// Zod parser for no reason.
if (typeof name !== 'string') {
return
}
)
// `props` may legitimately be omitted; treat `undefined`/`null` as an
// empty object before the validator. Anything else non-object (e.g.
// a string, a number) is a boundary violation.
if (props !== null && props !== undefined && typeof props !== 'object') {
return
}
// The casts to `EventName` / `EventProps<EventName>` here are
// pass-through only — this file does NOT pretend the renderer's
// name/props are type-safe. The validator inside `track()` is the
// single enforcement point at runtime; these casts only feed the
// typed channel that the validator will re-check.
track(name as EventName, (props ?? {}) as EventProps<EventName>)
})
ipcMain.handle('telemetry:setOptIn', (_event, optedIn: unknown): void => {
// Strict input typing — renderer can pass anything over IPC.
+4 -1
View File
@@ -59,7 +59,10 @@ function makeFakeStore(settings: GlobalSettings): Store {
getSettings: vi.fn(() => settings),
updateSettings: vi.fn((updates: Partial<GlobalSettings>) => {
if (updates.telemetry) {
settings.telemetry = { ...settings.telemetry, ...updates.telemetry } as typeof settings.telemetry
settings.telemetry = {
...settings.telemetry,
...updates.telemetry
} as typeof settings.telemetry
}
return settings
})
+1 -5
View File
@@ -86,11 +86,7 @@ let storeRef: Store | null = null
// be bounded by `resolveConsent` + the validator.
let testTransportEnabled = false
function buildCommonProps(
installId: string,
sid: string,
channel: 'stable' | 'rc'
): CommonProps {
function buildCommonProps(installId: string, sid: string, channel: 'stable' | 'rc'): CommonProps {
// `.max(64)` on every free-form string field in `commonPropsSchema` is the
// upper bound; node's platform / arch / release strings are always well
// under that in practice. We do not truncate here because the validator's
+6
View File
@@ -365,6 +365,12 @@ export type PreloadApi = {
displayName?: string
kind?: 'git' | 'folder'
}) => Promise<{ repo: Repo } | { error: string }>
// Why: error union matches the IPC handler's return shape; renderer callers branch on `'error' in result`.
create: (args: {
parentPath: string
name: string
kind: 'git' | 'folder'
}) => Promise<{ repo: Repo } | { error: string }>
onCloneProgress: (callback: (data: { phase: string; percent: number }) => void) => () => void
getGitUsername: (args: { repoId: string }) => Promise<string>
getBaseRefDefault: (args: { repoId: string }) => Promise<BaseRefDefaultResult>
+6
View File
@@ -212,6 +212,12 @@ const api = {
kind?: 'git' | 'folder'
}): Promise<unknown> => ipcRenderer.invoke('repos:addRemote', args),
create: (args: {
parentPath: string
name: string
kind: 'git' | 'folder'
}): Promise<unknown> => ipcRenderer.invoke('repos:create', args),
remove: (args: { repoId: string }): Promise<void> => ipcRenderer.invoke('repos:remove', args),
update: (args: { repoId: string; updates: Record<string, unknown> }): Promise<unknown> =>
@@ -0,0 +1,387 @@
/**
* Step for AddRepoDialog (orca#763).
*
* Split from AddRepoDialog and AddRepoSteps to keep both under the 400-line
* oxlint limit, following the same pattern as useRemoteRepo.
*/
import React, { useCallback, useRef, useState } from 'react'
import { toast } from 'sonner'
import { Folder, GitBranch, Home, Pencil } from 'lucide-react'
import { useAppStore } from '@/store'
import { DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import type { Repo } from '../../../../shared/types'
type DialogStep = 'add' | 'clone' | 'remote' | 'create' | 'setup'
type RepoKind = 'git' | 'folder'
export function useCreateRepo(
fetchWorktrees: (repoId: string) => Promise<void>,
setStep: (step: DialogStep) => void,
setAddedRepo: (repo: Repo | null) => void,
closeModal: () => void
) {
const [createName, setCreateName] = useState('')
const [createParent, setCreateParent] = useState('')
const [createKind, setCreateKind] = useState<RepoKind>('git')
const [createError, setCreateError] = useState<string | null>(null)
const [isCreating, setIsCreating] = useState(false)
// Why: monotonic ID so stale create callbacks can detect they were superseded
// when the user clicks Back or closes the dialog mid-create. Mirrors the
// cloneGenRef pattern in AddRepoDialog.
const createGenRef = useRef(0)
const resetCreateState = useCallback(() => {
createGenRef.current++
setCreateName('')
setCreateParent('')
setCreateKind('git')
setCreateError(null)
setIsCreating(false)
}, [])
const handlePickParent = useCallback(async () => {
const dir = await window.api.repos.pickDirectory()
if (dir) {
setCreateParent(dir)
setCreateError(null)
}
}, [])
const handleCreate = useCallback(async () => {
const name = createName.trim()
const parentPath = createParent.trim()
if (!name || !parentPath) {
return
}
const gen = ++createGenRef.current
setIsCreating(true)
setCreateError(null)
try {
const result = await window.api.repos.create({
parentPath,
name,
kind: createKind
})
// Why: if the user closed the dialog or clicked Back mid-create,
// createGenRef was bumped by resetCreateState. Ignore stale results.
if (gen !== createGenRef.current) {
return
}
if ('error' in result) {
setCreateError(result.error)
return
}
const repo = result.repo
// Upsert into the store before the repos:changed event round-trips,
// so the next step can find the repo immediately.
const state = useAppStore.getState()
const existingIdx = state.repos.findIndex((r) => r.id === repo.id)
// Why: the IPC handler dedupes by path (see repos:create) and returns
// the existing repo unchanged. If its ID is already in our store, the
// handler took the dedup path — no new project was created, so don't
// claim one was.
const wasDeduped = existingIdx !== -1
if (existingIdx === -1) {
useAppStore.setState({ repos: [...state.repos, repo] })
} else {
const updated = [...state.repos]
updated[existingIdx] = repo
useAppStore.setState({ repos: updated })
}
if (wasDeduped) {
toast.info('Project already added', {
description: repo.displayName
})
} else {
toast.success('Project created', {
description: repo.displayName
})
}
if (isGitRepoKind(repo)) {
// Why: setAddedRepo only drives the git "setup" step; the folder
// branch closes the dialog, which resets addedRepo to null anyway.
setAddedRepo(repo)
await fetchWorktrees(repo.id)
if (gen !== createGenRef.current) {
return
}
setStep('setup')
} else {
// Why: without activating the new folder, the dialog closes and users
// see no change. Matches addNonGitFolder's behavior in the store slice.
await fetchWorktrees(repo.id)
if (gen !== createGenRef.current) {
return
}
const folderWorktree = useAppStore.getState().worktreesByRepo[repo.id]?.[0]
if (folderWorktree) {
activateAndRevealWorktree(folderWorktree.id)
}
closeModal()
}
} catch (err) {
if (gen !== createGenRef.current) {
return
}
const message = err instanceof Error ? err.message : String(err)
setCreateError(message)
} finally {
// Why: only clear the loading state if this invocation is still current;
// a superseded create must not flip the flag back off for a new flow.
if (gen === createGenRef.current) {
setIsCreating(false)
}
}
}, [createName, createParent, createKind, fetchWorktrees, setStep, setAddedRepo, closeModal])
return {
createName,
createParent,
createKind,
createError,
isCreating,
setCreateName,
setCreateKind,
setCreateError,
resetCreateState,
handlePickParent,
handleCreate
}
}
// ── UI helpers ───────────────────────────────────────────────────────
type KindCardProps = {
kind: RepoKind
selected: boolean
disabled: boolean
onSelect: () => void
onArrowNav: () => void
icon: React.ReactNode
title: string
caption: string
}
function KindCard({
kind,
selected,
disabled,
onSelect,
onArrowNav,
icon,
title,
caption
}: KindCardProps): React.JSX.Element {
return (
<button
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
onClick={onSelect}
onKeyDown={(e) => {
// Why: WAI-ARIA radiogroup spec expects all four arrow keys to move
// selection. Left/Right handle the horizontal grid layout; Up/Down
// are added so vertical nav (e.g. screen-reader users, future layout
// changes) behaves the same.
if (
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight' ||
e.key === 'ArrowUp' ||
e.key === 'ArrowDown'
) {
e.preventDefault()
onArrowNav()
} else if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault()
onSelect()
}
}}
disabled={disabled}
data-kind={kind}
className={`group relative flex items-center gap-3 rounded-md border px-3.5 py-3.5 text-left text-xs transition-colors cursor-pointer outline-none ${
selected ? 'border-foreground/30 bg-accent' : 'border-border hover:bg-accent/50'
} focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-60`}
>
{/* Icon chip gives the glyph enough weight to sit balanced next to the title block. */}
<span
className={`shrink-0 inline-flex items-center justify-center size-8 rounded-md border transition-colors ${
selected
? 'border-foreground/20 bg-background/60 text-foreground'
: 'border-border/70 bg-background/30 text-muted-foreground group-hover:text-foreground'
}`}
>
{icon}
</span>
<span className="min-w-0">
<span className="block text-[13px] font-medium leading-tight">{title}</span>
<span className="block text-[11px] text-muted-foreground leading-snug mt-0.5">
{caption}
</span>
</span>
</button>
)
}
type CreateStepProps = {
createName: string
createParent: string
createKind: RepoKind
createError: string | null
isCreating: boolean
onNameChange: (value: string) => void
onKindChange: (kind: RepoKind) => void
onPickParent: () => void
onCreate: () => void
}
export function CreateStep({
createName,
createParent,
createKind,
createError,
isCreating,
onNameChange,
onKindChange,
onPickParent,
onCreate
}: CreateStepProps): React.JSX.Element {
const radioGroupRef = useRef<HTMLDivElement>(null)
// Arrow keys cycle selection within the radiogroup (WAI-ARIA radio pattern).
const cycleKind = useCallback(() => {
const next = createKind === 'git' ? 'folder' : 'git'
onKindChange(next)
requestAnimationFrame(() => {
const nextEl = radioGroupRef.current?.querySelector<HTMLButtonElement>(
`[data-kind="${next}"]`
)
nextEl?.focus()
})
}, [createKind, onKindChange])
const trimmedName = createName.trim()
const canSubmit = trimmedName.length > 0 && createParent.trim().length > 0 && !isCreating
return (
<>
<DialogHeader>
<DialogTitle>Start a new project</DialogTitle>
<DialogDescription>
Create a Git repository or a plain folder and open it in Orca.
</DialogDescription>
</DialogHeader>
{/* Why: DialogContent is a CSS grid; grid items default to min-width:auto
(= content size), so a long path inside the Location row would blow out
the dialog width even with flex + truncate on the row itself. min-w-0
here caps the grid track at the dialog's max-width. */}
<div className="space-y-3.5 pt-1 min-w-0">
{/* Kind toggle. Real radiogroup so screen readers announce it as a choice. */}
<div
ref={radioGroupRef}
role="radiogroup"
aria-label="Project kind"
className="grid grid-cols-2 gap-2"
>
<KindCard
kind="git"
selected={createKind === 'git'}
disabled={isCreating}
onSelect={() => onKindChange('git')}
onArrowNav={cycleKind}
icon={<GitBranch className="size-4" />}
title="Git repository"
caption="Initializes an empty Git repo"
/>
<KindCard
kind="folder"
selected={createKind === 'folder'}
disabled={isCreating}
onSelect={() => onKindChange('folder')}
onArrowNav={cycleKind}
icon={<Folder className="size-4" />}
title="Folder"
caption="Create a new folder"
/>
</div>
{/* Name. Monospaced because it ends up as a directory name. */}
<div className="space-y-1">
<label
htmlFor="create-project-name"
className="text-[11px] font-medium text-muted-foreground block"
>
Name
</label>
<Input
id="create-project-name"
value={createName}
onChange={(e) => onNameChange(e.target.value)}
placeholder="my-project"
className="h-11 text-sm font-mono"
disabled={isCreating}
autoFocus
autoComplete="off"
spellCheck={false}
/>
</div>
{/* Location. The "Choose…" button morphs into a summary + Change once picked. */}
<div className="space-y-1">
<span className="text-[11px] font-medium text-muted-foreground block">Location</span>
{createParent ? (
<div className="group flex items-center gap-2.5 rounded-md border border-border bg-background/40 h-11 min-w-0 px-3 text-sm">
<span className="shrink-0 inline-flex items-center justify-center size-7 rounded-md border border-border/70 bg-background/50 text-muted-foreground">
<Home className="size-3.5" />
</span>
<span className="flex-1 min-w-0 truncate font-mono text-[12px]" title={createParent}>
{createParent}
</span>
<button
type="button"
onClick={onPickParent}
disabled={isCreating}
className="shrink-0 inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground transition-colors cursor-pointer disabled:cursor-not-allowed"
aria-label="Change parent folder"
>
<Pencil className="size-3" />
Change
</button>
</div>
) : (
<Button
type="button"
variant="outline"
onClick={onPickParent}
disabled={isCreating}
className="w-full h-11 justify-start text-sm text-muted-foreground font-normal gap-2.5"
>
<span className="shrink-0 inline-flex items-center justify-center size-7 rounded-md border border-border/70 bg-background/40">
<Folder className="size-3.5" />
</span>
Choose parent folder
</Button>
)}
</div>
{createError && (
<p className="text-[11px] text-destructive" role="alert">
{createError}
</p>
)}
<Button onClick={onCreate} disabled={!canSubmit} size="lg" className="w-full">
{isCreating ? 'Creating…' : 'Create project'}
</Button>
</div>
</>
)
}
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'
import { FolderOpen, GitBranchPlus, Settings, ArrowLeft, Globe, Monitor } from 'lucide-react'
import { FolderOpen, ArrowLeft, Globe, Monitor } from 'lucide-react'
import { useAppStore } from '@/store'
import {
Dialog,
@@ -11,8 +11,9 @@ import {
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { LinkedWorktreeItem } from './LinkedWorktreeItem'
import { RemoteStep, CloneStep, useRemoteRepo } from './AddRepoSteps'
import { CreateStep, useCreateRepo } from './AddRepoCreateStep'
import { SetupStep } from './AddRepoSetupStep'
import { isGitRepoKind } from '../../../../shared/repo-kind'
import type { Repo, Worktree } from '../../../../shared/types'
@@ -27,7 +28,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
const openSettingsPage = useAppStore((s) => s.openSettingsPage)
const openSettingsTarget = useAppStore((s) => s.openSettingsTarget)
const [step, setStep] = useState<'add' | 'clone' | 'remote' | 'setup'>('add')
const [step, setStep] = useState<'add' | 'clone' | 'remote' | 'create' | 'setup'>('add')
const [addedRepo, setAddedRepo] = useState<Repo | null>(null)
const [isAdding, setIsAdding] = useState(false)
const [cloneUrl, setCloneUrl] = useState('')
@@ -55,6 +56,20 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
handleAddRemoteRepo,
handleConnectTarget
} = useRemoteRepo(fetchWorktrees, setStep, setAddedRepo, closeModal)
const {
createName,
createParent,
createKind,
createError,
isCreating,
setCreateName,
setCreateKind,
setCreateError,
resetCreateState,
handlePickParent,
handleCreate
} = useCreateRepo(fetchWorktrees, setStep, setAddedRepo, closeModal)
useEffect(() => {
if (!isCloning) {
return
@@ -79,8 +94,6 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
})
}, [worktrees])
const hasWorktrees = worktrees.length > 0
const resetState = useCallback(() => {
cloneGenRef.current++
// Why: kill the git clone process if one is running, so backing out
@@ -94,8 +107,9 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
setIsCloning(false)
setCloneError(null)
setCloneProgress(null)
resetCreateState()
resetRemoteState()
}, [resetRemoteState])
}, [resetRemoteState, resetCreateState])
// Why: reset state on close so reopening doesn't show stale step/repo.
useEffect(() => {
@@ -104,7 +118,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
}
}, [isOpen, resetState])
const isInputStep = step === 'add' || step === 'clone' || step === 'remote'
const isInputStep = step === 'add' || step === 'clone' || step === 'remote' || step === 'create'
const handleBrowse = useCallback(async () => {
setIsAdding(true)
@@ -217,7 +231,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
<DialogContent className="sm:max-w-lg">
{/* Step indicator row — back button (step 2 only), dots, X is rendered by DialogContent */}
<div className="flex items-center justify-center -mt-1">
{(step === 'clone' || step === 'remote') && (
{(step === 'clone' || step === 'remote' || step === 'create') && (
<button
className="absolute left-6 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
onClick={handleBack}
@@ -300,6 +314,21 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
</div>
</Button>
</div>
{/* Secondary link rather than a fourth card — create-from-scratch
is a less common path than importing. See orca#763. */}
<div className="flex items-center justify-center pt-1">
<button
type="button"
onClick={() => {
setCreateError(null)
setStep('create')
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
Or start a new project from scratch
</button>
</div>
</>
) : step === 'remote' ? (
<RemoteStep
@@ -342,64 +371,36 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
onPickDestination={handlePickDestination}
onClone={handleClone}
/>
) : step === 'create' ? (
<CreateStep
createName={createName}
createParent={createParent}
createKind={createKind}
createError={createError}
isCreating={isCreating}
onNameChange={(value) => {
setCreateName(value)
setCreateError(null)
}}
onKindChange={(kind) => {
setCreateKind(kind)
setCreateError(null)
}}
onPickParent={handlePickParent}
onCreate={handleCreate}
/>
) : (
<>
<DialogHeader>
<DialogTitle>
{hasWorktrees ? 'Open or create a worktree' : 'Set up your first worktree'}
</DialogTitle>
<DialogDescription>
{hasWorktrees
? `${addedRepo?.displayName} has ${worktrees.length} worktree${worktrees.length !== 1 ? 's' : ''}. Open one to pick up where you left off, or create a new one.`
: `Orca uses git worktrees as isolated task environments. Create one for ${addedRepo?.displayName} to get started.`}
</DialogDescription>
</DialogHeader>
{hasWorktrees && (
<div className="space-y-2 min-w-0">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Existing worktrees
</p>
<div className="space-y-1.5 max-h-[40vh] overflow-y-auto scrollbar-sleek pr-1">
{sortedWorktrees.map((wt) => (
<LinkedWorktreeItem
key={wt.id}
worktree={wt}
onOpen={() => handleOpenWorktree(wt)}
/>
))}
</div>
</div>
)}
<div className="flex flex-col gap-3 pt-2">
<Button onClick={handleCreateWorktree} className="w-full">
<GitBranchPlus className="size-4 mr-2" />
{hasWorktrees ? 'Create new worktree' : 'Create first worktree'}
</Button>
<div className="flex items-center justify-between">
<button
className="inline-flex items-center justify-center gap-1.5 text-xs text-muted-foreground/70 hover:text-foreground transition-colors cursor-pointer"
onClick={handleConfigureRepo}
>
<Settings className="size-3" />
Configure project
</button>
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={() => {
closeModal()
resetState()
}}
>
Skip
</Button>
</div>
</div>
</>
<SetupStep
repoName={addedRepo?.displayName ?? ''}
sortedWorktrees={sortedWorktrees}
onOpenWorktree={handleOpenWorktree}
onCreateWorktree={handleCreateWorktree}
onConfigureRepo={handleConfigureRepo}
onSkip={() => {
closeModal()
resetState()
}}
/>
)}
</DialogContent>
</Dialog>
@@ -0,0 +1,80 @@
/**
* Setup step for AddRepoDialog — shown after a repo is added, cloned, or created.
* Split out so the parent dialog stays under the 400-line oxlint limit.
*/
import React from 'react'
import { GitBranchPlus, Settings } from 'lucide-react'
import { DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { LinkedWorktreeItem } from './LinkedWorktreeItem'
import type { Worktree } from '../../../../shared/types'
type SetupStepProps = {
repoName: string
sortedWorktrees: Worktree[]
onOpenWorktree: (worktree: Worktree) => void
onCreateWorktree: () => void
onConfigureRepo: () => void
onSkip: () => void
}
export function SetupStep({
repoName,
sortedWorktrees,
onOpenWorktree,
onCreateWorktree,
onConfigureRepo,
onSkip
}: SetupStepProps): React.JSX.Element {
const hasWorktrees = sortedWorktrees.length > 0
const worktreeCount = sortedWorktrees.length
return (
<>
<DialogHeader>
<DialogTitle>
{hasWorktrees ? 'Open or create a worktree' : 'Set up your first worktree'}
</DialogTitle>
<DialogDescription>
{hasWorktrees
? `${repoName} has ${worktreeCount} worktree${worktreeCount !== 1 ? 's' : ''}. Open one to pick up where you left off, or create a new one.`
: `Orca uses git worktrees as isolated task environments. Create one for ${repoName} to get started.`}
</DialogDescription>
</DialogHeader>
{hasWorktrees && (
<div className="space-y-2 min-w-0">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Existing worktrees
</p>
<div className="space-y-1.5 max-h-[40vh] overflow-y-auto scrollbar-sleek pr-1">
{sortedWorktrees.map((wt) => (
<LinkedWorktreeItem key={wt.id} worktree={wt} onOpen={() => onOpenWorktree(wt)} />
))}
</div>
</div>
)}
<div className="flex flex-col gap-3 pt-2">
<Button onClick={onCreateWorktree} className="w-full">
<GitBranchPlus className="size-4 mr-2" />
{hasWorktrees ? 'Create new worktree' : 'Create first worktree'}
</Button>
<div className="flex items-center justify-between">
<button
className="inline-flex items-center justify-center gap-1.5 text-xs text-muted-foreground/70 hover:text-foreground transition-colors cursor-pointer"
onClick={onConfigureRepo}
>
<Settings className="size-3" />
Configure project
</button>
<Button variant="ghost" size="sm" className="text-xs" onClick={onSkip}>
Skip
</Button>
</div>
</div>
</>
)
}
@@ -21,7 +21,7 @@ import type { SshTarget, SshConnectionState } from '../../../../shared/ssh-types
export function useRemoteRepo(
fetchWorktrees: (repoId: string) => Promise<void>,
setStep: (step: 'add' | 'clone' | 'remote' | 'setup') => void,
setStep: (step: 'add' | 'clone' | 'remote' | 'create' | 'setup') => void,
setAddedRepo: (repo: Repo | null) => void,
closeModal: () => void
) {