fix: base a new fork on the dev workspace when forking from one (#10489)

* fix: base a new fork on the dev workspace when forking from one

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: carry dev-workspace fields on the superadmin-synthesized entry

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-08-04 00:51:33 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent f365929eaa
commit db46d34727
5 changed files with 79 additions and 12 deletions
@@ -12,6 +12,7 @@
import {
findWorkspaceDescendants,
findCanonicalDevWorkspace,
findDefaultForkBase,
findWorkspaceRoot,
buildWorkspaceHierarchy
} from '$lib/utils/workspaceHierarchy'
@@ -107,6 +108,9 @@
const devOfRoot = $derived(
root ? findCanonicalDevWorkspace(root.id, forkableWorkspaces) : undefined
)
// Base a new fork gets by default: the dev workspace when the selection sits in its subtree, the
// family root otherwise.
const defaultForkBase = $derived(findDefaultForkBase(effectiveId, forkableWorkspaces))
const createForkLabel = 'Create new fork…'
// Candidate bases ("targets") for a new fork: the root plus every fork/dev in the family, so a fork
// can itself be the base — i.e. a fork of a fork. Root first, matching the list order below.
@@ -205,8 +209,8 @@
// Bare fork id, without the wm-fork- prefix. Forks have no separate display
// name — the id is the name (it also becomes the git branch).
let newForkId = $state('')
// The base ("target") a new fork will branch from. Defaults to the root; the user can pick any fork
// in the family (via the inline target selector) to create a fork of a fork.
// The base ("target") a new fork will branch from. Defaults to `defaultForkBase`; the user can pick
// any fork in the family (via the inline target selector) to create a fork of a fork.
let createForkBaseId = $state<string | undefined>(undefined)
// Manual keyboard navigation, modelled after SelectDropdown. melt's
@@ -266,8 +270,8 @@
}
function enterCreateMode(initialId?: string, baseId?: string) {
// Default the target to the root; the user can switch to any fork in the family (fork of a fork).
createForkBaseId = baseId ?? root?.id
// The user can switch to any fork in the family (fork of a fork).
createForkBaseId = baseId ?? defaultForkBase?.id
creatingFork = true
newForkId = initialId ?? defaultForkId()
// Focus + select is handled by the input's own autofocus (it mounts with
@@ -338,8 +342,8 @@
const wasOpen = lastDropdownOpen
lastDropdownOpen = dropdownOpen
if (dropdownOpen && !wasOpen && pendingFork && !creatingFork && showCreateFork) {
// Preserve the base the fork was staged from; without this the re-entry defaults to the root
// and silently re-parents a fork that was staged off another fork.
// Preserve the base the fork was staged from; without this the re-entry falls back to the
// default base and silently re-parents a fork that was staged off another workspace.
void enterCreateMode(
pendingFork.id.startsWith(WM_FORK_PREFIX)
? pendingFork.id.slice(WM_FORK_PREFIX.length)
@@ -475,8 +479,8 @@
{#if showCreateFork}
<div class="my-1 border-t border-border-light shrink-0"></div>
{#if creatingFork}
<!-- Small inline form with labels: fork id + base ("target") workspace. The base
defaults to the root; picking a fork there creates a fork of a fork. -->
<!-- Small inline form with labels: fork id + base ("target") workspace. Picking a
fork as the base creates a fork of a fork. -->
<div class="flex flex-col gap-2 px-2.5 py-2">
<div class="flex flex-col gap-0.5">
<span class="text-2xs font-normal text-hint">Fork ID</span>
@@ -24,7 +24,8 @@
import {
workspaceIsFork,
findWorkspaceRoot,
findWorkspaceDescendants
findWorkspaceDescendants,
findDefaultForkBase
} from '$lib/utils/workspaceHierarchy'
import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
import {
@@ -115,7 +116,9 @@
subtitle: w.is_dev_workspace ? 'dev workspace' : w.id === familyRoot?.id ? undefined : 'fork'
}))
)
let defaultBaseWorkspaceId = $derived(familyRoot?.id)
let defaultBaseWorkspaceId = $derived(
findDefaultForkBase($workspaceStore, forkableWorkspaces)?.id
)
// Seed the base once the family is known; keep an explicit user choice as long as it stays valid.
$effect(() => {
if (!isFork) return
@@ -896,8 +899,8 @@
{#if createAsDevWorkspace}
A dev workspace is always based on the root workspace.
{:else}
Workspace to fork from. Defaults to the root; pick an existing fork to create a fork
of a fork (the new branch is based on the selected workspace's branch).
Workspace to fork from: the new branch is based on the selected workspace's branch.
Pick an existing fork to create a fork of a fork.
{/if}
</span>
<Select
@@ -42,6 +42,10 @@ export function useForkableWorkspaces(args: {
username: '',
color: w.color ?? undefined,
parent_workspace_id: w.parent_workspace_id,
// Dev-ness travels with the entry: the fork-base default and the "dev" badge both read
// it off the list, and a synthesized entry missing it reads as an ordinary fork.
is_dev_workspace: w.is_dev_workspace,
dev_workspace_label: w.dev_workspace_label,
disabled: false
}
]
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { findDefaultForkBase } from './workspaceHierarchy'
import type { UserWorkspace } from '../stores'
function ws(id: string, parent?: string, extra: Partial<UserWorkspace> = {}): UserWorkspace {
return { id, name: id, username: 'u', parent_workspace_id: parent, disabled: false, ...extra }
}
const root = ws('prod')
const dev = ws('devws', 'prod', { is_dev_workspace: true })
const forkOfDev = ws('wm-fork-a', 'devws')
const forkOfRoot = ws('wm-fork-b', 'prod')
const family = [root, dev, forkOfDev, forkOfRoot]
describe('findDefaultForkBase', () => {
it('bases a fork on the dev workspace from inside its subtree', () => {
expect(findDefaultForkBase('devws', family)?.id).toBe('devws')
expect(findDefaultForkBase('wm-fork-a', family)?.id).toBe('devws')
})
it('bases a fork on the root outside the dev subtree', () => {
expect(findDefaultForkBase('prod', family)?.id).toBe('prod')
expect(findDefaultForkBase('wm-fork-b', family)?.id).toBe('prod')
})
it('skips a dev workspace the user is disabled in', () => {
const disabledDev = [root, { ...dev, disabled: true }, forkOfDev]
expect(findDefaultForkBase('wm-fork-a', disabledDev)?.id).toBe('prod')
})
})
@@ -168,6 +168,31 @@ export function findCanonicalDevWorkspace(
)
}
/**
* The workspace a new fork should branch from by default. Inside a dev workspace's subtree the base is
* that dev workspace, not the family root: the dev workspace carries the changes being worked on, and
* a prod locked against forking (`lock_prod_forking`) rejects a root-based fork outright. Anywhere
* else it is the family root, so an ad-hoc fork branches from prod rather than from whichever
* throwaway fork happens to be open. A dev workspace the user is disabled in is skipped — forking from
* it would fail — and the walk continues upwards.
*/
export function findDefaultForkBase(
currentWorkspaceId: string | undefined,
allWorkspaces: UserWorkspace[]
): UserWorkspace | undefined {
let current = allWorkspaces.find((w) => w.id === currentWorkspaceId)
while (current) {
if (current.is_dev_workspace && !current.disabled) return current
if (!current.parent_workspace_id) break
const parent: UserWorkspace | undefined = allWorkspaces.find(
(w) => w.id === current!.parent_workspace_id
)
if (!parent) break
current = parent
}
return findWorkspaceRoot(currentWorkspaceId, allWorkspaces)
}
/**
* Helper function to find all descendants of a workspace
*/