fix: stop reading a parent-only fork item as deleted in the fork (#10467)

* fix: stop reading a parent-only fork item as deleted in the fork

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

* style: condense the deploy-direction helper comments

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

* fix: keep the ambiguous half of a one-sided diff out of bulk defaults

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

* fix: disable select-all on a removal-only list and cover the hidden source-only row

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

* fix: keep parent-only items out of the fork merge list entirely

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

* fix: count the fork banner's ahead/behind with the compare page's predicate

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

* fix: open the direction the fork banner's button offers

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

* chore: cache the sqlx query for the source-only visibility test

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

* fix: don't read an unloaded comparison as nothing to deploy

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

* fix: treat an in-flight comparison as unknown in the fork banner

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-03 18:14:41 +00:00
committed by GitHub
co-authored by Claude Opus 5
parent 0827fd285b
commit 689f5d7c75
12 changed files with 303 additions and 84 deletions
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_diff\n (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork)\n VALUES ('test-workspace', 'wm-fork-test-workspace', 'f/rt/parent_only', 'http_trigger', 1, 0, true, true, false)",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "aba0caddc216e218225d41cc3e14db0d8013ca448ca469f1c8c90965a514a8fb"
}
@@ -1754,6 +1754,83 @@ async fn test_compare_workspaces_phantom_trigger_shortfuse(
Ok(())
}
/// A source-only row is offered to the fork whatever its counters say, so it can
/// carry `behind = 0` — a `behind`-derived tally never sees it, and hiding one must
/// still be reported to the update direction. The merge direction does not carry
/// such a row at all, so hiding one withholds nothing from that side.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_compare_workspaces_hidden_source_only_no_behind(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base_url = format!("http://localhost:{port}/api");
let non_admin = windmill_api_client::create_client(
&format!("http://localhost:{port}"),
"SECRET_TOKEN_2".to_string(),
);
sqlx::query!(
"INSERT INTO workspace (id, name, owner, parent_workspace_id)
VALUES ('wm-fork-test-workspace', 'Fork', 'test-user', 'test-workspace')"
)
.execute(&db)
.await?;
sqlx::query!("INSERT INTO workspace_settings (workspace_id) VALUES ('wm-fork-test-workspace')")
.execute(&db)
.await?;
sqlx::query!(
"INSERT INTO workspace_key(workspace_id, kind, key)
VALUES ('wm-fork-test-workspace', 'cloud', 'test-key')"
)
.execute(&db)
.await?;
// Source-only row with no `behind`: the trigger has no backing row, so the
// visibility filter drops it exactly as it would an ACL-hidden one.
sqlx::query!(
"INSERT INTO workspace_diff
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes, exists_in_source, exists_in_fork)
VALUES ('test-workspace', 'wm-fork-test-workspace', 'f/rt/parent_only', 'http_trigger', 1, 0, true, true, false)"
)
.execute(&db)
.await?;
let comparison: serde_json::Value = non_admin
.client()
.get(&format!(
"{base_url}/w/test-workspace/workspaces/compare/wm-fork-test-workspace"
))
.send()
.await?
.json()
.await?;
assert_eq!(
comparison["all_behind_items_visible"].as_bool(),
Some(false),
"a hidden source-only row must trip the warning even at behind = 0: {comparison}"
);
assert_eq!(
comparison["hidden_behind"]["total"].as_i64(),
Some(1),
"a hidden source-only row must be counted as withheld from the update direction: {comparison}"
);
assert_eq!(
comparison["all_ahead_items_visible"].as_bool(),
Some(true),
"the merge direction does not carry a source-only row, so hiding one withholds nothing from it: {comparison}"
);
assert_eq!(
comparison["hidden_ahead"]["total"].as_i64(),
Some(0),
"a source-only row must not be reported as withheld from the merge direction: {comparison}"
);
Ok(())
}
/// Regression: the "sees everything" guard must require admin of BOTH sides, not
/// just the fork. `filter_visible_diffs` keeps a modified/conflict row (one that
/// exists in the source AND the fork) only when the caller can see it on both
@@ -9585,16 +9585,28 @@ async fn compare_workspaces(
.count(),
};
let all_ahead_items_visible = summary.total_ahead
== confirmed_diffs
.iter()
// Each direction accounts for what it carries (see the frontend's
// `diffActionableInDirection`): a lineage merge leaves out a row the fork lacks,
// while the update takes it whatever the counters say — and since it can carry
// `behind = 0`, that side counts rows rather than sums.
let source_only = |d: &WorkspaceDiffRow| {
d.exists_in_source.unwrap_or(false) && !d.exists_in_fork.unwrap_or(false)
};
let merge_carries = |d: &WorkspaceDiffRow| !is_lineage_pair || !source_only(d);
let ahead_sum = |rows: &[WorkspaceDiffRow]| {
rows.iter()
.filter(|d| merge_carries(d))
.map(|s| s.ahead)
.fold(0, |acc, s| acc + s.try_into().unwrap_or(0));
.fold(0i64, |acc, s| acc + i64::from(s))
};
let all_ahead_items_visible = ahead_sum(&visible_diffs) == ahead_sum(&confirmed_diffs);
let all_behind_items_visible = summary.total_behind
== confirmed_diffs
.iter()
.map(|s| s.behind)
.fold(0, |acc, s| acc + s.try_into().unwrap_or(0));
.fold(0, |acc, s| acc + s.try_into().unwrap_or(0))
&& visible_diffs.iter().filter(|d| source_only(d)).count()
== confirmed_diffs.iter().filter(|d| source_only(d)).count();
// Blast-radius guard for the "changes not visible to your user" warning
// (which hides the deploy button). The flag is a pure visibility guarantee —
@@ -9634,7 +9646,9 @@ async fn compare_workspaces(
if visible_keys.contains(&(d.kind.as_str(), d.path.as_str())) {
continue;
}
if d.ahead > 0 {
// Both sides mirror the flags above: a row is only withheld from a direction
// that would have carried it.
if d.ahead > 0 && merge_carries(d) {
hidden_ahead.total += 1;
*hidden_ahead.by_kind.entry(d.kind.clone()).or_default() += 1;
if sees_all_items {
@@ -9643,7 +9657,7 @@ async fn compare_workspaces(
.push(HiddenItem { kind: d.kind.clone(), path: d.path.clone() });
}
}
if d.behind > 0 {
if d.behind > 0 || source_only(d) {
hidden_behind.total += 1;
*hidden_behind.by_kind.entry(d.kind.clone()).or_default() += 1;
if sees_all_items {
@@ -43,6 +43,9 @@
checkDeployPermission,
deployItem,
deleteItemInWorkspace,
diffActionableInDirection,
diffCreatesInTarget,
diffRemovesInTarget,
getItemValue,
getOnBehalfOf,
type DeployPermission,
@@ -407,13 +410,9 @@
let canPreserveOnBehalfOf = $derived(mergeIntoParent ? canPreserveInParent : canPreserveInCurrent)
let selectableDiffs = $derived(
comparison?.diffs.filter((diff) => {
if (mergeIntoParent) {
return diff.ahead > 0
} else {
return diff.behind > 0
}
}) ?? []
comparison?.diffs.filter((diff) =>
diffActionableInDirection(diff, mergeIntoParent, isArbitraryTarget)
) ?? []
)
let selectedItems = $state<string[]>([])
@@ -457,10 +456,17 @@
}) ?? []
)
// Gates the "update before deploying" alert, so it counts what a merge would
// actually carry — not every row with an `ahead` counter, which includes the
// parent-only ones the merge direction leaves out.
let itemsWithAheadChanges = $derived(
comparison?.diffs.filter((diff) => {
const status = deploymentStatus[getItemKey(diff)]?.status
return diff && diff.ahead > 0 && !(status && status == 'deployed')
return (
diff &&
diffActionableInDirection(diff, true, isArbitraryTarget) &&
!(status && status == 'deployed')
)
}) ?? []
)
@@ -477,6 +483,7 @@
let onBehalfOfChoice = $state<Record<string, OnBehalfOfChoice>>({})
let customOnBehalfOf = $state<Record<string, OnBehalfOfDetails>>({})
let deployTargetWorkspace = $derived(mergeIntoParent ? parentWorkspaceId : currentWorkspaceId)
let deploySourceWorkspace = $derived(mergeIntoParent ? currentWorkspaceId : parentWorkspaceId)
function getItemKey(diff: WorkspaceItemDiff): string {
return `${diff.kind}:${diff.path}`
@@ -641,12 +648,10 @@
// All *diff* items selected. Trigger items are opt-in and don't count
// toward "all selected" — see item merge below in deployableItems.
// Deploying a row the current workspace lacks deletes it in the target. Against
// the parent that is a real deletion to propagate and is selected like anything
// else; against an arbitrary target the two workspaces were simply never in sync,
// so it takes an explicit act on that row. No bulk action may sweep one in.
// A row whose deploy deletes in the target takes an explicit tick (see
// `diffRemovesInTarget`); no bulk action may sweep one in.
function removesInTarget(diff: WorkspaceItemDiff): boolean {
return isArbitraryTarget && diff.exists_in_fork === false
return diffRemovesInTarget(diff, mergeIntoParent)
}
let bulkSelectableDiffs = $derived(selectableDiffs.filter((d) => !removesInTarget(d)))
@@ -691,17 +696,14 @@
) {
deploymentStatus[statusKey] = { status: 'loading' }
// Check if the item was deleted in the source workspace.
// If so, archive/delete it in the target workspace instead of copying.
// The workspace this deploy reads from doesn't have the item: archive/delete
// it in the target instead of copying. Same predicate as the row's badge and
// its exclusion from bulk selection, so what the row says is what it does.
const diff = comparison?.diffs.find((d) => getItemKey(d) === statusKey)
const itemDeletedInSource = diff
? mergeIntoParent
? diff.exists_in_fork === false
: diff.exists_in_source === false
: false
const removes = diff ? removesInTarget(diff) : false
let result: DeployResult
if (itemDeletedInSource) {
if (removes) {
result = await deleteItemInWorkspace(kind, path, workspaceToDeployTo)
} else {
result = await deployItem({
@@ -899,9 +901,15 @@
const filtered = bulkSelectableDiffs.filter(
(d) => !isTriggerOrScheduleKind(d.kind) && !hasDraft(d)
)
// The update direction leaves out two ambiguous shapes, both still one click
// away through "Select all": a conflict, and a parent-only row the fork has
// deploy events for — the fork may have dropped it on purpose, and a routine
// update must not silently bring it back.
const conflictSafe = mergeIntoParent
? filtered
: filtered.filter((d) => !(d.ahead > 0 && d.behind > 0))
: filtered.filter(
(d) => !(d.ahead > 0 && d.behind > 0) && !(d.ahead > 0 && diffCreatesInTarget(d, false))
)
// When reached from a session's Review, narrow the default to this chat's items.
const scoped = chatMask ? conflictSafe.filter((d) => diffInMask(d, chatMask)) : conflictSafe
selectedItems = scoped
@@ -987,6 +995,10 @@
let removalKeys = new Set<string>()
$effect(() => {
const diffs = comparison?.diffs
// Removals are direction-dependent, so a flip must refresh the set: keeping the
// other direction's would make every row of the new one look freshly flipped
// and revoke the opt-in this guard protects.
;[mergeIntoParent]
if (!diffs) return
untrack(() => {
const current = new Set(diffs.filter(removesInTarget).map((d) => getItemKey(d)))
@@ -1565,39 +1577,20 @@
<AlertTriangle class="w-3 h-3 inline mr-0.5" />+Draft
</Badge>
{/if}
<!-- Status badges -->
{#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead == 0 && diff.behind > 0}
<!-- Status badges. An item on one side only states the effect of
deploying it, never which side moved — the comparison cannot know
(see `diffRemovesInTarget`). -->
{#if diffCreatesInTarget(diff, mergeIntoParent)}
<Badge
title="This item was newly created in the parent workspace '{parentWorkspaceId}'"
title="This item exists in '{deploySourceWorkspace}' but not in '{deployTargetWorkspace}' — deploying it creates it there"
color="indigo"
size="xs">New</Badge
>
{/if}
{#if !diff.exists_in_fork && diff.exists_in_source && diff.ahead > 0}
<!-- Same row, two readings: against the parent the fork deleted the
item, while against an arbitrary target it may simply never have
existed here. Deploying removes it there either way — say that
rather than asserting a deletion that may not have happened. -->
{:else if removesInTarget(diff)}
<Badge
title={isArbitraryTarget
? `This item exists only in '${parentWorkspaceId}' — deploying it removes it there`
: `This item was deleted in '${currentWorkspaceId}'`}
title="This item exists in '{deployTargetWorkspace}' but not in '{deploySourceWorkspace}' — deploying it removes it there"
color="red"
size="xs">{isArbitraryTarget ? 'Removes in target' : 'Deleted'}</Badge
>
{/if}
{#if diff.exists_in_fork && !diff.exists_in_source && diff.behind > 0}
<Badge
title="This item was deleted in the parent workspace '{parentWorkspaceId}'"
color="red"
size="xs">Deleted</Badge
>
{/if}
{#if diff.exists_in_fork && !diff.exists_in_source && diff.ahead > 0 && diff.behind == 0}
<Badge
title="This item was newly created in '{currentWorkspaceId}'"
color="indigo"
size="xs">New</Badge
size="xs">Removes in {deployTargetWorkspace}</Badge
>
{/if}
{@const ciStatus = getCiTestStatus(diff)}
@@ -9,6 +9,7 @@
import { onMount, untrack } from 'svelte'
import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import { devLabelWord } from '$lib/utils/devWorkspaceLabel'
import { diffActionableInDirection } from '$lib/utils_workspace_deploy'
let loading = $state(false)
let comparison: WorkspaceComparison | undefined = $state(undefined)
@@ -82,9 +83,14 @@
}
}
// Opens the direction the button offers, so the label and the list agree: a fork
// with nothing to deploy lands on the update side, not on an empty deploy list.
// Both read `comparisonLoaded` — an unknown comparison counts zero of everything,
// which is indistinguishable from "nothing to deploy".
function openComparisonDrawer() {
if (parentWorkspaceId && $workspaceStore) {
goto('/forks/compare?workspace_id=' + encodeURIComponent($workspaceStore), {
const dir = comparisonLoaded && changesAhead === 0 ? '&dir=update' : ''
goto('/forks/compare?workspace_id=' + encodeURIComponent($workspaceStore) + dir, {
replaceState: true
})
}
@@ -148,6 +154,19 @@
return () => clearInterval(interval)
})
// Counted with the compare page's own predicate so the banner never advertises a
// direction whose list is empty: the `ahead`/`behind` sums in the summary include
// rows a direction does not carry, and miss a parent-only row that the update
// direction carries at `behind = 0`.
function countDir(c: WorkspaceComparison | undefined, mergeIntoParent: boolean): number {
return c?.diffs.filter((d) => diffActionableInDirection(d, mergeIntoParent)).length ?? 0
}
// A comparison in flight is not an answer: on a fork switch the component stays
// mounted and `comparison` still holds the previous fork's rows.
const comparisonLoaded = $derived(!loading && comparison !== undefined)
const changesAhead = $derived(countDir(comparison, true))
const changesBehind = $derived(countDir(comparison, false))
function forkAheadBehindMessage(changesAhead: number, changesBehind: number) {
let msg: string[] = []
if (changesAhead > 0 || changesBehind > 0) {
@@ -195,10 +214,7 @@
<div class="flex items-center flex-wrap gap-x-4 gap-y-1 text-xs min-w-0">
{#if comparison.summary.total_diffs > 0}
<span class="text-blue-700 dark:text-blue-100">
{forkAheadBehindMessage(
comparison.summary.total_ahead,
comparison.summary.total_behind
)}
{forkAheadBehindMessage(changesAhead, changesBehind)}
<span class="font-semibold underline">{parentWorkspaceId}</span> over {comparison
.summary.total_diffs} items<span class="hidden lg:inline">:</span>
</span>
@@ -343,7 +359,7 @@
>
{#if showDraftsOnly}
Review & deploy drafts
{:else if (comparison?.summary.total_ahead ?? 0) > 0}
{:else if !comparisonLoaded || changesAhead > 0}
Review & Deploy Changes
{:else}
Review & Update fork
@@ -85,7 +85,10 @@
}: Props = $props()
let selectableItems = $derived(items.filter(selectablePredicate))
let hasSelectableItems = $derived(selectableItems.length > 0)
// "Select all" is a bulk action, so it lives or dies by the bulk-selectable set:
// a list of nothing but bulk-excluded rows would otherwise offer an enabled
// control that selects nothing. Each such row is still selectable on its own.
let hasSelectableItems = $derived(selectableItems.some((i) => !bulkExcluded(i)))
// Plain row click and the checkbox both toggle this row in/out — multi-select
// is the default, no modifier needed.
@@ -195,6 +198,13 @@
{#if showGroupHeaders}
{@const selectable = groupSelectable(group)}
{@const selectedCount = selectable.filter((i) => selectedItems.includes(i.key)).length}
<!-- The label counts every ticked row, bulk-excluded ones included: those
are deployed like any other, and reporting only the bulk-selectable
ones would contradict the deploy button. The checkbox above keeps its
own count, which must ignore them to reach a full-checked state. -->
{@const selectedInGroup = group.items.filter((i) =>
selectedItems.includes(i.key)
).length}
<!-- The disabled-state hint lives on the row: a disabled Checkbox is
pointer-events-none, so a title on the input would never show. -->
<div
@@ -219,8 +229,8 @@
{/if}
<span class="text-xs font-semibold text-secondary truncate">{group.label}</span>
<span class="text-2xs text-tertiary whitespace-nowrap">
{group.items.length} item{group.items.length !== 1 ? 's' : ''}{selectedCount > 0
? ` · ${selectedCount} selected`
{group.items.length} item{group.items.length !== 1 ? 's' : ''}{selectedInGroup > 0
? ` · ${selectedInGroup} selected`
: ''}
</span>
{#if groupActions}
@@ -6014,8 +6014,8 @@ function formatForkIndexEntry(e: ForkDiffEntryView): string {
switch (e.status) {
case 'only_in_fork':
return `- ${name} — only in fork (${e.patchLineCount} lines)${draftFlag}`
case 'deleted_in_fork':
return `- ${name}deleted in fork, still in parent${draftFlag}`
case 'only_in_parent':
return `- ${name}only in parent, not in fork${draftFlag}`
case 'modified':
return `- ${name} — differs (${aheadBehind}; ${e.patchLineCount} diff lines)${draftFlag}`
case 'unchanged':
@@ -6193,8 +6193,8 @@ function renderForkEntrySection(
const header =
entry.status === 'only_in_fork'
? `${entry.kind} "${path}" exists only in the fork — not in parent "${parent}". Full content:\n\n`
: entry.status === 'deleted_in_fork'
? `${entry.kind} "${path}" was deleted in the fork but still exists in parent "${parent}". Removed content:\n\n`
: entry.status === 'only_in_parent'
? `${entry.kind} "${path}" exists only in parent "${parent}" — not in the fork. Parent content:\n\n`
: `Fork changes vs parent "${parent}" for ${entry.kind} "${path}":\n\n`
if (args.file !== undefined && !entry.files) {
throw new Error(
@@ -465,7 +465,7 @@ describe('fork mode', () => {
expect(byPath['f/a/b'].status).toBe('modified')
expect(byPath['f/a/new'].status).toBe('only_in_fork')
expect(byPath['f/a/new'].patch).not.toContain('parent-ws')
expect(byPath['f/a/gone'].status).toBe('deleted_in_fork')
expect(byPath['f/a/gone'].status).toBe('only_in_parent')
// One-sided entries fetch only the existing side: 2 + 1 + 1 calls.
expect(getItemValue).toHaveBeenCalledTimes(4)
})
@@ -620,7 +620,7 @@ const FORK_COMPARISON_REUSE_MS = 30_000
export type ForkDiffStatus =
| 'modified'
| 'only_in_fork'
| 'deleted_in_fork'
| 'only_in_parent'
| 'unchanged'
| 'pending'
| 'error'
@@ -657,7 +657,7 @@ export interface ForkDiffIndexView {
}
interface ForkMaterialized {
status: 'modified' | 'only_in_fork' | 'deleted_in_fork' | 'unchanged' | 'error'
status: 'modified' | 'only_in_fork' | 'only_in_parent' | 'unchanged' | 'error'
patch: string
lineCount: number
files?: Record<string, DiffFileView>
@@ -1023,7 +1023,7 @@ async function materializeFork(
const forkValue = forkSide?.value
const valueMasked = parentSide?.valueMasked === true || forkSide?.valueMasked === true
const oneSidedStatus = !entry.existsInFork
? 'deleted_in_fork'
? 'only_in_parent'
: !entry.existsInParent
? 'only_in_fork'
: undefined
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest'
import {
diffActionableInDirection,
diffCreatesInTarget,
diffRemovesInTarget
} from './utils_workspace_deploy'
/** The row shape the fork comparison returns for an item the parent has and the
* fork does not. `ahead = 1, behind = 0` is what the tally leaves after the fork
* pulled the parent's item in and then lost it (a delete, a git-sync revert):
* every deploy event in the fork counts as `ahead`, whatever wrote it. */
const parentOnly = { ahead: 1, behind: 0, exists_in_source: true, exists_in_fork: false }
const forkOnly = { ahead: 1, behind: 0, exists_in_source: false, exists_in_fork: true }
const bothSides = { ahead: 1, behind: 1, exists_in_source: true, exists_in_fork: true }
describe('deploy direction of a one-sided diff row', () => {
it('offers a parent-only item to the fork even with no behind count', () => {
expect(diffActionableInDirection(parentOnly, false)).toBe(true)
expect(diffCreatesInTarget(parentOnly, false)).toBe(true)
expect(diffRemovesInTarget(parentOnly, false)).toBe(false)
})
it('keeps a parent-only row out of a merge into the parent, whatever its ahead count', () => {
expect(diffActionableInDirection(parentOnly, true)).toBe(false)
// An arbitrary target has no tally behind it and does propagate the removal.
expect(diffActionableInDirection(parentOnly, true, true)).toBe(true)
expect(diffRemovesInTarget(parentOnly, true)).toBe(true)
})
it('does not resurrect a fork-only item into an update of the fork', () => {
expect(diffActionableInDirection(forkOnly, false)).toBe(false)
expect(diffCreatesInTarget(forkOnly, true)).toBe(true)
})
it('keeps a two-sided row on its counters', () => {
expect(diffActionableInDirection(bothSides, true)).toBe(true)
expect(diffActionableInDirection({ ...bothSides, behind: 0 }, false)).toBe(false)
expect(diffCreatesInTarget(bothSides, true)).toBe(false)
expect(diffRemovesInTarget(bothSides, false)).toBe(false)
})
})
@@ -354,6 +354,53 @@ export async function deployItem(params: DeployItemParams): Promise<DeployResult
)
}
/**
* The two sides of a `workspace_diff` row a deploy direction reads:
* `exists_in_source` is the parent (or arbitrary target) side, `exists_in_fork`
* the current workspace.
*/
type WorkspaceDiffSides = {
ahead: number
behind: number
exists_in_source: boolean
exists_in_fork: boolean
}
/** Deploying this row creates the item in the target, which does not have it. */
export function diffCreatesInTarget(diff: WorkspaceDiffSides, mergeIntoParent: boolean): boolean {
return mergeIntoParent ? diff.exists_in_source === false : diff.exists_in_fork === false
}
/**
* Deploying this row removes the item in the target, the only side that has it.
* Which side dropped it is unknowable `ahead`/`behind` count deploy events on a
* side, and a pull into the fork or a git-sync revert leaves the trace a delete
* does so a row states what deploying does, and a removal is never bulk-selected.
*/
export function diffRemovesInTarget(diff: WorkspaceDiffSides, mergeIntoParent: boolean): boolean {
return mergeIntoParent ? diff.exists_in_fork === false : diff.exists_in_source === false
}
/**
* Rows a deploy in this direction can act on. A merge carries what the fork *has*:
* an item only the parent has is no fork change (see `diffRemovesInTarget`), while
* the update direction takes it whatever the counters say. Only an arbitrary target
* merges one that one-way sync has no tally, so target-only does mean "remove".
*/
export function diffActionableInDirection(
diff: WorkspaceDiffSides,
mergeIntoParent: boolean,
isArbitraryTarget: boolean = false
): boolean {
if (mergeIntoParent) {
if (!isArbitraryTarget && diff.exists_in_fork === false) {
return false
}
return diff.ahead > 0
}
return diff.behind > 0 || diffCreatesInTarget(diff, mergeIntoParent)
}
/**
* Delete/archive an item in a workspace.
* Used when deploying a deletion from one workspace to another.
@@ -10,6 +10,7 @@
reconcileAfterWorkspaceChange
} from '$lib/components/sessions/sessionState.svelte'
import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte'
import { diffActionableInDirection } from '$lib/utils_workspace_deploy'
import { page } from '$app/state'
import { userWorkspaces, workspaceStore } from '$lib/stores'
import { onDestroy, untrack } from 'svelte'
@@ -65,7 +66,11 @@
// Which fork direction to restore when switching back from draft mode. The
// merged toggle (CompareModeToggle, rendered inside each card) reports its
// selection here; the page only swaps which comparison component is shown.
let forkDirection = $state<'deploy_to' | 'update'>('deploy_to')
// `?dir=update` opens on the other one, for callers that already know which
// direction has something in it (the fork banner's CTA).
let forkDirection = $state<'deploy_to' | 'update'>(
page.url.searchParams.get('dir') === 'update' ? 'update' : 'deploy_to'
)
// Explicit preselection via `?items=<kind:path,...>` (built by the chat's
// open_page tool). Parsed synchronously from the live URL so it can never race
@@ -149,17 +154,21 @@
)
// Per-direction counts for the merged toggle badges. Deployable = items ahead
// (fork has changes the parent lacks); updateable = items behind. Computed
// here so they show on the toggle in draft mode too (where CompareDrafts has
// no comparison data of its own). Typed helpers avoid a $state `never`
// inference quirk on `comparison` inside $derived. A conflict (ahead AND
// behind) is intentionally counted in both directions — it's actionable either
// way.
function countDir(c: WorkspaceComparison | undefined, dir: 'ahead' | 'behind'): number {
return c?.diffs.filter((d) => d[dir] > 0).length ?? 0
// (fork has changes the parent lacks); updateable = items behind, plus what the
// parent has and the fork does not. Same predicate as the deploy list, so the
// badge never counts rows the list won't show. Computed here so they show on the
// toggle in draft mode too (where CompareDrafts has no comparison data of its
// own). Typed helpers avoid a $state `never` inference quirk on `comparison`
// inside $derived. A conflict (ahead AND behind) is intentionally counted in both
// directions — it's actionable either way.
function countDir(c: WorkspaceComparison | undefined, mergeIntoParent: boolean): number {
return (
c?.diffs.filter((d) => diffActionableInDirection(d, mergeIntoParent, isArbitraryTarget))
.length ?? 0
)
}
const deployCount = $derived(countDir(comparison, 'ahead'))
const updateCount = $derived(countDir(comparison, 'behind'))
const deployCount = $derived(countDir(comparison, true))
const updateCount = $derived(countDir(comparison, false))
$effect(() => {
if (modeResolved || !currentWorkspaceData) return