mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
refactor: share workspace creation behavior across clients
This commit is contained in:
@@ -1428,6 +1428,7 @@ export function HostScreen({
|
||||
client={client}
|
||||
hostId={hostId}
|
||||
existingWorktreePaths={existingWorktreePaths}
|
||||
existingWorktrees={worktrees}
|
||||
onVisibleChange={(visible) => {
|
||||
newWorktreeModalVisibleRef.current = visible
|
||||
}}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ChevronDown, ChevronUp } from 'lucide-react-native'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse, RpcSuccess } from '../transport/types'
|
||||
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
import { BottomDrawer, BOTTOM_DRAWER_HIDE_DURATION_MS } from './BottomDrawer'
|
||||
import { PickerListDrawer } from './PickerListDrawer'
|
||||
import { MobileAgentIcon } from './MobileAgentIcon'
|
||||
import { getSuggestedCreatureName } from './worktree-name-suggestion'
|
||||
@@ -59,6 +59,8 @@ import {
|
||||
import { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
|
||||
import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes'
|
||||
import { deriveRepoSlug, type PasteRepoCandidate } from '../tasks/smart-source-paste-intent'
|
||||
import { shouldPreserveWorkspaceSourceOnRepoChange } from '../../../src/shared/new-workspace/workspace-source'
|
||||
import { getComposerRepoWorktreeBranches } from '../../../src/shared/composer-branch-selection'
|
||||
import { SmartWorkspaceSourceField } from './SmartWorkspaceSourceField'
|
||||
import { SmartWorkspaceSourceDrawer } from './SmartWorkspaceSourceDrawer'
|
||||
import { SmartWorkspaceAdvancedFields } from './SmartWorkspaceAdvancedFields'
|
||||
@@ -108,6 +110,12 @@ type CreateOptions = {
|
||||
approvedSetupContentHash?: string
|
||||
}
|
||||
|
||||
type NewWorktreeDrawerView = 'form' | 'transition' | 'source' | 'repo' | 'agent' | 'trust'
|
||||
|
||||
// Why: iOS cannot reliably present a second native modal until the first drawer's
|
||||
// exit commits; one extra frame keeps transitions sequential on slower devices.
|
||||
const NEW_WORKTREE_DRAWER_TRANSITION_MS = BOTTOM_DRAWER_HIDE_DURATION_MS + 16
|
||||
|
||||
function repoColor(name: string): string {
|
||||
const palette = ['#f97316', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f59e0b', '#6366f1']
|
||||
let hash = 0
|
||||
@@ -133,6 +141,7 @@ type Props = {
|
||||
// on the on-disk directory basename, so paths (not displayNames) are
|
||||
// what the suggestion logic must dedupe against.
|
||||
existingWorktreePaths?: readonly string[]
|
||||
existingWorktrees?: readonly { repoId: string; branch: string }[]
|
||||
onCreated: (worktreeId: string, name: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
@@ -142,6 +151,7 @@ export function NewWorktreeModal({
|
||||
client,
|
||||
hostId,
|
||||
existingWorktreePaths,
|
||||
existingWorktrees,
|
||||
onCreated,
|
||||
onClose
|
||||
}: Props) {
|
||||
@@ -166,6 +176,7 @@ export function NewWorktreeModal({
|
||||
client={client}
|
||||
hostId={hostId}
|
||||
existingWorktreePaths={existingWorktreePaths}
|
||||
existingWorktrees={existingWorktrees}
|
||||
onCreated={onCreated}
|
||||
onClose={onClose}
|
||||
/>
|
||||
@@ -177,21 +188,21 @@ function NewWorktreeModalContent({
|
||||
client,
|
||||
hostId,
|
||||
existingWorktreePaths,
|
||||
existingWorktrees,
|
||||
onCreated,
|
||||
onClose
|
||||
}: Props) {
|
||||
const [initialRepos] = useState(() => (hostId ? (getCachedRepos(hostId) as Repo[] | null) : null))
|
||||
const [repos, setRepos] = useState<Repo[]>(initialRepos ?? [])
|
||||
const [selectedRepo, setSelectedRepo] = useState<Repo | null>(null)
|
||||
const [showRepoPicker, setShowRepoPicker] = useState(false)
|
||||
const [showSourceDrawer, setShowSourceDrawer] = useState(false)
|
||||
const [drawerView, setDrawerView] = useState<NewWorktreeDrawerView>('form')
|
||||
const drawerTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const [selectedAgentState, setSelectedAgent] = useState<AgentOption>(AGENT_OPTIONS[0]!)
|
||||
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings | null>(null)
|
||||
const [detectedAgentIdsState, setDetectedAgentIdsState] = useState<DetectedAgentIdsState | null>(
|
||||
null
|
||||
)
|
||||
const [agentOverriddenState, setAgentOverridden] = useState(false)
|
||||
const [showAgentPicker, setShowAgentPicker] = useState(false)
|
||||
const [sshState, setSshState] = useState<SshConnectionState | null>(null)
|
||||
const [sshConnectingTargetId, setSshConnectingTargetId] = useState<string | null>(null)
|
||||
const [note, setNote] = useState('')
|
||||
@@ -210,6 +221,29 @@ function NewWorktreeModalContent({
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(initialRepos == null)
|
||||
const lastVisitedRepo = useLastVisitedWorktreeRepoId(hostId, visible)
|
||||
const selectedRepoWorktreeBranches = useMemo(
|
||||
() => getComposerRepoWorktreeBranches(existingWorktrees ?? [], selectedRepo?.id ?? null),
|
||||
[existingWorktrees, selectedRepo]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (drawerTransitionTimerRef.current) {
|
||||
clearTimeout(drawerTransitionTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
function transitionDrawer(nextView: Exclude<NewWorktreeDrawerView, 'transition'>): void {
|
||||
if (drawerTransitionTimerRef.current) {
|
||||
clearTimeout(drawerTransitionTimerRef.current)
|
||||
}
|
||||
setDrawerView('transition')
|
||||
drawerTransitionTimerRef.current = setTimeout(() => {
|
||||
drawerTransitionTimerRef.current = null
|
||||
setDrawerView(nextView)
|
||||
}, NEW_WORKTREE_DRAWER_TRANSITION_MS)
|
||||
}
|
||||
|
||||
// The Smart source picker owns the workspace name AND the linked-source
|
||||
// selection: typing names the workspace and drives source search, and picking
|
||||
@@ -218,6 +252,7 @@ function NewWorktreeModalContent({
|
||||
const composer = useMobileComposerSource({
|
||||
client,
|
||||
selectedRepoId: selectedRepo?.id ?? null,
|
||||
worktreeBranches: selectedRepoWorktreeBranches,
|
||||
onError: setError
|
||||
})
|
||||
|
||||
@@ -661,6 +696,7 @@ function NewWorktreeModalContent({
|
||||
contentHash: setupTrust.contentHash,
|
||||
previouslyApproved: wasSetupHookPreviouslyApproved(trustedOrcaHooks, selectedRepo.id)
|
||||
})
|
||||
transitionDrawer('trust')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -737,9 +773,9 @@ function NewWorktreeModalContent({
|
||||
function handleRepoSelected(repo: Repo): void {
|
||||
const repoChanged = repo.id !== selectedRepo?.id
|
||||
setSelectedRepo(repo)
|
||||
// Branch and GitHub/GitLab sources are tied to the repo they were searched
|
||||
// from; switching repos invalidates them, so clear the selection.
|
||||
if (repoChanged) {
|
||||
// Branch and provider-backed sources are repo-scoped; Linear/Jira are global
|
||||
// work context and survive choosing a different implementation repo.
|
||||
if (repoChanged && !shouldPreserveWorkspaceSourceOnRepoChange(composer.linkedWorkItem)) {
|
||||
composer.handleClearSmartNameSelection()
|
||||
}
|
||||
}
|
||||
@@ -756,6 +792,7 @@ function NewWorktreeModalContent({
|
||||
)
|
||||
const approvedHash = setupTrustPrompt.contentHash
|
||||
setSetupTrustPrompt(null)
|
||||
transitionDrawer('form')
|
||||
await handleCreate({ setupOverride: 'run', approvedSetupContentHash: approvedHash })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to trust setup script.')
|
||||
@@ -764,7 +801,7 @@ function NewWorktreeModalContent({
|
||||
|
||||
return (
|
||||
<>
|
||||
<BottomDrawer visible={visible} onClose={onClose}>
|
||||
<BottomDrawer visible={visible && drawerView === 'form'} onClose={onClose}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Create Workspace</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
@@ -788,7 +825,7 @@ function NewWorktreeModalContent({
|
||||
style={styles.fieldButton}
|
||||
onPress={() => {
|
||||
prepareSelectionPickerOpen()
|
||||
setShowRepoPicker(true)
|
||||
transitionDrawer('repo')
|
||||
}}
|
||||
>
|
||||
{selectedRepo ? (
|
||||
@@ -811,9 +848,13 @@ function NewWorktreeModalContent({
|
||||
label={selectedRepoIsGit ? "Name or 'Create From'" : 'Workspace name'}
|
||||
disabled={sshGate.requiresConnection}
|
||||
onBeforeOpen={() => setError('')}
|
||||
onOpenDrawer={() => setShowSourceDrawer(true)}
|
||||
onOpenDrawer={() => transitionDrawer('source')}
|
||||
/>
|
||||
|
||||
{composer.forkPushWarning ? (
|
||||
<Text style={styles.sourceWarning}>{composer.forkPushWarning}</Text>
|
||||
) : null}
|
||||
|
||||
{selectedRepoConnectionId ? (
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>SSH Connection</Text>
|
||||
@@ -864,7 +905,7 @@ function NewWorktreeModalContent({
|
||||
disabled={sshGate.requiresConnection}
|
||||
onPress={() => {
|
||||
prepareSelectionPickerOpen()
|
||||
setShowAgentPicker(true)
|
||||
transitionDrawer('agent')
|
||||
}}
|
||||
>
|
||||
<MobileAgentIcon agentId={selectedAgent.id} size={16} />
|
||||
@@ -980,10 +1021,10 @@ function NewWorktreeModalContent({
|
||||
)}
|
||||
</BottomDrawer>
|
||||
|
||||
{/* Why: list drawers must be siblings of the form drawer so their virtualized
|
||||
lists are not nested inside the form's vertical ScrollView. */}
|
||||
{/* Why: list drawers stay outside the form's ScrollView, and the transition
|
||||
state prevents overlapping native modals from swallowing iOS taps. */}
|
||||
<SmartWorkspaceSourceDrawer
|
||||
visible={visible && showSourceDrawer}
|
||||
visible={visible && drawerView === 'source'}
|
||||
client={client}
|
||||
composer={composer}
|
||||
availability={sourceAvailability}
|
||||
@@ -996,23 +1037,23 @@ function NewWorktreeModalContent({
|
||||
setSelectedRepo(nextRepo)
|
||||
}
|
||||
}}
|
||||
onClose={() => setShowSourceDrawer(false)}
|
||||
onClose={() => transitionDrawer('form')}
|
||||
/>
|
||||
|
||||
<PickerListDrawer
|
||||
visible={visible && showRepoPicker}
|
||||
visible={visible && drawerView === 'repo'}
|
||||
title="Repository"
|
||||
items={repoPickerItems}
|
||||
selectedId={selectedRepo?.id ?? ''}
|
||||
onSelect={(item) => handleRepoSelected(item.repo)}
|
||||
onClose={() => setShowRepoPicker(false)}
|
||||
onClose={() => transitionDrawer('form')}
|
||||
renderIcon={(item) => {
|
||||
return <View style={[styles.repoDot, { backgroundColor: repoBadgeColor(item.repo) }]} />
|
||||
}}
|
||||
/>
|
||||
|
||||
<PickerListDrawer
|
||||
visible={visible && showAgentPicker}
|
||||
visible={visible && drawerView === 'agent'}
|
||||
title="Agent"
|
||||
items={pickerAgentOptions}
|
||||
selectedId={selectedAgent.id}
|
||||
@@ -1020,21 +1061,25 @@ function NewWorktreeModalContent({
|
||||
setAgentOverridden(true)
|
||||
setSelectedAgent(agent)
|
||||
}}
|
||||
onClose={() => setShowAgentPicker(false)}
|
||||
onClose={() => transitionDrawer('form')}
|
||||
renderIcon={(agent) => <MobileAgentIcon agentId={agent.id} size={18} />}
|
||||
/>
|
||||
|
||||
<SetupHookTrustDrawer
|
||||
visible={visible && setupTrustPrompt != null}
|
||||
visible={visible && drawerView === 'trust' && setupTrustPrompt != null}
|
||||
prompt={setupTrustPrompt}
|
||||
busy={creating}
|
||||
onRunOnce={() => void approveSetupTrust(false)}
|
||||
onAlwaysTrust={() => void approveSetupTrust(true)}
|
||||
onDontRun={() => {
|
||||
setSetupTrustPrompt(null)
|
||||
transitionDrawer('form')
|
||||
void handleCreate({ setupOverride: 'skip' })
|
||||
}}
|
||||
onClose={() => setSetupTrustPrompt(null)}
|
||||
onClose={() => {
|
||||
setSetupTrustPrompt(null)
|
||||
transitionDrawer('form')
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
@@ -1176,6 +1221,12 @@ const styles = StyleSheet.create({
|
||||
fontSize: 13,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
sourceWarning: {
|
||||
marginTop: -spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
fontSize: 12,
|
||||
color: colors.statusAmber
|
||||
},
|
||||
advancedToggle: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -12,6 +12,7 @@ type Props = {
|
||||
client: RpcClient | null
|
||||
hostId?: string
|
||||
existingWorktreePaths?: readonly string[]
|
||||
existingWorktrees?: readonly { repoId: string; branch: string }[]
|
||||
onVisibleChange?: (visible: boolean) => void
|
||||
onRouteVisibleChange: (visible: boolean) => void
|
||||
onCreated: (worktreeId: string, name: string) => void
|
||||
@@ -24,6 +25,7 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
|
||||
client,
|
||||
hostId,
|
||||
existingWorktreePaths,
|
||||
existingWorktrees,
|
||||
onVisibleChange,
|
||||
onRouteVisibleChange,
|
||||
onCreated
|
||||
@@ -58,6 +60,7 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
|
||||
client={client}
|
||||
hostId={hostId}
|
||||
existingWorktreePaths={existingWorktreePaths}
|
||||
existingWorktrees={existingWorktrees}
|
||||
onCreated={onCreated}
|
||||
onClose={close}
|
||||
/>
|
||||
|
||||
@@ -58,9 +58,9 @@ describe('shouldApplyAutoName', () => {
|
||||
expect(shouldApplyAutoName({ currentName: 'fix-bug', lastAutoName: 'fix-bug' })).toBe(true)
|
||||
})
|
||||
|
||||
it('applies when the name is a lookup query (URL / #N / linear id)', () => {
|
||||
it('applies when the name is a lookup query (URL / #N)', () => {
|
||||
expect(shouldApplyAutoName({ currentName: '#42', lastAutoName: 'x' })).toBe(true)
|
||||
expect(shouldApplyAutoName({ currentName: 'ENG-9', lastAutoName: 'x' })).toBe(true)
|
||||
expect(shouldApplyAutoName({ currentName: 'ENG-9', lastAutoName: 'x' })).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a deliberately typed name', () => {
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types'
|
||||
import { getLinearIssueWorkspaceName } from '../../../src/shared/workspace-name'
|
||||
import {
|
||||
getLinearIssueWorkspaceName,
|
||||
getLinkedWorkItemSuggestedName,
|
||||
getLinkedWorkItemWorkspaceName
|
||||
} from '../../../src/shared/workspace-name'
|
||||
import { getLinearOrganizationUrlKeyFromIssueUrl } from '../../../src/shared/linear-links'
|
||||
import { isWorkItemLookupText } from './work-item-lookup-text'
|
||||
import {
|
||||
isBranchCheckedOutInWorktrees,
|
||||
resolveComposerBranchReuse,
|
||||
resolveComposerBranchSelection,
|
||||
resolveComposerReuseOverride
|
||||
} from '../../../src/shared/composer-branch-selection'
|
||||
buildGitHubWorkspaceSource,
|
||||
buildGitLabWorkspaceSource,
|
||||
buildLinearWorkspaceSource,
|
||||
buildWorkspaceSourceSelection,
|
||||
getWorkspaceSourceName,
|
||||
shouldApplyWorkspaceSourceAutoName
|
||||
} from '../../../src/shared/new-workspace/workspace-source'
|
||||
import { resolveComposerBranchPick as resolveSharedComposerBranchPick } from '../../../src/shared/composer-branch-selection'
|
||||
import type {
|
||||
MobileComposerCreateSelection,
|
||||
MobileLinkedWorkItem,
|
||||
@@ -26,14 +23,7 @@ export function buildGitHubLinkedWorkItem(item: {
|
||||
url: string
|
||||
repoId: string
|
||||
}): MobileLinkedWorkItem {
|
||||
return {
|
||||
provider: 'github',
|
||||
type: item.type,
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
repoId: item.repoId
|
||||
}
|
||||
return buildGitHubWorkspaceSource(item)
|
||||
}
|
||||
|
||||
export function buildGitLabLinkedWorkItem(item: {
|
||||
@@ -43,14 +33,7 @@ export function buildGitLabLinkedWorkItem(item: {
|
||||
url: string
|
||||
repoId: string
|
||||
}): MobileLinkedWorkItem {
|
||||
return {
|
||||
provider: 'gitlab',
|
||||
type: item.type,
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
repoId: item.repoId
|
||||
}
|
||||
return buildGitLabWorkspaceSource(item)
|
||||
}
|
||||
|
||||
export function buildLinearLinkedWorkItem(issue: {
|
||||
@@ -59,28 +42,14 @@ export function buildLinearLinkedWorkItem(issue: {
|
||||
url: string
|
||||
workspaceId?: string
|
||||
}): MobileLinkedWorkItem {
|
||||
const orgUrlKey = getLinearOrganizationUrlKeyFromIssueUrl(issue.url)
|
||||
return {
|
||||
provider: 'linear',
|
||||
type: 'issue',
|
||||
number: 0,
|
||||
title: issue.title,
|
||||
url: issue.url,
|
||||
linearIdentifier: issue.identifier,
|
||||
...(issue.workspaceId ? { linearWorkspaceId: issue.workspaceId } : {}),
|
||||
...(orgUrlKey ? { linearOrganizationUrlKey: orgUrlKey } : {})
|
||||
}
|
||||
return buildLinearWorkspaceSource(issue)
|
||||
}
|
||||
|
||||
// Faithful port of desktop applyLinkedWorkItem's name gate: the derived name
|
||||
// replaces the current field only when it's empty, still the last auto-name, or
|
||||
// a lookup query — never a name the user deliberately typed.
|
||||
export function shouldApplyAutoName(args: { currentName: string; lastAutoName: string }): boolean {
|
||||
return (
|
||||
!args.currentName.trim() ||
|
||||
args.currentName === args.lastAutoName ||
|
||||
isWorkItemLookupText(args.currentName)
|
||||
)
|
||||
return shouldApplyWorkspaceSourceAutoName(args)
|
||||
}
|
||||
|
||||
export function resolveWorkItemAutoName(item: {
|
||||
@@ -90,7 +59,7 @@ export function resolveWorkItemAutoName(item: {
|
||||
provider: 'github' | 'gitlab' | 'linear'
|
||||
linearIdentifier?: string
|
||||
}): string {
|
||||
return getLinkedWorkItemWorkspaceName(item)?.seedName ?? getLinkedWorkItemSuggestedName(item)
|
||||
return getWorkspaceSourceName({ ...item, url: '' }).seedName
|
||||
}
|
||||
|
||||
export function resolveLinearAutoName(issue: { identifier: string; title: string }): string {
|
||||
@@ -103,31 +72,7 @@ export function buildSmartNameSelection(args: {
|
||||
linkedWorkItem: MobileLinkedWorkItem | null
|
||||
baseBranch: string | undefined
|
||||
}): SmartNameSelection | null {
|
||||
const { linkedWorkItem, baseBranch } = args
|
||||
if (linkedWorkItem) {
|
||||
const isLinear = linkedWorkItem.provider === 'linear'
|
||||
const kind: SmartNameSelection['kind'] = isLinear
|
||||
? 'linear'
|
||||
: linkedWorkItem.provider === 'gitlab'
|
||||
? linkedWorkItem.type === 'mr'
|
||||
? 'gitlab-mr'
|
||||
: 'gitlab-issue'
|
||||
: linkedWorkItem.type === 'pr'
|
||||
? 'github-pr'
|
||||
: 'github-issue'
|
||||
return {
|
||||
kind,
|
||||
label:
|
||||
isLinear || linkedWorkItem.number === 0
|
||||
? linkedWorkItem.title
|
||||
: `#${linkedWorkItem.number} ${linkedWorkItem.title}`,
|
||||
url: linkedWorkItem.url
|
||||
}
|
||||
}
|
||||
if (baseBranch) {
|
||||
return { kind: 'branch', label: baseBranch }
|
||||
}
|
||||
return null
|
||||
return buildWorkspaceSourceSelection(args) as SmartNameSelection | null
|
||||
}
|
||||
|
||||
// Derives the create-time selection from composer state: a linked work item wins
|
||||
@@ -191,33 +136,14 @@ export function resolveComposerBranchPick(args: {
|
||||
lastAutoName: string
|
||||
worktreeBranches: readonly string[]
|
||||
}): ComposerBranchPick {
|
||||
const { refName, localBranchName } = args
|
||||
const selection = resolveComposerBranchSelection({
|
||||
refName,
|
||||
localBranchName,
|
||||
currentName: args.currentName,
|
||||
lastAutoName: args.lastAutoName
|
||||
})
|
||||
const branchCheckedOutElsewhere = isBranchCheckedOutInWorktrees(
|
||||
localBranchName,
|
||||
args.worktreeBranches
|
||||
)
|
||||
const { reuseEligibleBranch, defaultReuse } = resolveComposerBranchReuse({
|
||||
refName,
|
||||
localBranchName,
|
||||
selectionProducedOverride: selection.branchNameOverride !== undefined,
|
||||
branchCheckedOutElsewhere
|
||||
})
|
||||
const effectiveOverride = resolveComposerReuseOverride({
|
||||
refName,
|
||||
localBranchName,
|
||||
branchNameOverride: selection.branchNameOverride,
|
||||
branchCheckedOutElsewhere
|
||||
})
|
||||
const selection = resolveSharedComposerBranchPick(args)
|
||||
return {
|
||||
base: { baseBranch: selection.baseBranch, branchNameOverride: effectiveOverride },
|
||||
reuseEligibleBranch,
|
||||
reuseSelectedBranch: defaultReuse,
|
||||
base: {
|
||||
baseBranch: selection.baseBranch,
|
||||
branchNameOverride: selection.branchNameOverride
|
||||
},
|
||||
reuseEligibleBranch: selection.reuseEligibleBranch,
|
||||
reuseSelectedBranch: selection.defaultReuse,
|
||||
...(selection.name !== undefined && selection.lastAutoName !== undefined
|
||||
? { name: selection.name, lastAutoName: selection.lastAutoName }
|
||||
: {})
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import type { WorkspaceCreateGitPushTarget } from './workspace-create-params'
|
||||
import type { GitHubPrStartPoint } from '../../../src/shared/types'
|
||||
|
||||
// The resolved start point for a linked PR/MR: the base branch to create from
|
||||
// plus the optional review-compare ref, push target, and exact branch name.
|
||||
export type ComposerHostedBase = {
|
||||
baseBranch: string
|
||||
compareBaseRef?: string
|
||||
pushTarget?: WorkspaceCreateGitPushTarget
|
||||
branchNameOverride?: string
|
||||
}
|
||||
export type ComposerHostedBase = Pick<
|
||||
GitHubPrStartPoint,
|
||||
'baseBranch' | 'compareBaseRef' | 'pushTarget' | 'branchNameOverride' | 'maintainerCanModify'
|
||||
>
|
||||
|
||||
type HostedBaseResult = ComposerHostedBase | { error: string }
|
||||
|
||||
@@ -23,7 +21,7 @@ export async function resolveComposerPrBase(args: {
|
||||
headRefName?: string
|
||||
baseRefName?: string
|
||||
isCrossRepository?: boolean
|
||||
}): Promise<ComposerHostedBase> {
|
||||
}): Promise<GitHubPrStartPoint> {
|
||||
const { client, repoId, prNumber, headRefName, baseRefName, isCrossRepository } = args
|
||||
const response = await client.sendRequest(
|
||||
'worktree.resolvePrBase',
|
||||
@@ -39,7 +37,7 @@ export async function resolveComposerPrBase(args: {
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
const result = (response as RpcSuccess).result as HostedBaseResult
|
||||
const result = (response as RpcSuccess).result as GitHubPrStartPoint | { error: string }
|
||||
if ('error' in result) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import type { SmartNameMode } from '../../../src/shared/new-workspace/smart-workspace-source-results'
|
||||
import type {
|
||||
WorkspaceSourceLinkedItem,
|
||||
WorkspaceSourceSelection
|
||||
} from '../../../src/shared/new-workspace/workspace-source'
|
||||
import type { WorkspaceCreateGitPushTarget } from './workspace-create-params'
|
||||
|
||||
export type { SmartNameMode }
|
||||
|
||||
export type ComposerBaseState = {
|
||||
baseBranch?: string
|
||||
compareBaseRef?: string
|
||||
pushTarget?: WorkspaceCreateGitPushTarget
|
||||
branchNameOverride?: string
|
||||
}
|
||||
|
||||
// Mirrors the desktop composer's `linkedWorkItem` (a FolderWorkspaceLinkedTask
|
||||
// superset): the one work item a Smart selection pins the workspace to. Linear
|
||||
// items carry the workspace/org routing the runtime needs to relink the issue.
|
||||
export type MobileLinkedWorkItem = {
|
||||
provider: 'github' | 'gitlab' | 'linear'
|
||||
type: 'issue' | 'pr' | 'mr'
|
||||
number: number
|
||||
title: string
|
||||
url: string
|
||||
repoId?: string
|
||||
linearIdentifier?: string
|
||||
linearWorkspaceId?: string
|
||||
linearOrganizationUrlKey?: string
|
||||
export type MobileLinkedWorkItem = Omit<WorkspaceSourceLinkedItem, 'provider'> & {
|
||||
provider: Exclude<WorkspaceSourceLinkedItem['provider'], 'jira'>
|
||||
}
|
||||
|
||||
export type SmartNameSelectionKind =
|
||||
@@ -28,10 +31,8 @@ export type SmartNameSelectionKind =
|
||||
|
||||
// The pill descriptor the field renders once a source is selected. Same shape
|
||||
// as desktop's `SmartWorkspaceNameSelection`.
|
||||
export type SmartNameSelection = {
|
||||
export type SmartNameSelection = Omit<WorkspaceSourceSelection, 'kind'> & {
|
||||
kind: SmartNameSelectionKind
|
||||
label: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
// GitLab MR-state filter chips, mirroring desktop's getMrStateFilters(). Default
|
||||
|
||||
@@ -96,4 +96,17 @@ describe('fanOutSmartSearch', () => {
|
||||
await fanOutSmartSearch({ ...smartArgs, githubAvailable: false, client })
|
||||
expect(calls.map((c) => c.method)).not.toContain('github.listWorkItems')
|
||||
})
|
||||
|
||||
it('does not send oversized source queries to any provider', async () => {
|
||||
const calls: Call[] = []
|
||||
const client = fakeClient({}, calls)
|
||||
const result = await fanOutSmartSearch({ ...smartArgs, query: 'x'.repeat(2049), client })
|
||||
expect(calls).toEqual([])
|
||||
expect(result).toMatchObject({
|
||||
githubItems: [],
|
||||
gitlabItems: [],
|
||||
linearIssues: [],
|
||||
branches: []
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,10 @@ import type {
|
||||
GitLabWorkItem,
|
||||
LinearIssue
|
||||
} from '../../../src/shared/types'
|
||||
import type { SmartNameMode } from '../../../src/shared/new-workspace/smart-workspace-source-results'
|
||||
import {
|
||||
isSmartWorkspaceSourceQueryWithinLimit,
|
||||
type SmartNameMode
|
||||
} from '../../../src/shared/new-workspace/smart-workspace-source-results'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { isGitHubWorkItemsSshRemoteRequiredError } from './mobile-work-items'
|
||||
import type { MrStateFilter } from './mobile-composer-source-types'
|
||||
@@ -64,6 +67,11 @@ type FanOutArgs = {
|
||||
// modes surface the failure. No cross-provider ranking/dedup — the shared row
|
||||
// builder concatenates in provider order.
|
||||
export async function fanOutSmartSearch(args: FanOutArgs): Promise<SmartFanOutResult> {
|
||||
if (!isSmartWorkspaceSourceQueryWithinLimit(args.query)) {
|
||||
// Why: the source limit is an outbound-request boundary, not only a render
|
||||
// limit; pasted payloads must never fan out to provider CLIs or SSH hosts.
|
||||
return { ...EMPTY, needsGitHubRemote: false, error: '' }
|
||||
}
|
||||
const {
|
||||
client,
|
||||
mode,
|
||||
|
||||
@@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
deriveRepoSlug,
|
||||
findRepoMatchingSlug,
|
||||
findRepoMatchingSlugForPaste,
|
||||
resolvePasteIntent,
|
||||
type PasteRepoCandidate
|
||||
} from './smart-source-paste-intent'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
describe('resolvePasteIntent', () => {
|
||||
it('classifies a GitHub issue/PR URL as a github-link', () => {
|
||||
@@ -29,6 +31,12 @@ describe('resolvePasteIntent', () => {
|
||||
it('returns null for plain search text', () => {
|
||||
expect(resolvePasteIntent('login bug')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects oversized paste intents before any exact lookup', () => {
|
||||
expect(
|
||||
resolvePasteIntent(`https://github.com/acme/widgets/issues/12/${'x'.repeat(2048)}`)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveRepoSlug', () => {
|
||||
@@ -69,4 +77,62 @@ describe('findRepoMatchingSlug', () => {
|
||||
it('returns null when no repo matches', () => {
|
||||
expect(findRepoMatchingSlug(repos, { owner: 'other', repo: 'thing' })).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to the host-aware repo slug RPC for SSH and enterprise repos', async () => {
|
||||
const calls: string[] = []
|
||||
const client = {
|
||||
sendRequest: async (_method: string, params: unknown) => {
|
||||
const repo = (params as { repo: string }).repo
|
||||
calls.push(repo)
|
||||
return {
|
||||
ok: true,
|
||||
result: repo === 'id:b' ? { owner: 'enterprise', repo: 'widgets' } : null
|
||||
}
|
||||
}
|
||||
} as unknown as RpcClient
|
||||
await expect(
|
||||
findRepoMatchingSlugForPaste(
|
||||
client,
|
||||
repos,
|
||||
{ owner: 'enterprise', repo: 'widgets' },
|
||||
new Map()
|
||||
)
|
||||
).resolves.toMatchObject({ id: 'b' })
|
||||
expect(calls).toEqual(['id:a', 'id:b'])
|
||||
})
|
||||
|
||||
it('keeps local matching usable when an older desktop lacks the repo slug RPC', async () => {
|
||||
const client = {
|
||||
sendRequest: async () => ({
|
||||
ok: false,
|
||||
error: { code: 'method_not_found', message: 'Unknown method: github.repoSlug' }
|
||||
})
|
||||
} as unknown as RpcClient
|
||||
|
||||
await expect(
|
||||
findRepoMatchingSlugForPaste(
|
||||
client,
|
||||
repos,
|
||||
{ owner: 'enterprise', repo: 'widgets' },
|
||||
new Map()
|
||||
)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps local matching usable when the optional repo slug lookup rejects', async () => {
|
||||
const client = {
|
||||
sendRequest: async () => {
|
||||
throw new Error('connection closed')
|
||||
}
|
||||
} as unknown as RpcClient
|
||||
|
||||
await expect(
|
||||
findRepoMatchingSlugForPaste(
|
||||
client,
|
||||
repos,
|
||||
{ owner: 'enterprise', repo: 'widgets' },
|
||||
new Map()
|
||||
)
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type RepoSlug
|
||||
} from '../../../src/shared/new-workspace/github-links'
|
||||
import { parseGitLabIssueOrMRLink } from '../../../src/shared/new-workspace/gitlab-links'
|
||||
import { isSmartWorkspaceSourceQueryWithinLimit } from '../../../src/shared/new-workspace/smart-workspace-source-results'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
|
||||
@@ -32,6 +33,9 @@ export type PasteIntent = GitHubPasteIntent | GitLabPasteIntent | null
|
||||
// GitHub URL becomes 'github-link'; a bare "#123"/number becomes 'github-number';
|
||||
// a GitLab issue/MR URL becomes 'gitlab-link'.
|
||||
export function resolvePasteIntent(query: string): PasteIntent {
|
||||
if (!isSmartWorkspaceSourceQueryWithinLimit(query)) {
|
||||
return null
|
||||
}
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
@@ -84,6 +88,36 @@ export function findRepoMatchingSlug(
|
||||
return repos.find((repo) => slugsEqual(repo.slug, slug)) ?? null
|
||||
}
|
||||
|
||||
export async function findRepoMatchingSlugForPaste(
|
||||
client: RpcClient,
|
||||
repos: readonly PasteRepoCandidate[],
|
||||
slug: RepoSlug,
|
||||
cache: Map<string, RepoSlug | null>
|
||||
): Promise<PasteRepoCandidate | null> {
|
||||
const projected = findRepoMatchingSlug(repos, slug)
|
||||
if (projected) {
|
||||
return projected
|
||||
}
|
||||
// Why: projected remote metadata is incomplete for SSH and GitHub Enterprise;
|
||||
// ask each repo's owning runtime instead of assuming github.com URL syntax.
|
||||
for (const repo of repos) {
|
||||
let resolved = cache.get(repo.id)
|
||||
if (!cache.has(repo.id)) {
|
||||
try {
|
||||
const response = await client.sendRequest('github.repoSlug', { repo: `id:${repo.id}` })
|
||||
resolved = response.ok ? ((response as RpcSuccess).result as RepoSlug | null) : null
|
||||
} catch {
|
||||
resolved = null
|
||||
}
|
||||
cache.set(repo.id, resolved ?? null)
|
||||
}
|
||||
if (slugsEqual(resolved ?? null, slug)) {
|
||||
return repo
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function lookupGitHubItemByNumber(
|
||||
client: RpcClient,
|
||||
repoId: string,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types'
|
||||
import { resolveComposerManualBranchNameChange } from '../../../src/shared/composer-branch-selection'
|
||||
import { resolveGitHubWorkItemIdentity } from '../../../src/shared/new-workspace/github-work-item-identity'
|
||||
import { getForkPushWarning } from '../../../src/shared/new-workspace/fork-push-warning'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import {
|
||||
buildGitHubLinkedWorkItem,
|
||||
@@ -20,19 +21,11 @@ import {
|
||||
type ComposerHostedBase
|
||||
} from './composer-source-base-resolve'
|
||||
import type {
|
||||
ComposerBaseState,
|
||||
MobileComposerCreateSelection,
|
||||
MobileLinkedWorkItem,
|
||||
SmartNameSelection
|
||||
} from './mobile-composer-source-types'
|
||||
import type { WorkspaceCreateGitPushTarget } from './workspace-create-params'
|
||||
|
||||
type ComposerBaseState = {
|
||||
baseBranch?: string
|
||||
compareBaseRef?: string
|
||||
pushTarget?: WorkspaceCreateGitPushTarget
|
||||
branchNameOverride?: string
|
||||
}
|
||||
|
||||
const EMPTY_BASE: ComposerBaseState = {}
|
||||
|
||||
export type UseMobileComposerSourceArgs = {
|
||||
@@ -98,6 +91,7 @@ export function useMobileComposerSource(args: UseMobileComposerSourceArgs) {
|
||||
pushTarget: result.pushTarget,
|
||||
branchNameOverride: result.branchNameOverride
|
||||
})
|
||||
setForkPushWarning(getForkPushWarning(result))
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (resolveTokenRef.current !== token) {
|
||||
@@ -270,20 +264,22 @@ export function useMobileComposerSource(args: UseMobileComposerSourceArgs) {
|
||||
}
|
||||
}, [clearBaseAndBranch, name])
|
||||
|
||||
const handleBranchNameOverrideChange = useCallback((value: string) => {
|
||||
setBase((prev) => {
|
||||
const handleBranchNameOverrideChange = useCallback(
|
||||
(value: string) => {
|
||||
const next = resolveComposerManualBranchNameChange({
|
||||
value,
|
||||
pushTarget: prev.pushTarget,
|
||||
forkPushWarning: null
|
||||
pushTarget: base.pushTarget,
|
||||
forkPushWarning
|
||||
})
|
||||
return {
|
||||
...prev,
|
||||
setBase({
|
||||
...base,
|
||||
branchNameOverride: next.branchNameOverride,
|
||||
pushTarget: next.pushTarget
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
})
|
||||
setForkPushWarning(next.forkPushWarning)
|
||||
},
|
||||
[base, forkPushWarning]
|
||||
)
|
||||
|
||||
const smartNameSelection = useMemo<SmartNameSelection | null>(
|
||||
() => buildSmartNameSelection({ linkedWorkItem, baseBranch: base.baseBranch }),
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { RpcClient } from '../transport/rpc-client'
|
||||
import { fanOutSmartSearch, type SmartFanOutResult } from './smart-source-fan-out'
|
||||
import type { MrStateFilter } from './mobile-composer-source-types'
|
||||
import {
|
||||
findRepoMatchingSlug,
|
||||
findRepoMatchingSlugForPaste,
|
||||
lookupGitHubItemByNumber,
|
||||
lookupGitHubItemByOwnerRepo,
|
||||
lookupGitLabItemByPath,
|
||||
@@ -73,6 +73,7 @@ export function useSmartWorkspaceSource(args: UseSmartWorkspaceSourceArgs) {
|
||||
// the mode/repo changes so one provider's rows never render under another tab.
|
||||
const scopeRef = useRef('')
|
||||
const dismissedPasteRef = useRef<string>('')
|
||||
const repoSlugCacheRef = useRef<Map<string, { owner: string; repo: string } | null>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !enabled || mode === 'text') {
|
||||
@@ -104,7 +105,8 @@ export function useSmartWorkspaceSource(args: UseSmartWorkspaceSourceArgs) {
|
||||
mrStateFilter,
|
||||
linearWorkspaceId,
|
||||
repos,
|
||||
dismissedPasteRef
|
||||
dismissedPasteRef,
|
||||
repoSlugCache: repoSlugCacheRef.current
|
||||
})
|
||||
.then((result) => {
|
||||
if (stale) {
|
||||
@@ -183,12 +185,13 @@ async function runSmartSearch(args: {
|
||||
linearWorkspaceId: string | null | undefined
|
||||
repos: readonly PasteRepoCandidate[]
|
||||
dismissedPasteRef: { current: string }
|
||||
repoSlugCache: Map<string, { owner: string; repo: string } | null>
|
||||
}): Promise<{
|
||||
fan: SmartFanOutResult
|
||||
paste: PasteResolved
|
||||
crossRepoPrompt: SmartCrossRepoPrompt | null
|
||||
}> {
|
||||
const { client, mode, query, repoId, repos, dismissedPasteRef } = args
|
||||
const { client, mode, query, repoId, repos, dismissedPasteRef, repoSlugCache } = args
|
||||
const fan = await fanOutSmartSearch(args)
|
||||
const paste: PasteResolved = { github: null, gitlab: null }
|
||||
let crossRepoPrompt: SmartCrossRepoPrompt | null = null
|
||||
@@ -202,7 +205,12 @@ async function runSmartSearch(args: {
|
||||
if (intent.kind === 'github-number') {
|
||||
paste.github = await lookupGitHubItemByNumber(client, repoId, intent.number)
|
||||
} else if (intent.kind === 'github-link') {
|
||||
const matchingRepo = findRepoMatchingSlug(repos, intent.link.slug)
|
||||
const matchingRepo = await findRepoMatchingSlugForPaste(
|
||||
client,
|
||||
repos,
|
||||
intent.link.slug,
|
||||
repoSlugCache
|
||||
)
|
||||
if (matchingRepo && matchingRepo.id !== repoId) {
|
||||
crossRepoPrompt = { link: intent.link, matchingRepo }
|
||||
} else {
|
||||
|
||||
@@ -7,7 +7,7 @@ describe('isWorkItemLookupText', () => {
|
||||
expect(isWorkItemLookupText('https://github.com/o/r/issues/1')).toBe(true)
|
||||
expect(isWorkItemLookupText('https://gitlab.com/g/p/-/merge_requests/2')).toBe(true)
|
||||
expect(isWorkItemLookupText('https://linear.app/acme/issue/ENG-9')).toBe(true)
|
||||
expect(isWorkItemLookupText('ENG-9')).toBe(true)
|
||||
expect(isWorkItemLookupText('ENG-9')).toBe(false)
|
||||
})
|
||||
|
||||
it('treats plain names as non-lookup text', () => {
|
||||
|
||||
@@ -1,24 +1 @@
|
||||
import { parseGitHubIssueOrPRNumber } from '../../../src/shared/new-workspace/github-links'
|
||||
import { parseGitLabIssueOrMRNumber } from '../../../src/shared/new-workspace/gitlab-links'
|
||||
|
||||
const LINEAR_ISSUE_URL_RE = /^https?:\/\/(?:www\.)?linear\.app\/\S+/i
|
||||
const LINEAR_IDENTIFIER_RE = /^[A-Za-z][A-Za-z0-9_]*-\d+$/
|
||||
|
||||
// Why: text typed to *find* a work item — a GitHub/GitLab URL, "#123", or a
|
||||
// Linear link/identifier — is a lookup query, never a deliberate name. Selection
|
||||
// handlers use this to decide the resolved item's title-derived name may replace
|
||||
// the field content; otherwise the pasted reference silently survives behind the
|
||||
// pill and the workspace gets a slugified-URL name. Mirrors desktop's
|
||||
// isWorkItemLookupText using the shared link parsers.
|
||||
export function isWorkItemLookupText(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
parseGitHubIssueOrPRNumber(trimmed) !== null ||
|
||||
parseGitLabIssueOrMRNumber(trimmed) !== null ||
|
||||
LINEAR_ISSUE_URL_RE.test(trimmed) ||
|
||||
LINEAR_IDENTIFIER_RE.test(trimmed)
|
||||
)
|
||||
}
|
||||
export * from '../../../src/shared/new-workspace/work-item-lookup-text'
|
||||
|
||||
@@ -144,7 +144,7 @@ describe('task workspace create params', () => {
|
||||
).toMatchObject({
|
||||
repo: 'id:repo-linear',
|
||||
name: 'eng-42',
|
||||
displayName: 'Ship Linear parity',
|
||||
displayName: 'ENG-42 Ship Linear parity',
|
||||
linkedLinearIssue: 'ENG-42',
|
||||
startupDraft: 'https://linear.app/acme/issue/ENG-42/ship-linear-parity',
|
||||
createdWithAgent: 'grok'
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import type { TuiAgent } from '../../../src/shared/types'
|
||||
import type {
|
||||
CreateSparseCheckoutRequest,
|
||||
GitPushTarget,
|
||||
SetupDecision,
|
||||
TuiAgent
|
||||
} from '../../../src/shared/types'
|
||||
import { getWorkspaceSourceName } from '../../../src/shared/new-workspace/workspace-source'
|
||||
import { resolveMobileWorkspaceCreateName } from './mobile-workspace-name'
|
||||
import type { WorkspaceAgentChoice } from './workspace-agent-selection'
|
||||
|
||||
export type WorkspaceCreateSetupDecision = 'inherit' | 'run' | 'skip'
|
||||
|
||||
export type WorkspaceCreateSparseCheckout = {
|
||||
directories: string[]
|
||||
presetId?: string
|
||||
}
|
||||
|
||||
export type WorkspaceCreateGitPushTarget = {
|
||||
remoteName: string
|
||||
branchName: string
|
||||
remoteUrl?: string
|
||||
}
|
||||
export type WorkspaceCreateSetupDecision = SetupDecision
|
||||
export type WorkspaceCreateSparseCheckout = CreateSparseCheckoutRequest
|
||||
export type WorkspaceCreateGitPushTarget = GitPushTarget
|
||||
|
||||
export type WorkspaceCreateHostedStartPoint = {
|
||||
baseBranch: string
|
||||
@@ -97,7 +94,18 @@ export function buildTaskWorkspaceCreateParams(args: {
|
||||
const selectedPushTarget = pushTarget ?? hostedStartPoint?.pushTarget
|
||||
// Why: desktop only sends displayName while the name is still auto-derived; a
|
||||
// user-edited name suppresses it so the runtime keeps the user's chosen name.
|
||||
const displayName = nameIsAutoManaged ? { displayName: item.source.title } : {}
|
||||
const sourceName =
|
||||
item.provider === 'linear'
|
||||
? getWorkspaceSourceName({
|
||||
provider: 'linear',
|
||||
type: 'issue',
|
||||
number: 0,
|
||||
title: item.source.title,
|
||||
url: item.source.url,
|
||||
linearIdentifier: item.source.identifier
|
||||
})
|
||||
: getWorkspaceSourceName({ provider: item.provider, ...item.source })
|
||||
const displayName = nameIsAutoManaged ? { displayName: sourceName.displayName } : {}
|
||||
const common = {
|
||||
setupDecision,
|
||||
activate: true,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import {
|
||||
CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS,
|
||||
getClientWorktreeCreateCandidate,
|
||||
isRetryableWorktreeCreateConflict
|
||||
} from '../../../src/shared/new-workspace/worktree-create-retry-policy'
|
||||
import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout'
|
||||
|
||||
// Why: server-side collision checks (branch already exists locally / on a remote
|
||||
@@ -7,17 +12,6 @@ import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout'
|
||||
// branches outlive worktrees in git, and remote branches/PRs aren't visible from
|
||||
// worktree.ps. Retry by appending -2, -3, ... mirroring the desktop createWorktree
|
||||
// loop in src/renderer/src/store/slices/worktrees.ts.
|
||||
export const WORKTREE_NAME_COLLISION_PATTERNS = [
|
||||
/already exists locally/i,
|
||||
/already exists on a remote/i,
|
||||
/already has pr #\d+/i,
|
||||
// Older runtimes emit a bare `Branch "x" already exists.`; mirrors the desktop
|
||||
// createWorktree loop (src/renderer/src/store/slices/worktrees.ts).
|
||||
/^Branch ".+" already exists\./i
|
||||
]
|
||||
|
||||
const MAX_NAME_ATTEMPTS = 25
|
||||
|
||||
export type WorktreeCreateResult = { worktreeId: string; name: string } | { error: string }
|
||||
|
||||
// Creates a worktree, retrying with a numeric suffix on a name-collision error.
|
||||
@@ -32,10 +26,10 @@ export async function createWorktreeWithNameRetry(args: {
|
||||
maxAttempts?: number
|
||||
}): Promise<WorktreeCreateResult> {
|
||||
const { client, baseName, buildParams } = args
|
||||
const maxAttempts = args.maxAttempts ?? MAX_NAME_ATTEMPTS
|
||||
const maxAttempts = args.maxAttempts ?? CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS
|
||||
let lastError: string | null = null
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
const candidateName = attempt === 0 ? baseName : `${baseName}-${attempt + 1}`
|
||||
const candidateName = getClientWorktreeCreateCandidate(baseName, attempt)
|
||||
const response = await client.sendRequest('worktree.create', buildParams(candidateName), {
|
||||
timeoutMs: WORKTREE_CREATE_TIMEOUT_MS
|
||||
})
|
||||
@@ -44,7 +38,7 @@ export async function createWorktreeWithNameRetry(args: {
|
||||
return { worktreeId: result.worktree.id, name: candidateName }
|
||||
}
|
||||
lastError = response.error.message
|
||||
if (!WORKTREE_NAME_COLLISION_PATTERNS.some((pattern) => pattern.test(lastError ?? ''))) {
|
||||
if (!isRetryableWorktreeCreateConflict(lastError ?? '')) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { evaluateCompat } from './protocol-compat'
|
||||
|
||||
describe('evaluateCompat', () => {
|
||||
it('allows the current mobile app to connect before a protocol-2 desktop updates', () => {
|
||||
expect(
|
||||
evaluateCompat({
|
||||
desktopProtocolVersion: 2,
|
||||
desktopMinCompatibleMobileVersion: 2
|
||||
})
|
||||
).toEqual({ kind: 'ok' })
|
||||
})
|
||||
})
|
||||
@@ -84,4 +84,37 @@ describe('RPC optional pipe schemas', () => {
|
||||
})
|
||||
expectParses(methodParams(WORKTREE_METHODS, 'worktree.prefetchCreateBase'), { repo: 'repo-1' })
|
||||
})
|
||||
|
||||
it('accepts worktree.create payloads sent by the previous mobile protocol', () => {
|
||||
const create = methodParams(WORKTREE_METHODS, 'worktree.create')
|
||||
|
||||
expectParses(create, {
|
||||
repo: 'id:repo-github',
|
||||
name: 'fix-mobile-tasks',
|
||||
displayName: 'Fix mobile tasks',
|
||||
setupDecision: 'run',
|
||||
activate: true,
|
||||
startupDraft: 'https://github.com/acme/app/pull/123',
|
||||
createdWithAgent: 'codex',
|
||||
linkedPR: 123,
|
||||
baseBranch: 'origin/main',
|
||||
compareBaseRef: 'origin/main',
|
||||
branchNameOverride: 'feature/mobile-tasks',
|
||||
pushTarget: { remoteName: 'origin', branchName: 'feature/mobile-tasks' }
|
||||
})
|
||||
expectParses(create, {
|
||||
repo: 'id:repo-gitlab',
|
||||
name: 'mr-7',
|
||||
linkedGitLabMR: 7,
|
||||
sparseCheckout: { directories: ['mobile'], presetId: 'mobile' },
|
||||
comment: 'keep mobile parity'
|
||||
})
|
||||
expectParses(create, {
|
||||
repo: 'id:repo-linear',
|
||||
name: 'eng-42',
|
||||
linkedLinearIssue: 'ENG-42',
|
||||
linkedLinearIssueWorkspaceId: 'workspace-1',
|
||||
linkedLinearIssueOrganizationUrlKey: 'acme'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { buildLinearIssueLinkedWorkItem } from '@/lib/linear-linked-work-item'
|
||||
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
|
||||
import {
|
||||
getLinkedWorkItemProvider,
|
||||
getLinkedWorkItemWorkspaceName,
|
||||
type LinkedWorkItemSummary
|
||||
} from '@/lib/new-workspace'
|
||||
buildGitHubWorkspaceSource,
|
||||
buildGitLabWorkspaceSource,
|
||||
buildLinearWorkspaceSource,
|
||||
buildWorkspaceSourceSelection,
|
||||
getWorkspaceSourceName,
|
||||
getWorkspaceSourceProvider
|
||||
} from '../../../../shared/new-workspace/workspace-source'
|
||||
import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
@@ -63,7 +66,7 @@ export function toFolderWorkspaceLinkedTask(
|
||||
if (!item) {
|
||||
return null
|
||||
}
|
||||
const provider = getLinkedWorkItemProvider(item)
|
||||
const provider = getWorkspaceSourceProvider(item)
|
||||
return {
|
||||
provider,
|
||||
type: item.type,
|
||||
@@ -79,60 +82,23 @@ export function toFolderWorkspaceLinkedTask(
|
||||
export function getSmartNameSelection(
|
||||
linkedWorkItem: LinkedWorkItemSummary | null
|
||||
): SmartWorkspaceNameSelection | null {
|
||||
if (!linkedWorkItem) {
|
||||
return null
|
||||
}
|
||||
const provider = getLinkedWorkItemProvider(linkedWorkItem)
|
||||
const kind: SmartWorkspaceNameSelection['kind'] =
|
||||
provider === 'linear'
|
||||
? 'linear'
|
||||
: provider === 'jira'
|
||||
? 'jira'
|
||||
: provider === 'gitlab'
|
||||
? linkedWorkItem.type === 'mr'
|
||||
? 'gitlab-mr'
|
||||
: 'gitlab-issue'
|
||||
: linkedWorkItem.type === 'pr'
|
||||
? 'github-pr'
|
||||
: 'github-issue'
|
||||
return {
|
||||
kind,
|
||||
label:
|
||||
provider === 'linear' || provider === 'jira' || linkedWorkItem.number === 0
|
||||
? linkedWorkItem.title
|
||||
: `#${linkedWorkItem.number} ${linkedWorkItem.title}`,
|
||||
url: linkedWorkItem.url
|
||||
}
|
||||
return buildWorkspaceSourceSelection({ linkedWorkItem }) as SmartWorkspaceNameSelection | null
|
||||
}
|
||||
|
||||
export function getLinkedItemDisplayName(item: LinkedWorkItemSummary): string | null {
|
||||
return getLinkedWorkItemWorkspaceName(item)?.displayName ?? (item.title.trim() || null)
|
||||
return getWorkspaceSourceName(item).displayName || null
|
||||
}
|
||||
|
||||
export function toGitHubLinkedWorkItem(item: GitHubWorkItem): LinkedWorkItemSummary {
|
||||
return {
|
||||
type: item.type,
|
||||
provider: 'github',
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
repoId: item.repoId
|
||||
}
|
||||
return buildGitHubWorkspaceSource(item)
|
||||
}
|
||||
|
||||
export function toGitLabLinkedWorkItem(item: GitLabWorkItem): LinkedWorkItemSummary {
|
||||
return {
|
||||
type: item.type,
|
||||
provider: 'gitlab',
|
||||
number: item.number,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
repoId: item.repoId
|
||||
}
|
||||
return buildGitLabWorkspaceSource(item)
|
||||
}
|
||||
|
||||
export function toLinearLinkedWorkItem(issue: LinearIssue): LinkedWorkItemSummary {
|
||||
return buildLinearIssueLinkedWorkItem(issue)
|
||||
return buildLinearWorkspaceSource(issue)
|
||||
}
|
||||
|
||||
export function getFolderWorkspacePrimaryActionLabel(): string {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getComposerRepoWorktreeBranches,
|
||||
isBranchCheckedOutInWorktrees,
|
||||
resolveComposerBranchNameOverrideForCreate,
|
||||
resolveComposerBranchReuse,
|
||||
@@ -209,6 +210,20 @@ describe('isBranchCheckedOutInWorktrees', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getComposerRepoWorktreeBranches', () => {
|
||||
it('supplies only the selected repo branches to reuse eligibility', () => {
|
||||
expect(
|
||||
getComposerRepoWorktreeBranches(
|
||||
[
|
||||
{ repoId: 'repo-a', branch: 'feature/a' },
|
||||
{ repoId: 'repo-b', branch: 'feature/b' }
|
||||
],
|
||||
'repo-a'
|
||||
)
|
||||
).toEqual(['feature/a'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveComposerReuseOverride', () => {
|
||||
it('keeps the selection override for a reusable (non-busy) local branch', () => {
|
||||
expect(
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
export {
|
||||
isBranchCheckedOutInWorktrees,
|
||||
getComposerRepoWorktreeBranches,
|
||||
resolveComposerBranchPick,
|
||||
resolveComposerBranchNameOverrideForCreate,
|
||||
resolveComposerBranchReuse,
|
||||
resolveComposerBranchSelection,
|
||||
resolveComposerManualBranchNameChange,
|
||||
resolveComposerReuseOverride,
|
||||
type ComposerBranchPick,
|
||||
type ComposerBranchSelection
|
||||
} from '../../../shared/composer-branch-selection'
|
||||
|
||||
@@ -1,21 +1 @@
|
||||
import type { GitHubPrStartPoint } from '../../../shared/types'
|
||||
|
||||
export const FORK_PUSH_NO_MAINTAINER_EDIT_WARNING =
|
||||
'This PR has "Allow edits from maintainers" off; pushing to the fork may be rejected by GitHub.'
|
||||
|
||||
// Why: only warn for fork PRs where the push target points away from origin and
|
||||
// whose author left "Allow edits from maintainers" off. That's the one case
|
||||
// where our push to the contributor's fork can be rejected by GitHub. Returns
|
||||
// the warning text to show, or null when no warning applies.
|
||||
export function getForkPushWarning(
|
||||
result: Pick<GitHubPrStartPoint, 'pushTarget' | 'maintainerCanModify'>
|
||||
): string | null {
|
||||
if (
|
||||
result.maintainerCanModify === false &&
|
||||
result.pushTarget !== undefined &&
|
||||
result.pushTarget.remoteName !== 'origin'
|
||||
) {
|
||||
return FORK_PUSH_NO_MAINTAINER_EDIT_WARNING
|
||||
}
|
||||
return null
|
||||
}
|
||||
export * from '../../../shared/new-workspace/fork-push-warning'
|
||||
|
||||
@@ -147,6 +147,10 @@ import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-sug
|
||||
import type { SmartWorkspaceNameSelection } from '@/components/new-workspace/SmartWorkspaceNameField'
|
||||
import type { SmartNameMode } from '@/components/new-workspace/smart-workspace-source-results'
|
||||
import { getForkPushWarning } from './fork-push-warning'
|
||||
import {
|
||||
buildWorkspaceSourceSelection,
|
||||
shouldApplyWorkspaceSourceAutoName
|
||||
} from '../../../shared/new-workspace/workspace-source'
|
||||
import { CONTEXTUAL_TOUR_ENABLE_AUTO_WORKSPACE_NAME_EVENT } from '@/components/contextual-tours/contextual-tour-composer-events'
|
||||
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
|
||||
import { normalizeSparseDirectoryLines, sparseDirectoriesMatch } from '@/lib/sparse-paths'
|
||||
@@ -164,12 +168,10 @@ import {
|
||||
} from '@/lib/workspace-create-error-format'
|
||||
import type { SshConnectionStatus } from '../../../shared/ssh-types'
|
||||
import {
|
||||
isBranchCheckedOutInWorktrees,
|
||||
resolveComposerBranchNameOverrideForCreate,
|
||||
resolveComposerBranchReuse,
|
||||
resolveComposerBranchSelection,
|
||||
resolveComposerBranchPick,
|
||||
resolveComposerManualBranchNameChange,
|
||||
resolveComposerReuseOverride
|
||||
getComposerRepoWorktreeBranches
|
||||
} from './composer-branch-selection'
|
||||
import { isCurrentComposerDropOwner } from './composer-drop-owner'
|
||||
import {
|
||||
@@ -2072,7 +2074,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
// name or it silently becomes a slugified-URL workspace name.
|
||||
if (
|
||||
suggestedName &&
|
||||
(!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name))
|
||||
shouldApplyWorkspaceSourceAutoName({
|
||||
currentName: name,
|
||||
lastAutoName: lastAutoNameRef.current
|
||||
})
|
||||
) {
|
||||
setName(suggestedName)
|
||||
lastAutoNameRef.current = suggestedName
|
||||
@@ -2307,7 +2312,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
const nextName = titleName?.seedName ?? suggestedName
|
||||
if (
|
||||
nextName &&
|
||||
(!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name))
|
||||
shouldApplyWorkspaceSourceAutoName({
|
||||
currentName: name,
|
||||
lastAutoName: lastAutoNameRef.current
|
||||
})
|
||||
) {
|
||||
setName(nextName)
|
||||
lastAutoNameRef.current = nextName
|
||||
@@ -2909,7 +2917,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
const nextName = getLinkedItemDisplayName(linkedItem)
|
||||
if (
|
||||
nextName &&
|
||||
(!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name))
|
||||
shouldApplyWorkspaceSourceAutoName({
|
||||
currentName: name,
|
||||
lastAutoName: lastAutoNameRef.current
|
||||
})
|
||||
) {
|
||||
setName(nextName)
|
||||
lastAutoNameRef.current = nextName
|
||||
@@ -3013,7 +3024,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
const nextName = getLinkedItemDisplayName(linkedItem)
|
||||
if (
|
||||
nextName &&
|
||||
(!name.trim() || name === lastAutoNameRef.current || isWorkItemLookupText(name))
|
||||
shouldApplyWorkspaceSourceAutoName({
|
||||
currentName: name,
|
||||
lastAutoName: lastAutoNameRef.current
|
||||
})
|
||||
) {
|
||||
setName(nextName)
|
||||
lastAutoNameRef.current = nextName
|
||||
@@ -3110,11 +3124,12 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
const handleSmartBranchSelect = useCallback(
|
||||
(refName: string, localBranchName: string): void => {
|
||||
smartGitHubPrStartPointSelectionRef.current = null
|
||||
const selection = resolveComposerBranchSelection({
|
||||
const selection = resolveComposerBranchPick({
|
||||
refName,
|
||||
localBranchName,
|
||||
currentName: name,
|
||||
lastAutoName: lastAutoNameRef.current
|
||||
lastAutoName: lastAutoNameRef.current,
|
||||
worktreeBranches: getComposerRepoWorktreeBranches(worktreesByRepo[repoId] ?? [], repoId)
|
||||
})
|
||||
setBaseBranch(selection.baseBranch)
|
||||
setCompareBaseRef(undefined)
|
||||
@@ -3130,34 +3145,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
// Note: worktreesByRepo only covers visible worktrees; a branch busy only
|
||||
// in a hidden external worktree falls through to the backend conflict
|
||||
// check, which rejects it with a clear "already exists locally" error.
|
||||
const branchCheckedOutElsewhere = isBranchCheckedOutInWorktrees(
|
||||
localBranchName,
|
||||
(worktreesByRepo[repoId] ?? []).map((worktree) => worktree.branch)
|
||||
)
|
||||
const { reuseEligibleBranch: nextReuseEligibleBranch, defaultReuse } =
|
||||
resolveComposerBranchReuse({
|
||||
refName,
|
||||
localBranchName,
|
||||
selectionProducedOverride: selection.branchNameOverride !== undefined,
|
||||
branchCheckedOutElsewhere
|
||||
})
|
||||
const { reuseEligibleBranch: nextReuseEligibleBranch, defaultReuse } = selection
|
||||
setReuseEligibleBranch(nextReuseEligibleBranch)
|
||||
setReuseSelectedBranch(defaultReuse)
|
||||
setBranchNameOverridePreservesNameEdits(defaultReuse)
|
||||
const effectiveOverride = resolveComposerReuseOverride({
|
||||
refName,
|
||||
localBranchName,
|
||||
branchNameOverride: selection.branchNameOverride,
|
||||
branchCheckedOutElsewhere
|
||||
})
|
||||
if (selection.name !== undefined && selection.lastAutoName !== undefined) {
|
||||
setName(selection.name)
|
||||
lastAutoNameRef.current = selection.lastAutoName
|
||||
branchAutoNameRef.current = effectiveOverride ? selection.branchAutoName : ''
|
||||
setBranchNameOverride(effectiveOverride)
|
||||
branchAutoNameRef.current = selection.branchNameOverride ? selection.branchAutoName : ''
|
||||
setBranchNameOverride(selection.branchNameOverride)
|
||||
} else {
|
||||
setBranchNameOverride(effectiveOverride)
|
||||
branchAutoNameRef.current = effectiveOverride ? selection.branchAutoName : ''
|
||||
setBranchNameOverride(selection.branchNameOverride)
|
||||
branchAutoNameRef.current = selection.branchNameOverride ? selection.branchAutoName : ''
|
||||
}
|
||||
},
|
||||
[name, worktreesByRepo, repoId]
|
||||
@@ -3194,9 +3193,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
const suggestedName =
|
||||
getLinkedItemDisplayName(linkedItem) ?? getLinearIssueWorkspaceName(issue)
|
||||
if (
|
||||
!name.trim() ||
|
||||
name === lastAutoNameRef.current ||
|
||||
isWorkItemLookupText(name) ||
|
||||
shouldApplyWorkspaceSourceAutoName({
|
||||
currentName: name,
|
||||
lastAutoName: lastAutoNameRef.current
|
||||
}) ||
|
||||
name.trim().toLowerCase() === issue.identifier.toLowerCase()
|
||||
) {
|
||||
setName(suggestedName)
|
||||
@@ -3213,9 +3213,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
// Why: same lookup-text rule as applyLinkedWorkItem, plus the typed
|
||||
// Linear identifier ("STA-123") that matched this issue.
|
||||
if (
|
||||
!name.trim() ||
|
||||
name === lastAutoNameRef.current ||
|
||||
isWorkItemLookupText(name) ||
|
||||
shouldApplyWorkspaceSourceAutoName({
|
||||
currentName: name,
|
||||
lastAutoName: lastAutoNameRef.current
|
||||
}) ||
|
||||
name.trim().toLowerCase() === issue.identifier.toLowerCase()
|
||||
) {
|
||||
setName(suggestedName)
|
||||
@@ -3262,33 +3263,10 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
|
||||
if (isProjectGroupTarget) {
|
||||
return getFolderSmartNameSelection(linkedWorkItem)
|
||||
}
|
||||
if (linkedWorkItem) {
|
||||
const provider = getLinkedWorkItemProvider(linkedWorkItem)
|
||||
const isLinear = provider === 'linear'
|
||||
const kind: SmartWorkspaceNameSelection['kind'] = isLinear
|
||||
? 'linear'
|
||||
: provider === 'jira'
|
||||
? 'jira'
|
||||
: provider === 'gitlab'
|
||||
? linkedWorkItem.type === 'mr'
|
||||
? 'gitlab-mr'
|
||||
: 'gitlab-issue'
|
||||
: linkedWorkItem.type === 'pr'
|
||||
? 'github-pr'
|
||||
: 'github-issue'
|
||||
return {
|
||||
kind,
|
||||
label:
|
||||
isLinear || provider === 'jira' || linkedWorkItem.number === 0
|
||||
? linkedWorkItem.title
|
||||
: `#${linkedWorkItem.number} ${linkedWorkItem.title}`,
|
||||
url: linkedWorkItem.url
|
||||
}
|
||||
}
|
||||
if (baseBranch) {
|
||||
return { kind: 'branch', label: baseBranch }
|
||||
}
|
||||
return null
|
||||
return buildWorkspaceSourceSelection({
|
||||
linkedWorkItem,
|
||||
baseBranch
|
||||
}) as SmartWorkspaceNameSelection | null
|
||||
}, [baseBranch, isProjectGroupTarget, linkedWorkItem])
|
||||
|
||||
const handleOpenAgentSettings = useCallback((): void => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LinearIssue } from '../../../shared/types'
|
||||
import type { LinkedWorkItemSummary } from '@/lib/new-workspace'
|
||||
import { getLinearOrganizationUrlKeyFromIssueUrl } from '../../../shared/linear-links'
|
||||
import { buildLinearWorkspaceSource } from '../../../shared/new-workspace/workspace-source'
|
||||
|
||||
export function isLinearLinkedWorkItem(
|
||||
item: Pick<LinkedWorkItemSummary, 'provider' | 'linearIdentifier'> | null | undefined
|
||||
@@ -9,21 +9,5 @@ export function isLinearLinkedWorkItem(
|
||||
}
|
||||
|
||||
export function buildLinearIssueLinkedWorkItem(issue: LinearIssue): LinkedWorkItemSummary {
|
||||
const organizationUrlKey = getLinearOrganizationUrlKeyFromIssueUrl(issue.url)
|
||||
return {
|
||||
type: 'issue',
|
||||
provider: 'linear',
|
||||
// Why: Linear issue prose must not enter prompt metadata; keep only the
|
||||
// string identifier/link and leave numeric issue metadata empty.
|
||||
number: 0,
|
||||
title: issue.title,
|
||||
url: issue.url,
|
||||
linearIdentifier: issue.identifier,
|
||||
...(issue.workspaceId ? { linearWorkspaceId: issue.workspaceId } : {}),
|
||||
...(organizationUrlKey
|
||||
? {
|
||||
linearOrganizationUrlKey: organizationUrlKey
|
||||
}
|
||||
: {})
|
||||
}
|
||||
return buildLinearWorkspaceSource(issue)
|
||||
}
|
||||
|
||||
@@ -1,50 +1,4 @@
|
||||
import type { LinkedWorkItemSummary } from './new-workspace'
|
||||
|
||||
// Why: self-hosted GitLab issue URLs may not contain "gitlab", and modern
|
||||
// GitLab emits issue URLs as `/-/work_items/<iid>` as well as the legacy
|
||||
// `/-/issues/<iid>`. Recognize both forms.
|
||||
const GL_ISSUE_PATH_RE = /\/-\/(?:issues|work_items)\//i
|
||||
|
||||
export function isGitLabIssueUrl(url: string): boolean {
|
||||
try {
|
||||
return GL_ISSUE_PATH_RE.test(new URL(url).pathname)
|
||||
} catch {
|
||||
return GL_ISSUE_PATH_RE.test(url)
|
||||
}
|
||||
}
|
||||
|
||||
function isJiraIssueUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return (
|
||||
/\.atlassian\.net$/i.test(parsed.hostname) ||
|
||||
/\/browse\/[A-Z][A-Z0-9]+-\d+/i.test(parsed.pathname)
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function getLinkedWorkItemProvider(
|
||||
item: LinkedWorkItemSummary
|
||||
): NonNullable<LinkedWorkItemSummary['provider']> {
|
||||
if (item.provider) {
|
||||
return item.provider
|
||||
}
|
||||
if (item.linearIdentifier) {
|
||||
return 'linear'
|
||||
}
|
||||
if (item.jiraIdentifier || isJiraIssueUrl(item.url)) {
|
||||
return 'jira'
|
||||
}
|
||||
if (item.type === 'mr') {
|
||||
return 'gitlab'
|
||||
}
|
||||
if (isGitLabIssueUrl(item.url)) {
|
||||
return 'gitlab'
|
||||
}
|
||||
if (item.number === 0 && !item.url.includes('github.com')) {
|
||||
return 'linear'
|
||||
}
|
||||
return 'github'
|
||||
}
|
||||
export {
|
||||
getWorkspaceSourceProvider as getLinkedWorkItemProvider,
|
||||
isGitLabIssueUrl
|
||||
} from '../../../shared/new-workspace/workspace-source'
|
||||
|
||||
@@ -1,24 +1 @@
|
||||
import { getSmartGitHubSubmitIntent } from './smart-github-submit'
|
||||
import { parseGitLabIssueOrMRLink } from './gitlab-links'
|
||||
|
||||
const LINEAR_ISSUE_URL_RE = /^https?:\/\/(?:www\.)?linear\.app\/\S+/i
|
||||
|
||||
/**
|
||||
* Why: text typed into the smart name field to *find* a work item — a GitHub
|
||||
* or GitLab URL, "#123", or a linear.app link — is a lookup query, never a
|
||||
* deliberate workspace name. Selection handlers use this to decide that the
|
||||
* resolved item's title-derived name may replace the field content; otherwise
|
||||
* the pasted URL silently survives behind the selection pill and the
|
||||
* workspace gets a slugified-URL name.
|
||||
*/
|
||||
export function isWorkItemLookupText(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
getSmartGitHubSubmitIntent(trimmed) !== null ||
|
||||
parseGitLabIssueOrMRLink(trimmed) !== null ||
|
||||
LINEAR_ISSUE_URL_RE.test(trimmed)
|
||||
)
|
||||
}
|
||||
export * from '../../../shared/new-workspace/work-item-lookup-text'
|
||||
|
||||
@@ -71,6 +71,11 @@ import {
|
||||
worktreeWorkspaceKey
|
||||
} from '../../../../shared/workspace-scope'
|
||||
import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree'
|
||||
import {
|
||||
CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS,
|
||||
getClientWorktreeCreateCandidate,
|
||||
isRetryableWorktreeCreateConflict
|
||||
} from '../../../../shared/new-workspace/worktree-create-retry-policy'
|
||||
import {
|
||||
classifyWorktreeForceDeleteReason,
|
||||
getLockedWorktreeRemovalReason,
|
||||
@@ -2954,22 +2959,13 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
options
|
||||
) => {
|
||||
const automationProvenanceRequest = options?.automationProvenanceRequest
|
||||
const retryableConflictPatterns = [
|
||||
/already exists locally/i,
|
||||
/already exists on a remote/i,
|
||||
/^Branch ".+" already exists\./i,
|
||||
/already has pr #\d+/i
|
||||
]
|
||||
const nextCandidateName = (current: string, attempt: number): string =>
|
||||
attempt === 0 ? current : `${current}-${attempt + 1}`
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt < 25; attempt += 1) {
|
||||
const candidateName = nextCandidateName(name, attempt)
|
||||
for (let attempt = 0; attempt < CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS; attempt += 1) {
|
||||
const candidateName = getClientWorktreeCreateCandidate(name, attempt)
|
||||
// Why: older runtimes may still reject exact PR branch overrides on
|
||||
// collision, so the renderer retries both branch and worktree names.
|
||||
const candidateBranchNameOverride = branchNameOverride
|
||||
? nextCandidateName(branchNameOverride, attempt)
|
||||
? getClientWorktreeCreateCandidate(branchNameOverride, attempt)
|
||||
: undefined
|
||||
try {
|
||||
// Why: Manual sort is user-authored order. Stamp new workspaces
|
||||
@@ -3130,8 +3126,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
return result
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const shouldRetry = retryableConflictPatterns.some((pattern) => pattern.test(message))
|
||||
if (!shouldRetry || attempt === 24) {
|
||||
const shouldRetry = isRetryableWorktreeCreateConflict(message)
|
||||
if (!shouldRetry || attempt === CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS - 1) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,15 @@ export function isBranchCheckedOutInWorktrees(
|
||||
return worktreeBranches.some((ref) => ref.replace(/^refs\/heads\//, '') === branchName)
|
||||
}
|
||||
|
||||
export function getComposerRepoWorktreeBranches(
|
||||
worktrees: readonly { repoId: string; branch: string }[],
|
||||
repoId: string | null
|
||||
): string[] {
|
||||
return repoId
|
||||
? worktrees.filter((worktree) => worktree.repoId === repoId).map((worktree) => worktree.branch)
|
||||
: []
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue #5181: decide whether a picked branch row is an existing LOCAL branch
|
||||
* that can be reused (checked out) instead of branched off, and whether reuse
|
||||
@@ -99,6 +108,41 @@ export function resolveComposerReuseOverride(args: {
|
||||
return args.branchNameOverride
|
||||
}
|
||||
|
||||
export type ComposerBranchPick = ComposerBranchSelection & {
|
||||
reuseEligibleBranch: string | null
|
||||
defaultReuse: boolean
|
||||
}
|
||||
|
||||
export function resolveComposerBranchPick(args: {
|
||||
refName: string
|
||||
localBranchName: string
|
||||
currentName: string
|
||||
lastAutoName: string
|
||||
worktreeBranches: readonly string[]
|
||||
}): ComposerBranchPick {
|
||||
const selection = resolveComposerBranchSelection(args)
|
||||
const branchCheckedOutElsewhere = isBranchCheckedOutInWorktrees(
|
||||
args.localBranchName,
|
||||
args.worktreeBranches
|
||||
)
|
||||
const reuse = resolveComposerBranchReuse({
|
||||
refName: args.refName,
|
||||
localBranchName: args.localBranchName,
|
||||
selectionProducedOverride: selection.branchNameOverride !== undefined,
|
||||
branchCheckedOutElsewhere
|
||||
})
|
||||
return {
|
||||
...selection,
|
||||
branchNameOverride: resolveComposerReuseOverride({
|
||||
refName: args.refName,
|
||||
localBranchName: args.localBranchName,
|
||||
branchNameOverride: selection.branchNameOverride,
|
||||
branchCheckedOutElsewhere
|
||||
}),
|
||||
...reuse
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The branch-name override to apply when creating a worktree from the composer.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { GitHubPrStartPoint } from '../types'
|
||||
|
||||
export const FORK_PUSH_NO_MAINTAINER_EDIT_WARNING =
|
||||
'This PR has "Allow edits from maintainers" off; pushing to the fork may be rejected by GitHub.'
|
||||
|
||||
// Why: this is the one fork target where Orca can prepare the workspace but a
|
||||
// later push may still be rejected by GitHub permissions.
|
||||
export function getForkPushWarning(
|
||||
result: Pick<GitHubPrStartPoint, 'pushTarget' | 'maintainerCanModify'>
|
||||
): string | null {
|
||||
return result.maintainerCanModify === false &&
|
||||
result.pushTarget !== undefined &&
|
||||
result.pushTarget.remoteName !== 'origin'
|
||||
? FORK_PUSH_NO_MAINTAINER_EDIT_WARNING
|
||||
: null
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { parseGitHubIssueOrPRLink, parseGitHubIssueOrPRNumber } from './github-links'
|
||||
import { parseGitLabIssueOrMRLink } from './gitlab-links'
|
||||
|
||||
const LINEAR_ISSUE_URL_RE = /^https?:\/\/(?:www\.)?linear\.app\/\S+/i
|
||||
const GITHUB_ITEM_URL_IN_TEXT_RE =
|
||||
/https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/(?:issues|pull)\/\d+[^\s]*/i
|
||||
const TRAILING_URL_PUNCTUATION_RE = /[),.;:!?]+$/
|
||||
|
||||
function hasGitHubLookup(value: string): boolean {
|
||||
if (parseGitHubIssueOrPRNumber(value) !== null || parseGitHubIssueOrPRLink(value) !== null) {
|
||||
return true
|
||||
}
|
||||
const embedded = GITHUB_ITEM_URL_IN_TEXT_RE.exec(value)?.[0]
|
||||
return embedded
|
||||
? parseGitHubIssueOrPRLink(embedded.replace(TRAILING_URL_PUNCTUATION_RE, '')) !== null
|
||||
: false
|
||||
}
|
||||
|
||||
/** Lookup references may be replaced by an auto-name; deliberate names may not. */
|
||||
export function isWorkItemLookupText(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
hasGitHubLookup(trimmed) ||
|
||||
parseGitLabIssueOrMRLink(trimmed) !== null ||
|
||||
LINEAR_ISSUE_URL_RE.test(trimmed)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildLinearWorkspaceSource,
|
||||
buildWorkspaceSourceSelection,
|
||||
getWorkspaceSourceName,
|
||||
getWorkspaceSourceProvider,
|
||||
shouldApplyWorkspaceSourceAutoName,
|
||||
shouldPreserveWorkspaceSourceOnRepoChange
|
||||
} from './workspace-source'
|
||||
|
||||
describe('workspace source policy', () => {
|
||||
const linear = buildLinearWorkspaceSource({
|
||||
identifier: 'ENG-42',
|
||||
title: 'Ship mobile parity',
|
||||
url: 'https://linear.app/acme/issue/ENG-42/ship-mobile-parity',
|
||||
workspaceId: 'workspace-1'
|
||||
})
|
||||
|
||||
it('builds one Linear identity for desktop and mobile create flows', () => {
|
||||
expect(linear).toMatchObject({
|
||||
provider: 'linear',
|
||||
number: 0,
|
||||
linearIdentifier: 'ENG-42',
|
||||
linearWorkspaceId: 'workspace-1',
|
||||
linearOrganizationUrlKey: 'acme'
|
||||
})
|
||||
expect(getWorkspaceSourceName(linear)).toEqual({
|
||||
seedName: 'eng-42-ship-mobile-parity',
|
||||
displayName: 'ENG-42 Ship mobile parity'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves global work-item sources across repo changes', () => {
|
||||
expect(shouldPreserveWorkspaceSourceOnRepoChange(linear)).toBe(true)
|
||||
expect(
|
||||
shouldPreserveWorkspaceSourceOnRepoChange({
|
||||
provider: 'github',
|
||||
type: 'issue',
|
||||
number: 1,
|
||||
title: 'Repo scoped',
|
||||
url: 'https://github.com/o/r/issues/1'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('shares provider inference, selection labels, and auto-name gates', () => {
|
||||
const legacyGitLab = {
|
||||
type: 'issue' as const,
|
||||
number: 7,
|
||||
title: 'Self hosted',
|
||||
url: 'https://gitlab.example.com/g/p/-/work_items/7'
|
||||
}
|
||||
expect(getWorkspaceSourceProvider(legacyGitLab)).toBe('gitlab')
|
||||
expect(buildWorkspaceSourceSelection({ linkedWorkItem: legacyGitLab })).toMatchObject({
|
||||
kind: 'gitlab-issue',
|
||||
label: '#7 Self hosted'
|
||||
})
|
||||
expect(shouldApplyWorkspaceSourceAutoName({ currentName: '#42', lastAutoName: 'old' })).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
shouldApplyWorkspaceSourceAutoName({ currentName: 'my workspace', lastAutoName: 'old' })
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,195 @@
|
||||
import { getLinearOrganizationUrlKeyFromIssueUrl } from '../linear-links'
|
||||
import type { FolderWorkspaceLinkedTask, LinearIssue } from '../types'
|
||||
import {
|
||||
getLinkedWorkItemSuggestedName,
|
||||
getLinkedWorkItemWorkspaceName,
|
||||
type WorkspaceIntentWorkItem
|
||||
} from '../workspace-name'
|
||||
import { isWorkItemLookupText } from './work-item-lookup-text'
|
||||
|
||||
export type WorkspaceSourceProvider = FolderWorkspaceLinkedTask['provider']
|
||||
|
||||
export type WorkspaceSourceLinkedItem = FolderWorkspaceLinkedTask & {
|
||||
linearWorkspaceId?: string
|
||||
linearOrganizationUrlKey?: string
|
||||
}
|
||||
|
||||
export type GitHubWorkspaceSource = WorkspaceSourceLinkedItem & {
|
||||
provider: 'github'
|
||||
type: 'issue' | 'pr'
|
||||
}
|
||||
|
||||
export type GitLabWorkspaceSource = WorkspaceSourceLinkedItem & {
|
||||
provider: 'gitlab'
|
||||
type: 'issue' | 'mr'
|
||||
}
|
||||
|
||||
export type LinearWorkspaceSource = WorkspaceSourceLinkedItem & {
|
||||
provider: 'linear'
|
||||
type: 'issue'
|
||||
}
|
||||
|
||||
export type WorkspaceSourceItemLike = Omit<WorkspaceSourceLinkedItem, 'provider'> & {
|
||||
provider?: WorkspaceSourceProvider
|
||||
}
|
||||
|
||||
export type WorkspaceSourceSelectionKind =
|
||||
| 'github-pr'
|
||||
| 'github-issue'
|
||||
| 'gitlab-mr'
|
||||
| 'gitlab-issue'
|
||||
| 'branch'
|
||||
| 'linear'
|
||||
| 'jira'
|
||||
|
||||
export type WorkspaceSourceSelection = {
|
||||
kind: WorkspaceSourceSelectionKind
|
||||
label: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
const GITLAB_ISSUE_PATH_RE = /\/-\/(?:issues|work_items)\//i
|
||||
|
||||
export function isGitLabIssueUrl(url: string): boolean {
|
||||
try {
|
||||
return GITLAB_ISSUE_PATH_RE.test(new URL(url).pathname)
|
||||
} catch {
|
||||
return GITLAB_ISSUE_PATH_RE.test(url)
|
||||
}
|
||||
}
|
||||
|
||||
function isJiraIssueUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return (
|
||||
/\.atlassian\.net$/i.test(parsed.hostname) ||
|
||||
/\/browse\/[A-Z][A-Z0-9]+-\d+/i.test(parsed.pathname)
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceSourceProvider(item: WorkspaceSourceItemLike): WorkspaceSourceProvider {
|
||||
if (item.provider) {
|
||||
return item.provider
|
||||
}
|
||||
if (item.linearIdentifier) {
|
||||
return 'linear'
|
||||
}
|
||||
if (item.jiraIdentifier || isJiraIssueUrl(item.url)) {
|
||||
return 'jira'
|
||||
}
|
||||
if (item.type === 'mr' || isGitLabIssueUrl(item.url)) {
|
||||
return 'gitlab'
|
||||
}
|
||||
if (item.number === 0 && !item.url.includes('github.com')) {
|
||||
return 'linear'
|
||||
}
|
||||
return 'github'
|
||||
}
|
||||
|
||||
export function buildGitHubWorkspaceSource(item: {
|
||||
type: 'issue' | 'pr'
|
||||
number: number
|
||||
title: string
|
||||
url: string
|
||||
repoId?: string
|
||||
}): GitHubWorkspaceSource {
|
||||
return { provider: 'github', ...item }
|
||||
}
|
||||
|
||||
export function buildGitLabWorkspaceSource(item: {
|
||||
type: 'issue' | 'mr'
|
||||
number: number
|
||||
title: string
|
||||
url: string
|
||||
repoId?: string
|
||||
}): GitLabWorkspaceSource {
|
||||
return { provider: 'gitlab', ...item }
|
||||
}
|
||||
|
||||
export function buildLinearWorkspaceSource(
|
||||
issue: Pick<LinearIssue, 'identifier' | 'title' | 'url' | 'workspaceId'>
|
||||
): LinearWorkspaceSource {
|
||||
const organizationUrlKey = getLinearOrganizationUrlKeyFromIssueUrl(issue.url)
|
||||
return {
|
||||
provider: 'linear',
|
||||
type: 'issue',
|
||||
// Why: Linear uses a string identifier; numeric issue metadata must stay empty.
|
||||
number: 0,
|
||||
title: issue.title,
|
||||
url: issue.url,
|
||||
linearIdentifier: issue.identifier,
|
||||
...(issue.workspaceId ? { linearWorkspaceId: issue.workspaceId } : {}),
|
||||
...(organizationUrlKey ? { linearOrganizationUrlKey: organizationUrlKey } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldApplyWorkspaceSourceAutoName(args: {
|
||||
currentName: string
|
||||
lastAutoName: string
|
||||
}): boolean {
|
||||
return (
|
||||
!args.currentName.trim() ||
|
||||
args.currentName === args.lastAutoName ||
|
||||
isWorkItemLookupText(args.currentName)
|
||||
)
|
||||
}
|
||||
|
||||
function toWorkspaceIntentItem(item: WorkspaceSourceItemLike): WorkspaceIntentWorkItem {
|
||||
return { ...item, provider: getWorkspaceSourceProvider(item) }
|
||||
}
|
||||
|
||||
export function getWorkspaceSourceName(item: WorkspaceSourceItemLike): {
|
||||
seedName: string
|
||||
displayName: string
|
||||
} {
|
||||
const normalized = toWorkspaceIntentItem(item)
|
||||
const resolved = getLinkedWorkItemWorkspaceName(normalized)
|
||||
return {
|
||||
seedName: resolved?.seedName ?? getLinkedWorkItemSuggestedName(normalized),
|
||||
displayName: resolved?.displayName ?? item.title.trim()
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWorkspaceSourceSelection(args: {
|
||||
linkedWorkItem: WorkspaceSourceItemLike | null
|
||||
baseBranch?: string
|
||||
}): WorkspaceSourceSelection | null {
|
||||
const { linkedWorkItem, baseBranch } = args
|
||||
if (!linkedWorkItem) {
|
||||
return baseBranch ? { kind: 'branch', label: baseBranch } : null
|
||||
}
|
||||
const provider = getWorkspaceSourceProvider(linkedWorkItem)
|
||||
const kind: WorkspaceSourceSelectionKind =
|
||||
provider === 'linear'
|
||||
? 'linear'
|
||||
: provider === 'jira'
|
||||
? 'jira'
|
||||
: provider === 'gitlab'
|
||||
? linkedWorkItem.type === 'mr'
|
||||
? 'gitlab-mr'
|
||||
: 'gitlab-issue'
|
||||
: linkedWorkItem.type === 'pr'
|
||||
? 'github-pr'
|
||||
: 'github-issue'
|
||||
return {
|
||||
kind,
|
||||
label:
|
||||
provider === 'linear' || provider === 'jira' || linkedWorkItem.number === 0
|
||||
? linkedWorkItem.title
|
||||
: `#${linkedWorkItem.number} ${linkedWorkItem.title}`,
|
||||
url: linkedWorkItem.url
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldPreserveWorkspaceSourceOnRepoChange(
|
||||
item: WorkspaceSourceItemLike | null
|
||||
): boolean {
|
||||
if (!item) {
|
||||
return false
|
||||
}
|
||||
const provider = getWorkspaceSourceProvider(item)
|
||||
return provider === 'linear' || provider === 'jira'
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getClientWorktreeCreateCandidate,
|
||||
isRetryableWorktreeCreateConflict
|
||||
} from './worktree-create-retry-policy'
|
||||
|
||||
describe('client worktree create retry policy', () => {
|
||||
it('uses the same suffix sequence for every client', () => {
|
||||
expect(getClientWorktreeCreateCandidate('feature', 0)).toBe('feature')
|
||||
expect(getClientWorktreeCreateCandidate('feature', 1)).toBe('feature-2')
|
||||
})
|
||||
|
||||
it('retries only known branch and review conflicts', () => {
|
||||
expect(isRetryableWorktreeCreateConflict('Branch already exists locally')).toBe(true)
|
||||
expect(isRetryableWorktreeCreateConflict('Branch "x" already exists.')).toBe(true)
|
||||
expect(isRetryableWorktreeCreateConflict('Branch already has PR #42')).toBe(true)
|
||||
expect(isRetryableWorktreeCreateConflict('Permission denied')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
export const CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS = 25
|
||||
|
||||
// Why: mixed-version runtimes can still return these legacy conflicts instead
|
||||
// of performing their own suffix retry, so every client needs one policy.
|
||||
const RETRYABLE_WORKTREE_CREATE_CONFLICT_PATTERNS = [
|
||||
/already exists locally/i,
|
||||
/already exists on a remote/i,
|
||||
/^Branch ".+" already exists\./i,
|
||||
/already has pr #\d+/i
|
||||
]
|
||||
|
||||
export function getClientWorktreeCreateCandidate(value: string, attempt: number): string {
|
||||
return attempt === 0 ? value : `${value}-${attempt + 1}`
|
||||
}
|
||||
|
||||
export function isRetryableWorktreeCreateConflict(message: string): boolean {
|
||||
return RETRYABLE_WORKTREE_CREATE_CONFLICT_PATTERNS.some((pattern) => pattern.test(message))
|
||||
}
|
||||
@@ -45,6 +45,28 @@ describe('evaluateCompat', () => {
|
||||
expect(verdict).toEqual({ kind: 'ok' })
|
||||
})
|
||||
|
||||
it('allows desktop protocol 3 to roll out before mobile protocol 2 updates', () => {
|
||||
const verdict = evaluateCompat({
|
||||
mobileProtocolVersion: 2,
|
||||
minCompatibleDesktopVersion: 2,
|
||||
desktopProtocolVersion: 3,
|
||||
desktopMinCompatibleMobileVersion: 2
|
||||
})
|
||||
|
||||
expect(verdict).toEqual({ kind: 'ok' })
|
||||
})
|
||||
|
||||
it('allows mobile protocol 3 to roll out before desktop protocol 2 updates', () => {
|
||||
const verdict = evaluateCompat({
|
||||
mobileProtocolVersion: 3,
|
||||
minCompatibleDesktopVersion: 2,
|
||||
desktopProtocolVersion: 2,
|
||||
desktopMinCompatibleMobileVersion: 2
|
||||
})
|
||||
|
||||
expect(verdict).toEqual({ kind: 'ok' })
|
||||
})
|
||||
|
||||
it('blocks with mobile-too-old when desktop requires a newer mobile', () => {
|
||||
const verdict = evaluateCompat({
|
||||
mobileProtocolVersion: MOBILE_V,
|
||||
|
||||
Reference in New Issue
Block a user