Simplify workspace creation with inline worktree API
Replace the multi-step new-agent route flow with a single create_paseo_worktree endpoint that registers the workspace immediately and creates the git worktree in the background. The sidebar now calls this endpoint directly and shows a creating spinner inline. Also auto-resolves the base branch from origin/HEAD when not explicitly provided, removes the unused Tauri WebSocket transport layer, and normalizes loopback endpoints to localhost. Closes #125
This commit is contained in:
@@ -213,6 +213,10 @@ type BranchSuggestionsPayload = BranchSuggestionsResponse['payload']
|
||||
type DirectorySuggestionsPayload = DirectorySuggestionsResponse['payload']
|
||||
type PaseoWorktreeListPayload = PaseoWorktreeListResponse['payload']
|
||||
type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse['payload']
|
||||
type CreatePaseoWorktreePayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: 'create_paseo_worktree_response' }
|
||||
>['payload']
|
||||
type FileExplorerPayload = FileExplorerResponse['payload']
|
||||
type FileDownloadTokenPayload = FileDownloadTokenResponse['payload']
|
||||
type ListProviderModelsPayload = ListProviderModelsResponseMessage['payload']
|
||||
@@ -2148,6 +2152,22 @@ export class DaemonClient {
|
||||
})
|
||||
}
|
||||
|
||||
async createPaseoWorktree(
|
||||
input: { cwd: string; worktreeSlug?: string },
|
||||
requestId?: string
|
||||
): Promise<CreatePaseoWorktreePayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: 'create_paseo_worktree_request',
|
||||
cwd: input.cwd,
|
||||
worktreeSlug: input.worktreeSlug,
|
||||
},
|
||||
responseType: 'create_paseo_worktree_response',
|
||||
timeout: 60000,
|
||||
})
|
||||
}
|
||||
|
||||
async validateBranch(
|
||||
options: { cwd: string; branchName: string },
|
||||
requestId?: string
|
||||
|
||||
@@ -30,7 +30,6 @@ export function buildOfferEndpoints({
|
||||
}
|
||||
|
||||
endpoints.push(`localhost:${port}`);
|
||||
endpoints.push(`127.0.0.1:${port}`);
|
||||
|
||||
return dedupePreserveOrder(endpoints);
|
||||
}
|
||||
|
||||
@@ -132,6 +132,9 @@ import { DownloadTokenStore } from './file-download/token-store.js'
|
||||
import { PushTokenStore } from './push/token-store.js'
|
||||
import {
|
||||
type WorktreeConfig,
|
||||
computeWorktreePath,
|
||||
getWorktreeSetupCommands,
|
||||
resolveWorktreeRuntimeEnv,
|
||||
slugify,
|
||||
validateBranchSlug,
|
||||
listPaseoWorktrees,
|
||||
@@ -144,6 +147,7 @@ import {
|
||||
getCheckoutDiff,
|
||||
getCheckoutShortstat,
|
||||
getCheckoutStatus,
|
||||
getCheckoutStatusLite,
|
||||
listBranchSuggestions,
|
||||
NotGitRepoError,
|
||||
MergeConflictError,
|
||||
@@ -154,6 +158,7 @@ import {
|
||||
pushCurrentBranch,
|
||||
createPullRequest,
|
||||
getPullRequestStatus,
|
||||
resolveRepositoryDefaultBranch,
|
||||
} from '../utils/checkout-git.js'
|
||||
import { getProjectIcon } from '../utils/project-icon.js'
|
||||
import { expandTilde } from '../utils/path.js'
|
||||
@@ -1596,6 +1601,10 @@ export class Session {
|
||||
await this.handlePaseoWorktreeArchiveRequest(msg)
|
||||
break
|
||||
|
||||
case 'create_paseo_worktree_request':
|
||||
await this.handleCreatePaseoWorktreeRequest(msg)
|
||||
break
|
||||
|
||||
case 'open_project_request':
|
||||
await this.handleOpenProjectRequest(msg)
|
||||
break
|
||||
@@ -2870,19 +2879,21 @@ export class Session {
|
||||
`Creating worktree '${normalized.worktreeSlug ?? targetBranch}' for branch ${targetBranch}`
|
||||
)
|
||||
|
||||
const baseBranch = normalized.baseBranch ?? (await this.resolveGitCreateBaseBranch(cwd))
|
||||
const createdWorktree = await createAgentWorktree({
|
||||
branchName: targetBranch,
|
||||
cwd,
|
||||
baseBranch: normalized.baseBranch!,
|
||||
baseBranch,
|
||||
worktreeSlug: normalized.worktreeSlug ?? targetBranch,
|
||||
paseoHome: this.paseoHome,
|
||||
})
|
||||
cwd = createdWorktree.worktreePath
|
||||
worktreeConfig = createdWorktree
|
||||
} else if (normalized.createNewBranch) {
|
||||
const baseBranch = normalized.baseBranch ?? (await this.resolveGitCreateBaseBranch(cwd))
|
||||
await this.createBranchFromBase({
|
||||
cwd,
|
||||
baseBranch: normalized.baseBranch!,
|
||||
baseBranch,
|
||||
newBranchName: normalized.newBranchName!,
|
||||
})
|
||||
} else if (normalized.baseBranch) {
|
||||
@@ -3094,13 +3105,6 @@ export class Session {
|
||||
this.assertSafeGitRef(baseBranch, 'base branch')
|
||||
}
|
||||
|
||||
if (createWorktree && !baseBranch) {
|
||||
throw new Error('Base branch is required when creating a worktree')
|
||||
}
|
||||
if (createNewBranch && !baseBranch) {
|
||||
throw new Error('Base branch is required when creating a new branch')
|
||||
}
|
||||
|
||||
if (createNewBranch) {
|
||||
if (!normalizedBranchName) {
|
||||
throw new Error('New branch name is required')
|
||||
@@ -3133,6 +3137,20 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveGitCreateBaseBranch(cwd: string): Promise<string> {
|
||||
const checkout = await getCheckoutStatusLite(cwd, { paseoHome: this.paseoHome })
|
||||
if (!checkout.isGit) {
|
||||
throw new Error('Cannot create a worktree outside a git repository')
|
||||
}
|
||||
|
||||
const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : cwd
|
||||
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot)
|
||||
if (!baseBranch) {
|
||||
throw new Error('Unable to resolve repository default branch')
|
||||
}
|
||||
return baseBranch
|
||||
}
|
||||
|
||||
private toCheckoutError(error: unknown): CheckoutErrorPayload {
|
||||
if (error instanceof NotGitRepoError) {
|
||||
return { code: 'NOT_GIT_REPO', message: error.message }
|
||||
@@ -5677,6 +5695,54 @@ export class Session {
|
||||
return (await this.reconcileWorkspaceRecord(workspaceId)).workspace
|
||||
}
|
||||
|
||||
private async registerPendingWorktreeWorkspace(options: {
|
||||
repoRoot: string
|
||||
worktreePath: string
|
||||
branchName: string
|
||||
}): Promise<PersistedWorkspaceRecord> {
|
||||
const workspaceId = normalizePersistedWorkspaceId(options.worktreePath)
|
||||
const basePlacement = await this.buildProjectPlacement(options.repoRoot)
|
||||
const placement: ProjectPlacementPayload = {
|
||||
...basePlacement,
|
||||
checkout: {
|
||||
cwd: workspaceId,
|
||||
isGit: true,
|
||||
currentBranch: options.branchName,
|
||||
remoteUrl: basePlacement.checkout.remoteUrl,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: options.repoRoot,
|
||||
},
|
||||
}
|
||||
const now = new Date().toISOString()
|
||||
const existingWorkspace = await this.workspaceRegistry.get(workspaceId)
|
||||
const existingProject = await this.projectRegistry.get(placement.projectKey)
|
||||
const nextProjectRecord = this.buildPersistedProjectRecord({
|
||||
workspaceId,
|
||||
placement,
|
||||
createdAt: existingProject?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
})
|
||||
const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
placement,
|
||||
createdAt: existingWorkspace?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
await this.projectRegistry.upsert(nextProjectRecord)
|
||||
await this.workspaceRegistry.upsert(nextWorkspaceRecord)
|
||||
|
||||
if (
|
||||
existingWorkspace &&
|
||||
!existingWorkspace.archivedAt &&
|
||||
existingWorkspace.projectId !== nextWorkspaceRecord.projectId
|
||||
) {
|
||||
await this.archiveProjectRecordIfEmpty(existingWorkspace.projectId, now)
|
||||
}
|
||||
|
||||
return nextWorkspaceRecord
|
||||
}
|
||||
|
||||
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
|
||||
const existing = await this.workspaceRegistry.get(workspaceId)
|
||||
if (!existing || existing.archivedAt) {
|
||||
@@ -5910,6 +5976,129 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCreatePaseoWorktreeRequest(
|
||||
request: Extract<SessionInboundMessage, { type: 'create_paseo_worktree_request' }>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const checkout = await getCheckoutStatusLite(request.cwd, { paseoHome: this.paseoHome })
|
||||
if (!checkout.isGit) {
|
||||
throw new Error('Create worktree requires a git repository')
|
||||
}
|
||||
|
||||
const repoRoot = checkout.isPaseoOwnedWorktree ? checkout.mainRepoRoot : request.cwd
|
||||
const baseBranch = await resolveRepositoryDefaultBranch(repoRoot)
|
||||
if (!baseBranch) {
|
||||
throw new Error('Unable to resolve repository default branch')
|
||||
}
|
||||
|
||||
const normalizedSlug = request.worktreeSlug ? slugify(request.worktreeSlug) : uuidv4()
|
||||
const validation = validateBranchSlug(normalizedSlug)
|
||||
if (!validation.valid) {
|
||||
throw new Error(`Invalid worktree name: ${validation.error}`)
|
||||
}
|
||||
|
||||
const worktreePath = await computeWorktreePath(repoRoot, normalizedSlug, this.paseoHome)
|
||||
const workspace = await this.registerPendingWorktreeWorkspace({
|
||||
repoRoot,
|
||||
worktreePath,
|
||||
branchName: normalizedSlug,
|
||||
})
|
||||
const descriptor = await this.describeWorkspaceRecord(workspace)
|
||||
this.emit({
|
||||
type: 'create_paseo_worktree_response',
|
||||
payload: {
|
||||
workspace: descriptor,
|
||||
error: null,
|
||||
setupTerminalId: null,
|
||||
requestId: request.requestId,
|
||||
},
|
||||
})
|
||||
|
||||
void this.createPaseoWorktreeInBackground({
|
||||
requestCwd: request.cwd,
|
||||
repoRoot,
|
||||
baseBranch,
|
||||
slug: normalizedSlug,
|
||||
worktreePath,
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create worktree'
|
||||
this.sessionLogger.error(
|
||||
{ err: error, cwd: request.cwd, worktreeSlug: request.worktreeSlug },
|
||||
'Failed to create worktree'
|
||||
)
|
||||
this.emit({
|
||||
type: 'create_paseo_worktree_response',
|
||||
payload: {
|
||||
workspace: null,
|
||||
error: message,
|
||||
setupTerminalId: null,
|
||||
requestId: request.requestId,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async createPaseoWorktreeInBackground(options: {
|
||||
requestCwd: string
|
||||
repoRoot: string
|
||||
baseBranch: string
|
||||
slug: string
|
||||
worktreePath: string
|
||||
}): Promise<void> {
|
||||
let setupTerminalId: string | null = null
|
||||
|
||||
try {
|
||||
await createAgentWorktree({
|
||||
cwd: options.repoRoot,
|
||||
branchName: options.slug,
|
||||
baseBranch: options.baseBranch,
|
||||
worktreeSlug: options.slug,
|
||||
paseoHome: this.paseoHome,
|
||||
})
|
||||
|
||||
const setupCommands = getWorktreeSetupCommands(options.worktreePath)
|
||||
if (setupCommands.length > 0 && this.terminalManager) {
|
||||
const runtimeEnv = await resolveWorktreeRuntimeEnv({
|
||||
worktreePath: options.worktreePath,
|
||||
branchName: options.slug,
|
||||
repoRootPath: options.repoRoot,
|
||||
})
|
||||
this.terminalManager.registerCwdEnv({
|
||||
cwd: options.worktreePath,
|
||||
env: runtimeEnv,
|
||||
})
|
||||
const terminal = await this.terminalManager.createTerminal({
|
||||
cwd: options.worktreePath,
|
||||
name: `setup-${options.slug}`,
|
||||
env: runtimeEnv,
|
||||
})
|
||||
setupTerminalId = terminal.id
|
||||
|
||||
for (const command of setupCommands) {
|
||||
terminal.send({
|
||||
type: 'input',
|
||||
data: `${command}\r`,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.sessionLogger.error(
|
||||
{
|
||||
err: error,
|
||||
cwd: options.requestCwd,
|
||||
repoRoot: options.repoRoot,
|
||||
worktreeSlug: options.slug,
|
||||
worktreePath: options.worktreePath,
|
||||
setupTerminalId,
|
||||
},
|
||||
'Background worktree creation failed'
|
||||
)
|
||||
} finally {
|
||||
await this.emitWorkspaceUpdateForCwd(options.worktreePath)
|
||||
}
|
||||
}
|
||||
|
||||
private async handleArchiveWorkspaceRequest(
|
||||
request: Extract<SessionInboundMessage, { type: 'archive_workspace_request' }>
|
||||
): Promise<void> {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { execSync } from 'node:child_process'
|
||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { Session } from './session.js'
|
||||
import type { AgentSnapshotPayload } from '../shared/messages.js'
|
||||
@@ -335,55 +339,110 @@ describe('workspace aggregation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('create paseo worktree request returns a registered workspace descriptor', async () => {
|
||||
const emitted: Array<{ type: string; payload: unknown }> = []
|
||||
const session = createSessionForWorkspaceTests() as any
|
||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), 'session-worktree-test-')))
|
||||
const repoDir = path.join(tempDir, 'repo')
|
||||
const paseoHome = path.join(tempDir, 'paseo-home')
|
||||
execSync(`mkdir -p ${repoDir}`)
|
||||
execSync('git init -b main', { cwd: repoDir, stdio: 'pipe' })
|
||||
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: 'pipe' })
|
||||
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: 'pipe' })
|
||||
writeFileSync(path.join(repoDir, 'file.txt'), 'hello\n')
|
||||
execSync('git add .', { cwd: repoDir, stdio: 'pipe' })
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: 'pipe' })
|
||||
|
||||
const workspaces = new Map()
|
||||
const projects = new Map()
|
||||
session.paseoHome = paseoHome
|
||||
session.workspaceRegistry.get = async (workspaceId: string) => workspaces.get(workspaceId) ?? null
|
||||
session.workspaceRegistry.list = async () => Array.from(workspaces.values())
|
||||
session.workspaceRegistry.upsert = async (record: any) => {
|
||||
workspaces.set(record.workspaceId, record)
|
||||
}
|
||||
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null
|
||||
session.projectRegistry.list = async () => Array.from(projects.values())
|
||||
session.projectRegistry.upsert = async (record: any) => {
|
||||
projects.set(record.projectId, record)
|
||||
}
|
||||
session.emit = (message: { type: string; payload: unknown }) => {
|
||||
emitted.push(message)
|
||||
}
|
||||
try {
|
||||
await session.handleCreatePaseoWorktreeRequest({
|
||||
type: 'create_paseo_worktree_request',
|
||||
cwd: repoDir,
|
||||
worktreeSlug: 'worktree-123',
|
||||
requestId: 'req-worktree',
|
||||
})
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const response = emitted.find((message) => message.type === 'create_paseo_worktree_response') as
|
||||
| { type: 'create_paseo_worktree_response'; payload: any }
|
||||
| undefined
|
||||
|
||||
expect(response?.payload.error).toBeNull()
|
||||
expect(response?.payload.workspace).toMatchObject({
|
||||
projectDisplayName: 'repo',
|
||||
projectKind: 'git',
|
||||
workspaceKind: 'worktree',
|
||||
name: 'worktree-123',
|
||||
status: 'done',
|
||||
})
|
||||
expect(response?.payload.workspace?.id).toContain(path.join('worktree-123'))
|
||||
expect(workspaces.has(response?.payload.workspace?.id)).toBe(true)
|
||||
expect(projects.has(response?.payload.workspace?.projectId)).toBe(true)
|
||||
})
|
||||
|
||||
test('workspace update fanout for multiple cwd values is deduplicated', async () => {
|
||||
const emitted: Array<{ type: string; payload: unknown }> = []
|
||||
const session = createSessionForWorkspaceTests() as any
|
||||
session.emit = (message: any) => emitted.push(message)
|
||||
session.workspaceUpdatesSubscription = {
|
||||
subscriptionId: 'sub-dedupe',
|
||||
subscriptionId: 'sub-dedup',
|
||||
filter: undefined,
|
||||
isBootstrapping: false,
|
||||
pendingUpdatesByWorkspaceId: new Map(),
|
||||
}
|
||||
session.reconcileActiveWorkspaceRecords = async () => new Set()
|
||||
session.reconcileActiveWorkspaceRecords = async () => new Set(['/tmp/repo', '/tmp/repo/worktree'])
|
||||
session.listWorkspaceDescriptorsSnapshot = async () => [
|
||||
{
|
||||
id: '/tmp/repo',
|
||||
projectId: '/tmp/repo',
|
||||
projectDisplayName: 'repo',
|
||||
projectRootPath: '/tmp/repo',
|
||||
projectKind: 'non_git',
|
||||
workspaceKind: 'directory',
|
||||
name: 'repo',
|
||||
projectKind: 'git',
|
||||
workspaceKind: 'local_checkout',
|
||||
name: 'main',
|
||||
status: 'done',
|
||||
activityAt: null,
|
||||
},
|
||||
{
|
||||
id: '/tmp/repo/sub',
|
||||
id: '/tmp/repo/worktree',
|
||||
projectId: '/tmp/repo',
|
||||
projectDisplayName: 'repo',
|
||||
projectRootPath: '/tmp/repo',
|
||||
projectKind: 'non_git',
|
||||
workspaceKind: 'directory',
|
||||
name: 'sub',
|
||||
status: 'done',
|
||||
activityAt: null,
|
||||
projectKind: 'git',
|
||||
workspaceKind: 'worktree',
|
||||
name: 'feature',
|
||||
status: 'running',
|
||||
activityAt: '2026-03-01T12:00:00.000Z',
|
||||
},
|
||||
]
|
||||
session.onMessage = (message: { type: string; payload: unknown }) => {
|
||||
emitted.push(message)
|
||||
}
|
||||
|
||||
await session.emitWorkspaceUpdatesForCwds([
|
||||
'/tmp/repo',
|
||||
'/tmp/repo/',
|
||||
' /tmp/repo ',
|
||||
'/tmp/repo/sub',
|
||||
'/tmp/repo/sub/',
|
||||
])
|
||||
await session.emitWorkspaceUpdateForCwd('/tmp/repo/worktree')
|
||||
|
||||
const workspaceUpdates = emitted.filter((message) => message.type === 'workspace_update') as any[]
|
||||
expect(workspaceUpdates).toHaveLength(2)
|
||||
expect(workspaceUpdates.map((message) => message.payload.workspace.id)).toEqual([
|
||||
expect(workspaceUpdates.map((entry) => entry.payload.kind)).toEqual(['upsert', 'upsert'])
|
||||
expect(workspaceUpdates.map((entry) => entry.payload.workspace.id).sort()).toEqual([
|
||||
'/tmp/repo',
|
||||
'/tmp/repo/sub',
|
||||
'/tmp/repo/worktree',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -69,6 +69,17 @@ export function normalizeHostPort(input: string): string {
|
||||
return isIpv6 ? `[${host}]:${port}` : `${host}:${port}`;
|
||||
}
|
||||
|
||||
export function normalizeLoopbackToLocalhost(endpoint: string): string {
|
||||
const { host, port, isIpv6 } = parseHostPort(endpoint);
|
||||
if (host === "127.0.0.1" || (!isIpv6 && host === "0.0.0.0")) {
|
||||
return `localhost:${port}`;
|
||||
}
|
||||
if (isIpv6 && (host === "::1" || host === "::")) {
|
||||
return `localhost:${port}`;
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
export function deriveLabelFromEndpoint(endpoint: string): string {
|
||||
try {
|
||||
const { host } = parseHostPort(endpoint);
|
||||
|
||||
@@ -936,6 +936,13 @@ export const PaseoWorktreeArchiveRequestSchema = z.object({
|
||||
requestId: z.string(),
|
||||
})
|
||||
|
||||
export const CreatePaseoWorktreeRequestSchema = z.object({
|
||||
type: z.literal('create_paseo_worktree_request'),
|
||||
cwd: z.string(),
|
||||
worktreeSlug: z.string().optional(),
|
||||
requestId: z.string(),
|
||||
})
|
||||
|
||||
export const OpenProjectRequestSchema = z.object({
|
||||
type: z.literal('open_project_request'),
|
||||
cwd: z.string(),
|
||||
@@ -1186,6 +1193,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion('type', [
|
||||
DirectorySuggestionsRequestSchema,
|
||||
PaseoWorktreeListRequestSchema,
|
||||
PaseoWorktreeArchiveRequestSchema,
|
||||
CreatePaseoWorktreeRequestSchema,
|
||||
OpenProjectRequestSchema,
|
||||
ArchiveWorkspaceRequestSchema,
|
||||
FileExplorerRequestSchema,
|
||||
@@ -1961,6 +1969,16 @@ export const PaseoWorktreeArchiveResponseSchema = z.object({
|
||||
}),
|
||||
})
|
||||
|
||||
export const CreatePaseoWorktreeResponseSchema = z.object({
|
||||
type: z.literal('create_paseo_worktree_response'),
|
||||
payload: z.object({
|
||||
workspace: WorkspaceDescriptorPayloadSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
setupTerminalId: z.string().nullable(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
})
|
||||
|
||||
export const FileExplorerResponseSchema = z.object({
|
||||
type: z.literal('file_explorer_response'),
|
||||
payload: z.object({
|
||||
@@ -2237,6 +2255,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion('type', [
|
||||
DirectorySuggestionsResponseSchema,
|
||||
PaseoWorktreeListResponseSchema,
|
||||
PaseoWorktreeArchiveResponseSchema,
|
||||
CreatePaseoWorktreeResponseSchema,
|
||||
FileExplorerResponseSchema,
|
||||
ProjectIconResponseSchema,
|
||||
FileDownloadTokenResponseSchema,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
MergeFromBaseConflictError,
|
||||
NotGitRepoError,
|
||||
pushCurrentBranch,
|
||||
resolveRepositoryDefaultBranch,
|
||||
} from "./checkout-git.js";
|
||||
import { createWorktree } from "./worktree.js";
|
||||
import { getPaseoWorktreeMetadataPath } from "./worktree-metadata.js";
|
||||
@@ -707,6 +708,17 @@ describe("checkout git utilities", () => {
|
||||
expect(baseDiff.diff).not.toContain("file.txt");
|
||||
});
|
||||
|
||||
it("resolves the repository default branch from origin HEAD", async () => {
|
||||
execSync("git checkout -b develop", { cwd: repoDir });
|
||||
execSync("git checkout main", { cwd: repoDir });
|
||||
execSync("git remote add origin https://github.com/acme/repo.git", { cwd: repoDir });
|
||||
execSync("git update-ref refs/remotes/origin/main refs/heads/main", { cwd: repoDir });
|
||||
execSync("git update-ref refs/remotes/origin/develop refs/heads/develop", { cwd: repoDir });
|
||||
execSync("git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main", { cwd: repoDir });
|
||||
|
||||
await expect(resolveRepositoryDefaultBranch(repoDir)).resolves.toBe("main");
|
||||
});
|
||||
|
||||
it("merges to stored baseRefName when baseRef is not provided", async () => {
|
||||
// Create a non-default base branch with a unique commit.
|
||||
execSync("git checkout -b develop", { cwd: repoDir });
|
||||
|
||||
@@ -708,7 +708,7 @@ async function hasOriginRemote(cwd: string): Promise<boolean> {
|
||||
return url !== null;
|
||||
}
|
||||
|
||||
async function resolveBaseRef(repoRoot: string): Promise<string | null> {
|
||||
export async function resolveRepositoryDefaultBranch(repoRoot: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execAsync("git symbolic-ref --quiet refs/remotes/origin/HEAD", {
|
||||
cwd: repoRoot,
|
||||
@@ -755,6 +755,10 @@ async function resolveBaseRef(repoRoot: string): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveBaseRef(repoRoot: string): Promise<string | null> {
|
||||
return resolveRepositoryDefaultBranch(repoRoot);
|
||||
}
|
||||
|
||||
function normalizeLocalBranchRefName(input: string): string {
|
||||
return input.startsWith("origin/") ? input.slice("origin/".length) : input;
|
||||
}
|
||||
|
||||
@@ -668,12 +668,21 @@ export async function deriveWorktreeProjectHash(cwd: string): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
async function getPaseoWorktreesRoot(cwd: string, paseoHome?: string): Promise<string> {
|
||||
export async function getPaseoWorktreesRoot(cwd: string, paseoHome?: string): Promise<string> {
|
||||
const home = paseoHome ? resolve(paseoHome) : resolvePaseoHome();
|
||||
const projectHash = await deriveWorktreeProjectHash(cwd);
|
||||
return join(home, "worktrees", projectHash);
|
||||
}
|
||||
|
||||
export async function computeWorktreePath(
|
||||
cwd: string,
|
||||
slug: string,
|
||||
paseoHome?: string
|
||||
): Promise<string> {
|
||||
const worktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome);
|
||||
return join(worktreesRoot, slug);
|
||||
}
|
||||
|
||||
function normalizePathForOwnership(input: string): string {
|
||||
try {
|
||||
return realpathSync(input);
|
||||
|
||||
Reference in New Issue
Block a user