From 39583aca89e180612875f31d65d49178948b8f9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Violeta=20S=C3=A1nchez=20Mart=C3=ADn?= Date: Wed, 28 Oct 2026 13:19:13 +0100 Subject: [PATCH 01/18] Tests: the play-along cm updates a folder, or refuses while changes are pending as cm set to shelve them does Co-Authored-By: Claude Opus 5.5 --- .../workspace/testing/playAlongWorkspace.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/main/workspace/testing/playAlongWorkspace.ts b/src/main/workspace/testing/playAlongWorkspace.ts index 5861d6b15..bce79cfb7 100644 --- a/src/main/workspace/testing/playAlongWorkspace.ts +++ b/src/main/workspace/testing/playAlongWorkspace.ts @@ -40,6 +40,11 @@ export interface WorkspaceScenario { shelvesOnServer?: { id: number; comment: string; changes: Record }[]; /** Branches `cm find branch` finds; every branch of `BRANCH_IDS` by default. */ knownBranches?: string[]; + /** + * `cm update ` refuses while changes are pending, as a `cm` whose PendingChangesOnSwitchAction asks to shelve + * them does with `--machinereadable`; otherwise it updates the folder and leaves them, as by default. + */ + updateRefusesPendingChanges?: boolean; fail?: { /** `cm shelveset create` leaves this path out of the shelve. */ shelveMisses?: string; @@ -56,6 +61,8 @@ export interface WorkspaceScenario { shelveConflicts?: number; /** Merging a shelve into the workspace fails. */ shelveMerge?: string; + /** `cm update` fails with these words. */ + update?: string; }; /** A private file the switch finds in the way of a file it writes, and renames `.private.0`. */ privateInTheWay?: string; @@ -94,6 +101,8 @@ export function playAlongWorkspace(workspacePath: string, scenario: WorkspaceSce const privatePaths = new Set(Object.keys(pending).filter((path) => pending[path] === 'PR')); const shelves = new Map((scenario.shelvesOnServer ?? []).map(({ id, comment, changes }) => [id, { comment, changes, addedContents: {} }])); const deletedShelves: number[] = []; + /** The folders `cm update` updated, as workspace paths. */ + const updatedFolders: string[] = []; let nextShelveId = 7; const onDisk = (path: string): string => join(workspacePath, ...path.split('/')); @@ -152,6 +161,16 @@ export function playAlongWorkspace(workspacePath: string, scenario: WorkspaceSce return ''; }; + const update = ({ args }: FakeCmCommand): string | CmFailure => { + if (fail.update) return cmFails(fail.update); + if (scenario.updateRefusesPendingChanges && Object.values(pending).some((codes) => codes !== 'PR')) { + return cmFails('The workspace cannot be updated since there are pending changes. Please review the pending changes and retry the operation again.'); + } + const folder = args.slice(1).find((arg) => !arg.startsWith('-')); + updatedFolders.push(folder ? relative(folder) : ''); + return ''; + }; + const mergeShelve = ({ args }: FakeCmCommand): string | CmFailure => { const shelve = shelves.get(Number(args[1]!.slice('sh:'.length)))!; if (args.includes('--merge')) { @@ -198,6 +217,7 @@ export function playAlongWorkspace(workspacePath: string, scenario: WorkspaceSce .join(''), undo, switch: switchTo, + update, merge: mergeShelve, 'lock list': () => formatOutput( @@ -224,6 +244,7 @@ export function playAlongWorkspace(workspacePath: string, scenario: WorkspaceSce shelves: (): number[] => [...shelves.keys()], shelveComment: (id: number): string | undefined => shelves.get(id)?.comment, deletedShelves, + updatedFolders, changelists: () => changelists, }; } From a77e524e157393b03c21dc4f9c86006581373204 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Violeta=20S=C3=A1nchez=20Mart=C3=ADn?= Date: Wed, 28 Oct 2026 13:19:14 +0100 Subject: [PATCH 02/18] A checked-in xlink's files load with an update of its folder, the other pending changes put aside only when cm refuses for them Co-Authored-By: Claude Opus 5.5 --- src/main/cm/xlinkArgs.ts | 15 ++- src/main/workspace/loadXlink.test.ts | 106 +++++++++++++++++++ src/main/workspace/loadXlink.ts | 89 ++++++++++++++++ src/main/workspace/shelveFlowDependencies.ts | 2 +- src/shared/domain/switchWithChanges.ts | 7 +- 5 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 src/main/workspace/loadXlink.test.ts create mode 100644 src/main/workspace/loadXlink.ts diff --git a/src/main/cm/xlinkArgs.ts b/src/main/cm/xlinkArgs.ts index ce73bdad6..ff7c82f0d 100644 --- a/src/main/cm/xlinkArgs.ts +++ b/src/main/cm/xlinkArgs.ts @@ -1,7 +1,8 @@ // `cm xlink` arguments. The command creates an xlink as a pending change (`cm xlink [-w] [-rs] // [rules]`), edits one, checking it out (`-e `: only where it points; writable, folder and server stay), // and adds or deletes its branch expansion rules (`-ar`/`-dr `). None of it reads or writes the -// xlinked files: they come with the next update, so each is a quick command. +// xlinked files, so each is a quick command: they come once the xlink is checked in and its folder updated +// (`loadXlinkArgs`). import { fullBranchName, repositoryFolder, type NewXlink, type XlinkPoint, type XlinkRule } from '@shared/domain/xlinks'; import { equalsCondition, findArgs } from './findQuery'; @@ -48,6 +49,18 @@ export function deleteXlinkRulesArgs(absolutePath: string, rules: readonly Xlink return ['xlink', '--deleterules', absolutePath, ...rules.map(xlinkRuleArg)]; } +/** + * `cm update` of a checked-in xlink's folder, which loads its files. Updating the workspace wouldn't: it is at the + * changeset that added the xlink already, so `cm update` finds it up to date and reads no tree. Updating a folder, `cm` + * looks for pending changes in the whole workspace first, and does as client.conf's PendingChangesOnSwitchAction says: + * with none set, as by default, it updates the folder and leaves them as they are; set to shelve them, it would shelve, + * undo and merge them back itself, which `--machinereadable` turns into refusing (as a setting to fail does), so the + * app moves them aside itself (`loadXlink`). `--noinput`: never ask; `--dontmerge`: never open a merge tool. + */ +export function loadXlinkArgs(absolutePath: string): string[] { + return ['update', absolutePath, '--machinereadable', '--noinput', '--dontmerge']; +} + export function showXlinkArgs(absolutePath: string): string[] { return ['xlink', '--show', absolutePath]; } diff --git a/src/main/workspace/loadXlink.test.ts b/src/main/workspace/loadXlink.test.ts new file mode 100644 index 000000000..1e487319c --- /dev/null +++ b/src/main/workspace/loadXlink.test.ts @@ -0,0 +1,106 @@ +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { AUTOMATIC_SHELVE_COMMENT } from '@shared/domain/shelve'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { recordingContext } from '../operations/testing/recordingContext'; +import { loadXlink } from './loadXlink'; +import { playAlongDependencies } from './testing/playAlongDependencies'; +import { playAlongWorkspace, type WorkspaceScenario } from './testing/playAlongWorkspace'; + +const EDITED_AND_ADDED = { 'src/a.txt': 'CH', 'src/new.txt': 'AD' }; + +let workspacePath: string; + +beforeEach(async () => { + workspacePath = join(await mkdtemp(join(tmpdir(), 'uvcs-load-xlink-')), 'wk'); + await mkdir(join(workspacePath, 'src'), { recursive: true }); + await writeFile(join(workspacePath, 'src', 'new.txt'), 'added\n'); +}); + +function workspaceWith(scenario: WorkspaceScenario) { + const workspace = playAlongWorkspace(workspacePath, scenario); + return { ...workspace, ...playAlongDependencies(workspace.cm, workspacePath) }; +} + +const load = (deps: ReturnType['deps']) => loadXlink(deps, workspacePath, 'code/lib', recordingContext().context); + +describe('Loading a checked-in xlink’s files', () => { + it('updates its folder alone, leaving the other pending changes as they are', async () => { + const { deps, commands, pending, updatedFolders, shelves } = workspaceWith({ pending: EDITED_AND_ADDED }); + + expect(await load(deps)).toEqual({ kind: 'loaded' }); + + expect(updatedFolders).toEqual(['code/lib']); + expect(commands).toMatchObject([ + // `--machinereadable`: a cm set to shelve the changes on update refuses instead, and never shelves them itself. + { via: 'execute', args: ['update', join(workspacePath, 'code', 'lib'), '--machinereadable', '--noinput', '--dontmerge'], options: { cwd: workspacePath } }, + ]); + expect(pending()).toEqual(EDITED_AND_ADDED); + expect(shelves()).toEqual([]); + }); + + it('puts the changes aside when cm refuses because of them, loads the files, and merges them back', async () => { + const { deps, pending, updatedFolders, shelves, deletedShelves, recordOf, lines } = workspaceWith({ pending: EDITED_AND_ADDED, updateRefusesPendingChanges: true }); + + expect(await load(deps)).toEqual({ kind: 'loaded' }); + + expect(updatedFolders).toEqual(['code/lib']); + expect(lines()).toContain(`undo -r ${workspacePath} --symlink`); + expect(pending()).toEqual(EDITED_AND_ADDED); + expect(existsSync(join(workspacePath, 'src', 'new.txt'))).toBe(true); + expect(shelves()).toEqual([]); + expect(deletedShelves).toEqual([7]); + expect(recordOf(7)).toBeUndefined(); + }); + + it('shelves the changes put aside as the official client leaves them, so they are found should they not come back', async () => { + const { deps, shelveComment, leftChanges, recordOf } = workspaceWith({ pending: EDITED_AND_ADDED, updateRefusesPendingChanges: true, fail: { shelveConflicts: 1 } }); + + expect(await load(deps)).toEqual({ kind: 'changesWaiting', shelveId: 7 }); + + expect(shelveComment(7)).toMatch(new RegExp(`^${AUTOMATIC_SHELVE_COMMENT}`)); + expect(recordOf(7)).toMatchObject({ mode: 'bring', reason: 'update' }); + expect(await leftChanges.find(workspacePath)).toMatchObject([{ shelveId: 7, reason: 'update' }]); + }); + + it('undoes checkouts holding no change instead of shelving them', async () => { + const { deps, pending, updatedFolders, shelves, lines } = workspaceWith({ pending: { 'src/a.txt': 'CO' }, updateRefusesPendingChanges: true }); + + expect(await load(deps)).toEqual({ kind: 'loaded' }); + + expect(lines()).toContain(`undo --unchanged -r ${workspacePath} --symlink`); + expect(pending()).toEqual({}); + expect(shelves()).toEqual([]); + expect(updatedFolders).toEqual(['code/lib']); + }); + + it('leaves a merge in progress alone, as no shelve can hold it', async () => { + const { deps, pending, shelves } = workspaceWith({ pending: { 'src/a.txt': 'CH' }, mergingFrom: 3, updateRefusesPendingChanges: true }); + + await expect(load(deps)).rejects.toThrow('A merge in progress keeps its files from loading'); + expect(pending()).toEqual({ 'src/a.txt': 'CH' }); + expect(shelves()).toEqual([]); + }); + + it('fails with cm’s words when nothing is pending: no change could be in the way', async () => { + const { deps, shelves } = workspaceWith({ fail: { update: 'The server is unreachable.' } }); + + await expect(load(deps)).rejects.toThrow('The server is unreachable.'); + expect(shelves()).toEqual([]); + }); + + it('puts the changes back when the files don’t load once they are aside', async () => { + const { deps, pending, shelves } = workspaceWith({ pending: EDITED_AND_ADDED, updateRefusesPendingChanges: true }); + const execute = deps.cm.execute; + deps.cm.execute = async (args, options) => { + if (args[0] === 'update' && Object.keys(pending()).length === 0) throw new Error('The server is unreachable.'); + return execute(args, options); + }; + + await expect(load(deps)).rejects.toThrow('The server is unreachable. Your changes were put back.'); + expect(pending()).toEqual(EDITED_AND_ADDED); + expect(shelves()).toEqual([]); + }); +}); diff --git a/src/main/workspace/loadXlink.ts b/src/main/workspace/loadXlink.ts new file mode 100644 index 000000000..98e4a7ada --- /dev/null +++ b/src/main/workspace/loadXlink.ts @@ -0,0 +1,89 @@ +import { CmError } from '../cm/CmError'; +import { onLinksThemselves } from '../cm/symlinkArgs'; +import { loadXlinkArgs } from '../cm/xlinkArgs'; +import { toAbsolutePath } from '../files/workspacePaths'; +import { hasPendingChanges } from '../merge/hasPendingChanges'; +import type { OperationContext } from '../operations/OperationTracker'; +import { moveNewItemsAside } from './moveNewItemsAside'; +import { shelvedContents, summarizePending } from './pendingSnapshot'; +import { failureReason, putBackAfterFailure, putShelvedChangesBack } from './putShelvedChangesBack'; +import { readPendingSnapshot } from './readPendingChanges'; +import { selectorObjectRef } from './selectorObjectRef'; +import type { ShelveFlowDependencies } from './shelveFlowDependencies'; +import { newShelveRecord, NO_TARGET } from './shelveRecord'; +import { switchApproach } from './switchApproach'; +import { describeSelector } from './switchSelectors'; +import { createAutomaticShelve } from './verifiedShelve'; +import { readWorkspaceIdentity } from './workspaceIdentity'; + +const IN_MERGE = 'A merge in progress keeps its files from loading: check the merge in or undo it, then update the xlink’s folder.'; + +/** How a checked-in xlink's files loaded: the changes moved aside for it wait in a shelve when they couldn't merge back cleanly. */ +export type XlinkLoad = { kind: 'loaded' } | { kind: 'changesWaiting'; shelveId: number }; + +/** + * Loads the files of the checked-in xlink at `xlinkPath` (a workspace path) with one `cm update` of its folder + * (`loadXlinkArgs`), which leaves the other pending changes as they are. When `cm` refuses because of them (its + * PendingChangesOnSwitchAction asks to shelve or fail), they are moved aside as a switch moves them, never by `cm`: + * 1. Shelve every change with the official automatic-shelve comment, check the shelve holds them all, and record it + * like changes put aside to update, so "Welcome back" offers them should they not come back. + * 2. Undo them, move the files they added aside, and load the xlink's files. + * 3. Merge the shelve back: the workspace is at the changeset it was shelved on, so only a change made under the + * xlink's folder meanwhile could conflict. + * If a step before the files load fails, the changes are put back. + */ +export async function loadXlink(deps: ShelveFlowDependencies, workspacePath: string, xlinkPath: string, context: OperationContext): Promise { + const { cm } = deps; + const load = () => cm.execute(loadXlinkArgs(toAbsolutePath(workspacePath, xlinkPath)), { cwd: workspacePath }); + try { + await load(); + return { kind: 'loaded' }; + } catch (error) { + if (!(error instanceof CmError)) throw error; + // Only the pending changes tell `cm`'s refusal apart from any other failure: its words are localized. + const snapshot = await readPendingSnapshot(cm, workspacePath); + switch (switchApproach(summarizePending(snapshot.changes))) { + case 'switchAsIs': + throw error; + case 'refuseMerge': + throw new Error(IN_MERGE); + case 'undoUnchangedCheckouts': + await cm.query(onLinksThemselves('undo', '--unchanged', '-r', workspacePath), { cwd: workspacePath }); + await load(); + return { kind: 'loaded' }; + case 'shelveChanges': + return loadAroundChanges(deps, workspacePath, load, context); + } + } +} + +async function loadAroundChanges(deps: ShelveFlowDependencies, workspacePath: string, load: () => Promise, context: OperationContext): Promise { + const { cm, records } = deps; + const workspace = await readWorkspaceIdentity(cm, workspacePath); + const objectRef = await selectorObjectRef(cm, workspacePath, workspace.selector); + if (!objectRef) throw new Error(`Couldn't find ${describeSelector(workspace.selector)} in the repository, so your changes stayed as they were.`); + + const snapshot = await readPendingSnapshot(cm, workspacePath); + context.beginStep('Putting your changes aside', 1, 3); + const shelve = await createAutomaticShelve(cm, workspacePath, snapshot.changes, objectRef, context); + const record = newShelveRecord(workspace, shelve.id, shelvedContents(snapshot), { mode: 'bring', reason: 'update', objectRef, target: NO_TARGET }); + records.save(record); + + // From here on the changes live in the shelve: a failure puts them back. + try { + // Links too: without `--symlink` a checked-out link stays pending (and its target would be undone instead). + await cm.execute(onLinksThemselves('undo', '-r', workspacePath), { cwd: workspacePath }); + await moveNewItemsAside(cm, records, workspacePath, snapshot.changes, record, deps.backupsRoot); + if (await hasPendingChanges(cm, workspacePath)) throw new Error('Some changes are still pending after undoing them'); + context.beginStep('Loading its files', 2, 3); + await load(); + } catch (error) { + const failure = failureReason(error); + if (await putBackAfterFailure(deps, workspacePath, record, context)) throw new Error(`${failure}. Your changes were put back.`); + throw new Error(`${failure}. Your changes are safe in shelve ${record.shelveId}; restore them from Changes.`); + } + + context.beginStep('Bringing your changes back', 3, 3); + const broughtBack = await putShelvedChangesBack(cm, deps.leftChanges, workspacePath, record, context).catch(() => false); + return broughtBack ? { kind: 'loaded' } : { kind: 'changesWaiting', shelveId: record.shelveId }; +} diff --git a/src/main/workspace/shelveFlowDependencies.ts b/src/main/workspace/shelveFlowDependencies.ts index 9125a98f3..d21f10ce8 100644 --- a/src/main/workspace/shelveFlowDependencies.ts +++ b/src/main/workspace/shelveFlowDependencies.ts @@ -2,7 +2,7 @@ import type { CmClient } from '../cm/CmClient'; import type { LeftChangesFinder } from './leftChanges'; import type { SwitchShelveRecords } from './switchShelveRecords'; -/** What every flow that shelves changes out of the workspace (switch, shelve away, update) works with. */ +/** What every flow that shelves changes out of the workspace (switch, shelve away, update, loading an xlink) works with. */ export interface ShelveFlowDependencies { cm: CmClient; records: SwitchShelveRecords; diff --git a/src/shared/domain/switchWithChanges.ts b/src/shared/domain/switchWithChanges.ts index 3f40f1b9b..d729791a2 100644 --- a/src/shared/domain/switchWithChanges.ts +++ b/src/shared/domain/switchWithChanges.ts @@ -87,9 +87,10 @@ export interface SwitchShelveRecord { } /** - * Why the app shelved changes: to switch away, to update past incoming changesets that deleted or moved - * files changed locally (`cm update` can't merge those), or because the user shelved them away (Shelve undoes what it - * shelves). Only the first two are left changes, offered by "Welcome back"; all of them are put back the same way. + * Why the app shelved changes: to switch away, to update (past incoming changesets that deleted or moved files changed + * locally, which `cm update` can't merge; or to load a new xlink's files, which `cm` refuses with changes pending: + * `loadXlink`), or because the user shelved them away (Shelve undoes what it shelves). Only the first two are left + * changes, offered by "Welcome back"; all of them are put back the same way. */ type ShelvedChangesReason = 'switch' | 'update' | 'shelve'; From cacd0798369f5ea706504cf31f7f7785133f3ebb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Violeta=20S=C3=A1nchez=20Mart=C3=ADn?= Date: Wed, 28 Oct 2026 13:19:14 +0100 Subject: [PATCH 03/18] Creating an xlink checks it in alone and loads its files, saying which step stopped once it exists Co-Authored-By: Claude Opus 5.5 --- src/main/services/createServices.ts | 2 +- src/main/services/xlinksService.test.ts | 67 ++++++++++++++++++------- src/main/services/xlinksService.ts | 53 ++++++++++++++++--- src/shared/api/xlinks.ts | 8 +-- src/shared/domain/xlinks.ts | 11 ++++ 5 files changed, 112 insertions(+), 29 deletions(-) diff --git a/src/main/services/createServices.ts b/src/main/services/createServices.ts index 78ceae263..720d296c6 100644 --- a/src/main/services/createServices.ts +++ b/src/main/services/createServices.ts @@ -83,7 +83,7 @@ export function createServices(context: ServiceContext): UvcsApi { updates: createUpdatesService(context), windows: createWindowsService(context), workspaces: createWorkspacesService(context, switching), - xlinks: createXlinksService(context), + xlinks: createXlinksService(context, switching), }; } diff --git a/src/main/services/xlinksService.test.ts b/src/main/services/xlinksService.test.ts index b1e256606..1fa2548e8 100644 --- a/src/main/services/xlinksService.test.ts +++ b/src/main/services/xlinksService.test.ts @@ -1,18 +1,25 @@ +import { existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import type { NewXlink } from '@shared/domain/xlinks'; +import { describe, expect, it, vi } from 'vitest'; import { FOUND_LABELS } from '../cm/foundObjects'; import { recordsOutput } from '../cm/testing/cmOutput'; import { cmFails, fakeCmClient, type CmAnswer } from '../cm/testing/fakeCmClient'; import { TREE_ITEMS } from '../cm/treeItems'; +import type { SwitchContext } from './ServiceContext'; +import { readingFileOption } from './testing/readingFileOption'; +import { serviceContext } from './testing/serviceContext'; import { createXlinksService } from './xlinksService'; +vi.mock('electron', () => ({ app: { getPath: () => tmpdir() } })); + const WORKSPACE = join(tmpdir(), 'wkspaces', 'game'); const LIB = join(WORKSPACE, 'code', 'lib'); function xlinks(answers: Record) { const fake = fakeCmClient(answers); - return { ...fake, service: createXlinksService({ cm: fake.cm }) }; + return { ...fake, service: createXlinksService(serviceContext(fake.cm), {} as SwitchContext) }; } describe('Reading an xlink’s rules', () => { @@ -29,28 +36,52 @@ describe('Reading an xlink’s rules', () => { }); describe('Creating an xlink', () => { - it('runs one quick cm xlink at its folder in the workspace', async () => { - const { service, commands } = xlinks({ xlink: '' }); - - await service.create(WORKSPACE, { - path: 'code/lib', - repository: 'lib@local', - point: { kind: 'branch', branch: '/main' }, - writable: true, - relativeServer: false, - folder: '/', - rules: [{ from: '/main/task', to: '/main/dev' }], - }); - - expect(commands).toMatchObject([{ via: 'query', args: ['xlink', '-w', LIB, '/', 'br:/main@lib@local', 'br:/main/task-br:/main/dev'] }]); + const ENGINE: NewXlink = { + path: 'code/lib', + repository: 'lib@local', + point: { kind: 'branch', branch: '/main' }, + writable: true, + relativeServer: false, + folder: '/', + rules: [{ from: '/main/task', to: '/main/dev' }], + }; + const CHECKED_IN = `CI_START\nAD ${LIB}\nCHANGESET cs:3@br:/main@game@local (mount:'/')\n`; + + it('creates it, checks in it alone with the comment, and loads its files with an update of its folder', async () => { + const { seen, answer } = readingFileOption('-commentsfile=', CHECKED_IN); + const { service, commands } = xlinks({ xlink: '', checkin: answer, update: '' }); + + expect(await service.create(WORKSPACE, ENGINE, 'Add the engine', 'op-1')).toEqual({ kind: 'loaded', changesetId: 3 }); + + expect(commands).toMatchObject([ + { via: 'query', args: ['xlink', '-w', LIB, '/', 'br:/main@lib@local', 'br:/main/task-br:/main/dev'] }, + { via: 'execute', args: ['checkin', LIB, '--all', '--dependencies', `-commentsfile=${seen.file}`, '--machinereadable', '--symlink'], options: { cwd: WORKSPACE } }, + { via: 'execute', args: ['update', LIB, '--machinereadable', '--noinput', '--dontmerge'], options: { cwd: WORKSPACE } }, + ]); + expect(seen.content).toBe('Add the engine'); + expect(existsSync(seen.file)).toBe(false); }); it('fails with cm’s words, as when the folder holding it isn’t controlled', async () => { - const { service } = xlinks({ xlink: cmFails(`${join(WORKSPACE, 'code')} is not in a workspace.`) }); + const { service, commands } = xlinks({ xlink: cmFails(`${join(WORKSPACE, 'code')} is not in a workspace.`) }); - const created = service.create(WORKSPACE, { path: 'code/lib', repository: 'lib@local', point: { kind: 'changeset', changeset: 1 }, writable: false, relativeServer: false, folder: '/', rules: [] }); + const created = service.create(WORKSPACE, { ...ENGINE, writable: false, rules: [] }, 'c', 'op-1'); await expect(created).rejects.toThrow('is not in a workspace'); + expect(commands).toHaveLength(1); + }); + + it('keeps the xlink as a pending change when it can’t be checked in, and says why', async () => { + const { service, ran } = xlinks({ xlink: '', checkin: cmFails('The server is unreachable.') }); + + expect(await service.create(WORKSPACE, ENGINE, 'c', 'op-1')).toEqual({ kind: 'notCheckedIn', reason: 'The server is unreachable' }); + expect(ran('update')).toBe(false); + }); + + it('says its files didn’t load once it is checked in, and why', async () => { + const { service } = xlinks({ xlink: '', checkin: CHECKED_IN, update: cmFails('The server is unreachable.'), status: '' }); + + expect(await service.create(WORKSPACE, ENGINE, 'c', 'op-1')).toEqual({ kind: 'notLoaded', changesetId: 3, reason: 'The server is unreachable' }); }); }); diff --git a/src/main/services/xlinksService.ts b/src/main/services/xlinksService.ts index 9f085a264..d5ba1ffe5 100644 --- a/src/main/services/xlinksService.ts +++ b/src/main/services/xlinksService.ts @@ -1,5 +1,9 @@ +import { join } from 'node:path'; +import { app } from 'electron'; import type { XlinksApi } from '@shared/api/xlinks'; -import type { NewXlink, XlinkEdit, XlinkPoint } from '@shared/domain/xlinks'; +import type { CreatedXlink, NewXlink, XlinkEdit, XlinkPoint } from '@shared/domain/xlinks'; +import { checkinArgs } from '../cm/checkinArgs'; +import { readCheckinOutput } from '../cm/checkinOutput'; import { layoutReader } from '../cm/formatRecords'; import { FOUND_LABELS } from '../cm/foundObjects'; import { subfoldersIn } from '../cm/repositoryFolders'; @@ -16,20 +20,57 @@ import { xlinkTreeSpec, } from '../cm/xlinkArgs'; import { parseXlinkRules } from '../cm/xlinkShow'; +import { withTempFile } from '../files/tempFile'; import { toAbsolutePath } from '../files/workspacePaths'; -import type { ServiceContext } from './ServiceContext'; +import { failureReason } from '../workspace/putShelvedChangesBack'; +import { loadXlink } from '../workspace/loadXlink'; +import type { ServiceContext, SwitchContext } from './ServiceContext'; -/** Xlinks created and edited: every command a quick one, as none moves files (`xlinkArgs`). */ -export function createXlinksService({ cm }: Pick): XlinksApi { +/** + * Xlinks created and edited. Creating one checks it in and loads its files, an operation; every other command is a + * quick one, as none moves files (`xlinkArgs`). + */ +export function createXlinksService({ cm, operations }: Pick, { switchShelves, leftChanges }: SwitchContext): XlinksApi { const inWorkspace = (workspacePath: string) => ({ cwd: workspacePath }); + const shelveFlow = { cm, records: switchShelves, leftChanges, backupsRoot: join(app.getPath('userData'), 'shelve-backups') }; async function rules(workspacePath: string, path: string) { const output = await cm.query(showXlinkArgs(toAbsolutePath(workspacePath, path)), inWorkspace(workspacePath)); return parseXlinkRules(output); } - async function create(workspacePath: string, xlink: NewXlink) { - await cm.query(createXlinkArgs(toAbsolutePath(workspacePath, xlink.path), xlink), inWorkspace(workspacePath)); + // Once `cm xlink` made it, the xlink exists whatever comes next: a later failure is what it resolves to, not an error, + // so the dialog doesn't offer to create it again. + function create(workspacePath: string, xlink: NewXlink, comment: string, operationId: string): Promise { + return operations.run(operationId, async (context) => { + const absolutePath = toAbsolutePath(workspacePath, xlink.path); + await cm.query(createXlinkArgs(absolutePath, xlink), inWorkspace(workspacePath)); + + context.reportProgress('Checking in the xlink'); + let changesetId: number; + try { + changesetId = await checkInAlone(workspacePath, absolutePath, comment); + } catch (error) { + return { kind: 'notCheckedIn', reason: failureReason(error) }; + } + + context.reportProgress('Loading its files'); + try { + const load = await loadXlink(shelveFlow, workspacePath, xlink.path, context); + return load.kind === 'loaded' ? { kind: 'loaded', changesetId } : { kind: 'loaded', changesetId, changesWaitIn: load.shelveId }; + } catch (error) { + return { kind: 'notLoaded', changesetId, reason: failureReason(error) }; + } + }); + } + + /** Checks in the xlink and nothing else: the folder holding it, and the added folders above, go in with it (`checkinArgs`). */ + function checkInAlone(workspacePath: string, absolutePath: string, comment: string): Promise { + return withTempFile(comment, async (commentsFile) => { + const checkin = readCheckinOutput(await cm.execute(checkinArgs([absolutePath], commentsFile, false), inWorkspace(workspacePath))); + if (checkin.kind === 'noChanges') throw new Error('cm found nothing to check in'); + return checkin.changesetId; + }); } async function treePoint(workspacePath: string, repository: string, point: XlinkPoint) { diff --git a/src/shared/api/xlinks.ts b/src/shared/api/xlinks.ts index a367efe51..08e313894 100644 --- a/src/shared/api/xlinks.ts +++ b/src/shared/api/xlinks.ts @@ -1,13 +1,13 @@ -import type { NewXlink, XlinkEdit, XlinkExpansionRule, XlinkPoint } from '../domain/xlinks'; +import type { CreatedXlink, NewXlink, XlinkEdit, XlinkExpansionRule, XlinkPoint } from '../domain/xlinks'; export interface XlinksApi { /** The branch expansion rules of the xlink at `path`, a workspace path (`cm xlink --show`): a read-only one has none. */ rules(workspacePath: string, path: string): Promise; /** - * Creates an xlink, a pending change (`cm xlink`): its folder fills with the repository's files once it is checked - * in and the workspace updated. + * Creates an xlink (`cm xlink`), checks it in alone with `comment`, and loads its files: `cm` fills its folder only + * once it is checked in. Fails only when `cm xlink` does; a later step that fails is in what it resolves to. */ - create(workspacePath: string, xlink: NewXlink): Promise; + create(workspacePath: string, xlink: NewXlink, comment: string, operationId: string): Promise; /** * The tree where `point` is in `repository`, as `folders` lists it: a changeset's or branch head's spec at once; a * label is read at its changeset, which one `cm find` names. diff --git a/src/shared/domain/xlinks.ts b/src/shared/domain/xlinks.ts index 560bcb470..2cc4b2ea9 100644 --- a/src/shared/domain/xlinks.ts +++ b/src/shared/domain/xlinks.ts @@ -35,6 +35,17 @@ export interface NewXlink { rules: XlinkRule[]; } +/** + * What creating an xlink did (`XlinksApi.create`): checked in, in `changesetId`, with its files loaded (`loaded`), or + * stopped after `cm xlink` by a later step, whose failure `reason` names: not checked in, it is a pending change; checked + * in, its files didn't load. The pending changes put aside to load them wait in shelve `changesWaitIn` when they + * couldn't merge back cleanly ("Welcome back" offers them). + */ +export type CreatedXlink = + | { kind: 'loaded'; changesetId: number; changesWaitIn?: number } + | { kind: 'notCheckedIn'; reason: string } + | { kind: 'notLoaded'; changesetId: number; reason: string }; + /** A change to an xlink (`XlinksApi.edit`): what changed only. */ export interface XlinkEdit { path: string; From 3ade145ac3de48a3cb48d699e9aa033e94b42285 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Violeta=20S=C3=A1nchez=20Mart=C3=ADn?= Date: Wed, 28 Oct 2026 13:19:14 +0100 Subject: [PATCH 04/18] New xlink creates and checks it in, with a comment, and its folder shows the xlinked files at once Co-Authored-By: Claude Opus 5.5 --- .../src/features/xlinks/XlinkDialog.tsx | 3 +- .../xlinks/createdXlinkMessage.test.ts | 29 +++++++++++++++++ .../features/xlinks/createdXlinkMessage.ts | 19 ++++++++++++ .../src/features/xlinks/xlinkDraft.test.ts | 17 ++++++++++ .../src/features/xlinks/xlinkDraft.ts | 27 +++++++++++++--- .../features/xlinks/xlinkOperations.test.ts | 31 ++++++++++++------- .../src/features/xlinks/xlinkOperations.ts | 25 +++++++++++---- 7 files changed, 128 insertions(+), 23 deletions(-) create mode 100644 src/renderer/src/features/xlinks/createdXlinkMessage.test.ts create mode 100644 src/renderer/src/features/xlinks/createdXlinkMessage.ts diff --git a/src/renderer/src/features/xlinks/XlinkDialog.tsx b/src/renderer/src/features/xlinks/XlinkDialog.tsx index 97659fdee..292a5ad6e 100644 --- a/src/renderer/src/features/xlinks/XlinkDialog.tsx +++ b/src/renderer/src/features/xlinks/XlinkDialog.tsx @@ -9,7 +9,7 @@ import { openDialog } from '../../ui/dialog/dialogStore'; import fieldStyles from '../../ui/Field.module.css'; import { IconButton } from '../../ui/IconButton'; import { SegmentedControl } from '../../ui/SegmentedControl'; -import { TextField } from '../../ui/TextField'; +import { TextArea, TextField } from '../../ui/TextField'; import { PickField } from './PickField'; import styles from './XlinkDialog.module.css'; import { @@ -96,6 +96,7 @@ function XlinkDialog({ target, save, onClose }: { target: XlinkDialogTarget; sav change({ folder })} /> )} {fields.showsRules && } + {fields.isNew &&