Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
39583ac
Tests: the play-along cm updates a folder, or refuses while changes a…
violetaGitHub Oct 28, 2026
a77e524
A checked-in xlink's files load with an update of its folder, the oth…
violetaGitHub Oct 28, 2026
cacd079
Creating an xlink checks it in alone and loads its files, saying whic…
violetaGitHub Oct 28, 2026
3ade145
New xlink creates and checks it in, with a comment, and its folder sh…
violetaGitHub Oct 28, 2026
53b348d
Docs: creating an xlink checks it in and loads its files
violetaGitHub Oct 28, 2026
aed238f
Loading an xlink with changes in the way reads them once
violetaGitHub Oct 28, 2026
caea2b8
"Welcome back" and Shelves tell changes put aside to load an xlink's …
violetaGitHub Oct 28, 2026
161b651
Changes put aside to load an xlink are recorded as such, and an updat…
violetaGitHub Oct 28, 2026
e90a8cb
Refactor: the update and xlink flows share how they say a failed step…
violetaGitHub Oct 28, 2026
b82e250
A checked-in xlink's files can be loaded on their own, and an xlink t…
violetaGitHub Oct 28, 2026
40087de
Files offers Load its files on a checked-in xlink still empty, and a …
violetaGitHub Oct 28, 2026
e003494
Docs: loading an xlink's files later, the changes put aside for it, a…
violetaGitHub Oct 28, 2026
2b9467c
Loading an xlink with changes that can't be shelved says what to do, …
violetaGitHub Oct 28, 2026
265bcf1
A new xlink without its files tells to open its folder in Files to fi…
violetaGitHub Oct 28, 2026
ba72eaf
"Welcome back" says changes put aside for any xlink's files, and chan…
violetaGitHub Oct 28, 2026
fa5b40e
Test: an xlink's files load with an update of the folder holding it
violetaGitHub Oct 29, 2026
294bc01
An xlink showing a folder of its repository loads its files, as the f…
violetaGitHub Oct 29, 2026
031b0db
Test: creating an xlink and loading its files refresh as a checkin do…
violetaGitHub Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/features/shelves-and-switching.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ with a way to reveal it: `filesKeptAside`, `noteKeptAside`), switches, and merge
`applyShelveCleanly`). Stopping is honoured only until the changes are shelved. Failures put the changes back
(`rollBackSwitch`), switching back first if the switch moved the workspace halfway; when that isn't possible the error
says which shelve holds them and where to restore them. Every flow that shelves changes out of the workspace (switch,
shelve away, update) puts them back the same way (`putBackAfterFailure`). Left shelves (the app's and the official
client's) are offered again by the "Welcome back" banner in Changes (`features/leftChanges`), or restored
automatically on arrival when they apply cleanly (`restoreOnArrival`). Changes still waiting to be brought (conflicts
shelve away, update, loading an xlink's files: docs/features/xlinks.md) puts them back the same way
(`putBackAfterFailure`). Left shelves (the app's and the official client's) are offered again by the "Welcome back"
banner in Changes (`features/leftChanges`), or restored automatically on arrival when they apply cleanly
(`restoreOnArrival`). Changes still waiting to be brought (conflicts
left for the merge view) are offered on the target, and as left ones on the source if the user goes back instead.

Both places that ask what happens to the pending changes, the switch's question (`SwitchWithChangesDialog`,
Expand Down
51 changes: 44 additions & 7 deletions docs/features/xlinks.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,24 +44,61 @@ it saves are decided by `xlinkDraft.ts` (`xlinkDialogFields`, `newXlinkOf`, `xli
server; nothing changes those or the repository, so the edit dialog shows them in its description only. Moving or
renaming the folder is Rename, Cut and Paste, as for any item.

Creating an xlink makes it a pending change (added) and editing one checks it out; editing one not checked in yet
leaves it added (the dialog says which: `descriptionOfEdited`). The change reaches others once checked in. A new
xlink's folder stays empty until then, and fills when the workspace updates.
Editing an xlink checks it out; editing one not checked in yet leaves it added (the dialog says which:
`descriptionOfEdited`). The change reaches others once checked in.

## Creating checks it in and loads its files

`cm` fills an xlink's folder only from the server's tree: a pending xlink has no files, and no `cm` command loads
them. So **Create and check in** (`XlinksApi.create`) creates the xlink, checks it in alone with the dialog's comment
(`checkinCommentOf`: the one typed, or "Add xlink <path>"), and loads its files, one operation. Other pending changes
stay pending. Updating the workspace unnamed may not load them: it is at the changeset the checkin made, so
`cm update` can find it up to date. Updating the folder holding the xlink does (the workspace's root, named, for one at
the root: `loadXlinkArgs`); its own folder doesn't, as `cm` fails there for an xlink showing a folder of its
repository ("Can't move to <folder>") and removes the folder. A partial update brings nothing else in: `cm` refuses
one while the workspace is out of date.

Before updating a folder, `cm` looks for pending changes in the whole workspace and does as client.conf's
`PendingChangesOnSwitchAction` says. Unset, 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 the app never lets `cm` do (as
`switchWithChanges`); `--machinereadable` turns that into refusing, as a setting to fail does. On a refusal
(`loadXlink`), unchanged checkouts are undone, a merge in progress is left alone (the files don't load), and other
changes are put aside as a switch puts them: an automatic shelve recorded with `reason: 'xlink'`, undo, added files
moved aside, the folder updated, the shelve merged back. The workspace didn't move, so only a change under the new
folder could conflict; changes that don't come back cleanly wait in their shelve, offered by "Welcome back" as "put
aside to load an xlink". A failure before the files load puts the changes back. The refusal's words are localized,
so it is told apart only by changes being pending: any failed update with changes pending takes this path. When `cm`
fails before the changes are shelved (the server is down), nothing changed and the error is what `cm update` said;
the app's own reasons they can't be shelved (changes inside another xlink: "Check them in first") stay as they are.

The files load with no progress bar, "Loading its files" alone: `--forcedetailedprogress`, which prints the bar
`readUpdateProgress` reads, is turned off by `--machinereadable`, which keeps `cm` from shelving.

Once `cm xlink` made it, the xlink exists whatever comes next: a later step that fails ends the operation with what
it is now (`CreatedXlink`: not checked in, a pending change to check in from Changes; or checked in without its
files) rather than an error, so the dialog closes instead of offering to create it again (`createdXlinkMessage`).
Only `cm xlink` failing keeps the dialog open.

**Load its files** (`XlinksApi.load`, `loadXlinkFiles`) loads them later, the same way: on a checked-in xlink whose
folder Files listed empty (`unloadedXlinkOf`), as one checked in from Changes is, and from the toast when they didn't
load as it was created. An added xlink isn't offered it: nothing loads one before it's checked in.

An edit that fails halfway leaves the xlink as the dialog still shows it, so Save can be pressed again: when adding
the new rules fails, the rules just deleted are added back (`xlinksService`). Pointing it again where the first try
already did changes nothing.

## `cm` commands

All in `main/cm/xlinkArgs.ts` (arguments) and `xlinkShow.ts` (`parseXlinkRules`), run by `xlinksService`. None
moves files, so each is a quick `query()`; each creates or edits an item, so it counts as the app's own write
(`changesWorkspace`), except `--show`.
All in `main/cm/xlinkArgs.ts` (arguments) and `xlinkShow.ts` (`parseXlinkRules`), run by `xlinksService`. No
`cm xlink` moves files, so each is a quick `query()`; each creates or edits an item, so it counts as the app's own
write (`changesWorkspace`), except `--show`. Creating also checks in and updates, both processes (`execute()`).

| Does | Command | When |
| ------------------------ | ------------------------------------------------------------------------ | ------------------------------------------- |
| Reads the rules | `cm xlink --show <path>` | Edit xlink… on a writable xlink, once |
| Creates | `cm xlink [-w] [-rs] <path> <folder> <point spec> [rules]` | Create xlink |
| Creates | `cm xlink [-w] [-rs] <path> <folder> <point spec> [rules]` | Create and check in |
| Checks the new one in | `cm checkin <path> --all --dependencies -commentsfile=<file>` (`checkinArgs`) | Create and check in, once created |
| Loads its files | `cm update <folder holding it> --machinereadable --noinput --dontmerge` | Create and check in, once checked in; Load its files |
| Points elsewhere | `cm xlink -e <path> <point spec>` | Save, when the point changed |
| Deletes, then adds rules | `cm xlink --deleterules <path> <rules>`, then `--addrules` | Save, when rules changed (one each at most) |

Expand Down
15 changes: 14 additions & 1 deletion src/main/cm/xlinkArgs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import type { NewXlink } from '@shared/domain/xlinks';
import { repositoryFolder } from '@shared/domain/xlinks';
import { createXlinkArgs, labelChangesetArgs, repositoryFolderArgs, xlinkPointSpec, xlinkRuleArg, xlinkTreeSpec } from './xlinkArgs';
import { createXlinkArgs, loadXlinkArgs, labelChangesetArgs, repositoryFolderArgs, xlinkPointSpec, xlinkRuleArg, xlinkTreeSpec } from './xlinkArgs';
import { TREE_ITEM_FORMAT } from './treeItems';

const LIB = join('wk', 'code', 'lib');
Expand Down Expand Up @@ -78,3 +78,16 @@ describe('The tree of the repository an xlink points to', () => {
expect(repositoryFolderArgs('/', 'cs:4@lib@local')[1]).toBe('/');
});
});

describe('Loading a checked-in xlink’s files', () => {
const WORKSPACE = join('wkspaces', 'game');

// Updating the xlink's own folder fails for one showing a folder of its repository ("Can't move to <folder>").
it('updates the folder holding the xlink, never its own', () => {
expect(loadXlinkArgs(WORKSPACE, 'code/lib')).toEqual(['update', join(WORKSPACE, 'code'), '--machinereadable', '--noinput', '--dontmerge']);
});

it('updates the workspace’s root, named, for an xlink at the root', () => {
expect(loadXlinkArgs(WORKSPACE, 'lib')[1]).toBe(join(WORKSPACE));
});
});
20 changes: 19 additions & 1 deletion src/main/cm/xlinkArgs.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
// `cm xlink` arguments. The command creates an xlink as a pending change (`cm xlink [-w] [-rs] <path> <folder> <point>
// [rules]`), edits one, checking it out (`-e <path> <point>`: only where it points; writable, folder and server stay),
// and adds or deletes its branch expansion rules (`-ar`/`-dr <path> <rules>`). 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';
import { FOUND_LABELS } from './foundObjects';
import { toAbsolutePath } from '../files/workspacePaths';
import { TREE_ITEM_FORMAT } from './treeItems';

/** Where an xlink points, as `cm xlink` takes it: a changeset, branch or label spec in its repository (`name@server`). */
Expand Down Expand Up @@ -48,6 +50,22 @@ export function deleteXlinkRulesArgs(absolutePath: string, rules: readonly Xlink
return ['xlink', '--deleterules', absolutePath, ...rules.map(xlinkRuleArg)];
}

/**
* `cm update` of the folder holding a checked-in xlink (the workspace's root, named, for one at the root), which loads
* its files. Not the xlink's own folder: for an xlink showing a folder of its repository, `cm` fails there ("Can't move
* to <folder>") and removes the folder. Nor the workspace unnamed: it is at the changeset that added the xlink already,
* and `cm update` may find it up to date. Updating a folder brings nothing else in: `cm` refuses a partial update while
* the workspace is out of date ("Cannot perform a partial update when the workspace is out of date"). 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(workspacePath: string, xlinkPath: string, platform: NodeJS.Platform = process.platform): string[] {
const holdingFolder = xlinkPath.split('/').slice(0, -1).join('/');
return ['update', toAbsolutePath(workspacePath, holdingFolder, platform), '--machinereadable', '--noinput', '--dontmerge'];
}

export function showXlinkArgs(absolutePath: string): string[] {
return ['xlink', '--show', absolutePath];
}
Expand Down
2 changes: 1 addition & 1 deletion src/main/services/createServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
}

Expand Down
106 changes: 88 additions & 18 deletions src/main/services/xlinksService.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,28 @@
import { existsSync } from 'node:fs';
import { mkdtemp } from 'node:fs/promises';
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 { playAlongDependencies } from '../workspace/testing/playAlongDependencies';
import { playAlongWorkspace } from '../workspace/testing/playAlongWorkspace';
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<string, CmAnswer>) {
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', () => {
Expand All @@ -29,28 +39,88 @@ 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', join(WORKSPACE, 'code'), '--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' });
});

it('says so in plain words when cm checks in nothing', async () => {
const { service } = xlinks({ xlink: '', checkin: 'CI_START\nNO_CHANGES_APPLIED\n' });

expect(await service.create(WORKSPACE, ENGINE, 'c', 'op-1')).toEqual({ kind: 'notCheckedIn', reason: 'Nothing was checked in' });
});

it('names the shelve the changes put aside to load its files wait in, when they don’t come back on their own', async () => {
const workspacePath = join(await mkdtemp(join(tmpdir(), 'uvcs-xlink-create-')), 'wk');
const workspace = playAlongWorkspace(workspacePath, { pending: { 'src/a.txt': 'CH' }, updateRefusesPendingChanges: true, fail: { shelveConflicts: 1 } });
const { deps } = playAlongDependencies(workspace.cm, workspacePath);
// The play-along workspace knows no xlinks: creating and checking in this one answer as cm does.
const play = { query: workspace.cm.query.bind(workspace.cm), execute: workspace.cm.execute.bind(workspace.cm) };
workspace.cm.query = (args, options) => (args[0] === 'xlink' ? Promise.resolve('') : play.query(args, options));
workspace.cm.execute = (args, options) => (args[0] === 'checkin' ? Promise.resolve(CHECKED_IN) : play.execute(args, options));
const service = createXlinksService(serviceContext(workspace.cm), { switchShelves: deps.records, leftChanges: deps.leftChanges });

expect(await service.create(workspacePath, ENGINE, 'c', 'op-1')).toEqual({ kind: 'loaded', changesetId: 3, changesWaitIn: 7 });
expect(workspace.updatedFolders).toEqual(['code']);
});

});

describe('Loading a checked-in xlink’s files', () => {
it('updates its folder alone, as an operation', async () => {
const { service, commands } = xlinks({ update: '' });

expect(await service.load(WORKSPACE, 'code/lib', 'op-1')).toEqual({});
expect(commands).toMatchObject([{ via: 'execute', args: ['update', join(WORKSPACE, 'code'), '--machinereadable', '--noinput', '--dontmerge'] }]);
});

it('fails with what went wrong: nothing exists to tell apart from it', async () => {
const { service } = xlinks({ update: cmFails('The server is unreachable.'), status: '' });

await expect(service.load(WORKSPACE, 'code/lib', 'op-1')).rejects.toThrow('The server is unreachable.');
});
});

Expand Down
Loading
Loading