Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions changelog.d/workspace-draft-spelling.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- On a disk that folds case, as macOS's does by default, a path that disk has nothing for takes the spelling of a workspace entry it equals in simple case folding, letter by letter, so a draft that isn't on disk yet has one name too. A `write` of `work/src/NEW.txt` where the workspace holds the draft `src/New.txt` now writes that draft, as the disk will once it's materialized, and `read work/src/new.txt` finds it; a directory works the same way. Two drafts that differ only in case used to be two entries for one file: a materialize wrote one and skipped the other, a forced one wrote the second over the first, and two directory drafts became one disk directory whose files the next pass moved under its spelling. A path that names a workspace entry exactly, or does once its directories take disk's spelling, keeps the entry's spelling, so an entry that disk lists in another spelling, or in a directory disk lists in another, such as a draft beside a file made on disk in another case before the draft was materialized, is read, synced, materialized and deleted by its own path. So is a file deleted in the workspace while disk keeps its copy, and a path in conflict: a `read` of its own path answers that it was deleted, or is in conflict, rather than taking the file in again, and a `materialize` of that path with `applyDeletions`, or a `sync`, reaches it. A path that disk's spelling puts in a nested mount's directory keeps that spelling, so the nested-mount checks still refuse what they refused. A draft whose directory disk comes to list in another spelling before it's materialized is found only by its own path. Drafts that differ only in Unicode normalization form, or in letters only a fuller folding joins, such as `ß` and `ss`, stay two entries, and where the disk folds them, as macOS's folds normalization, a materialize still writes one and skips the other. Spelling a path disk has nothing for reads the workspace's entries under the deepest of its directories disk has, so on macOS a script that creates thousands of new files, or looks up thousands of paths that don't exist, under one large directory pays that once per path. On a disk that tells case apart, every answer is as before, except that where such a disk folds normalization forms, a path that names a workspace entry exactly, or a deletion or conflict the workspace records there, or does once its directories take disk's form, keeps its form, outside nested mounts' directories.
2 changes: 1 addition & 1 deletion changelog.d/workspace-path-disk-spelling.fixed.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
- On a disk that folds names, as macOS's does by default (case, and Unicode normalization), the workspace's tools take a path as disk spells it. A `path` spelled otherwise, such as `work/SRC` for disk's `src/`, reaches disk's files, and `glob`, `grep` and `ls` used to name what they found from that spelling. So the workspace took each file in a second time under that name, announced it as created, and announced it deleted at the next pass over the mount; a `write` there was held under the name given while disk put it in `src/`. Now each segment of a `path` that reaches an entry under another spelling takes the entry's own name, from the mount's root down, and answers name files as disk lists them: `read work/SRC/a.ts` answers with `path: "work/src/a.ts"`. A segment disk doesn't have keeps the spelling given, and nothing past a symlink, or past a `..` that climbs above the mount's root, is respelled. `readBinary`, `readBinaryFromDisk`, `readFileFromDisk` and `writeBinary` take their paths the same way; `resolveAbsolutePath` takes its path as given, and the path it returns reaches the same file. A nested mount's directory is now known in its configured spelling and in disk's: `read`, `read_image`, `write`, `edit` and `delete`, and the public readers and writer, refuse a path into it spelled in another case or normalization form as that mount's, and an outer mount no longer takes in the files of a nested mount whose directory was configured in another spelling than disk's. On a disk that tells spellings apart, every answer is as before.
- On a disk that folds names, as macOS's does by default (case, and Unicode normalization), the workspace's tools take a path as disk spells it, unless it names a workspace entry exactly, or a deletion or conflict the workspace records at that path, or does once its directories take disk's spelling, which keeps that spelling where disk's spelling doesn't put it in a nested mount's directory. A `path` spelled otherwise, such as `work/SRC` for disk's `src/`, reaches disk's files, and `glob`, `grep` and `ls` used to name what they found from that spelling. So the workspace took each file in a second time under that name, announced it as created, and announced it deleted at the next pass over the mount; a `write` there was held under the name given while disk put it in `src/`. Now each segment of a `path` that reaches an entry under another spelling takes the entry's own name, from the mount's root down, and answers name files as disk lists them: `read work/SRC/a.ts` answers with `path: "work/src/a.ts"`. A segment disk doesn't have keeps the spelling given, unless a workspace entry spells it, and nothing past a symlink, or past a `..` that climbs above the mount's root, is respelled. `readBinary`, `readBinaryFromDisk`, `readFileFromDisk` and `writeBinary` take their paths the same way; `resolveAbsolutePath` takes its path as given, and the path it returns reaches the same file. A nested mount's directory on disk is now known in its configured spelling and in disk's: `read`, `read_image`, `write`, `edit` and `delete`, and the public readers and writer, refuse a path into it spelled in another case or normalization form as that mount's, and an outer mount no longer takes in the files of a nested mount whose directory was configured in another spelling than disk's. On a disk that tells spellings apart, every answer is as before.
82 changes: 61 additions & 21 deletions src/modules/workspace/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import { MountWatcher, type FsChange } from './watcher.js';
import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
import { BranchIntents } from './branch-intent.js';
import { IGNORED, NONBLOCK, OTHER_MOUNT, ROOT_REPLACED, ROOT_UNAVAILABLE, checkDir, checkRoot, diskSpelling, filesystemTrustsCtime, inOtherMount, leadsInto, linkLeads, linkOnTheWay, linkRefusal, linkWords, locateRoot, nestedAtReal, nestedHolder, passedOverAt, readPrefix, type CheckedDir, type LinkFound, type MountView, type NestedMount } from './observe.js';
import { IGNORED, NONBLOCK, OTHER_MOUNT, ROOT_REPLACED, ROOT_UNAVAILABLE, checkDir, checkRoot, diskSpelling, filesystemTrustsCtime, foldsCase, inOtherMount, leadsInto, linkLeads, linkOnTheWay, linkRefusal, linkWords, locateRoot, nestedAtReal, nestedHolder, passedOverAt, readPrefix, type CheckedDir, type LinkFound, type MountView, type NestedMount, type WorkspaceEntries } from './observe.js';
import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-registry.js';
import {
type MountRuntime,
Expand Down Expand Up @@ -824,6 +824,8 @@ export class WorkspaceModule implements Module {
* (readNesting).
*/
private otherMounts = new Map<string, NestedMountEntry[]>();
/** Whether each mount's root folds case (foldsCase), where disk has it, as the last start read it. */
private rootFoldsCase = new Map<string, boolean>();
/**
* Whether a file's mode is observed, taken in and applied (MountRuntime's
* `modesObserved`): not on Windows, which reports no POSIX modes. A test
Expand Down Expand Up @@ -998,14 +1000,14 @@ export class WorkspaceModule implements Module {
}

/**
* Where a search's walk starts: `base`, its path as disk spells it, then each
* segment of `beneath` while disk lists it, in the directory reached so
* far, as a directory with exactly that spelling. The walk names what it
* finds from its start's spelling, and a case-insensitive disk would let
* it go on under a spelling disk doesn't list, taking files in under that
* spelling beside their own. So the start stops before a segment spelled
* any other way, not there, or not a directory, and before a link, which
* a walk of the whole mount doesn't go through either. It lists only
* Where a search's walk starts: `base`, its path as parsePath spells it,
* then each segment of `beneath` while disk lists it, in the directory
* reached so far, as a directory with exactly that spelling. The walk names
* what it finds from its start's spelling, and a case-insensitive disk
* would let it go on under a spelling disk doesn't list, taking files in
* under that spelling beside their own. So the start stops before a segment
* spelled any other way, not there, or not a directory, and before a link,
* which a walk of the whole mount doesn't go through either. It lists only
* directories it has checked lie inside the mount, and none the mount
* passes over by design, which the walk then answers for.
*/
Expand Down Expand Up @@ -1054,14 +1056,18 @@ export class WorkspaceModule implements Module {

async start(ctx: ModuleContext): Promise<void> {
// Which mounts are nested where, with the roots placed where disk has
// them and each nested mount's directory as disk spells it, read before
// this start sets anything, so a restart takes no call and walks or
// watches nothing before they're known. A stop begun meanwhile stands,
// and this start then sets nothing.
// them and each nested mount's directory as disk spells it, and whether
// each mount's root folds case, read where disk has it, before this start
// sets anything, so a restart takes no call and walks or watches nothing
// before they're known. A stop begun meanwhile stands, and this start then
// sets nothing.
const stops = this.stopsBegun;
const nesting = await this.readNesting();
const { nesting, reals } = await this.readNesting();
const rootFoldsCase = new Map<string, boolean>();
for (const mount of this.config.mounts) rootFoldsCase.set(mount.name, await foldsCase(reals.get(mount.name)!));
if (this.stopsBegun !== stops) return;
this.otherMounts = nesting;
this.rootFoldsCase = rootFoldsCase;

this.ctx = ctx;
this.stopped = false;
Expand Down Expand Up @@ -1979,9 +1985,11 @@ export class WorkspaceModule implements Module {
* disk that folds names, the outer mount's walks name the directory as
* disk does, and so does a path parsePath has respelled, while a path given
* as configured still names it as configured (resolveAbsolutePath). A
* directory not on disk keeps the spellings the roots give alone.
* directory not on disk keeps the spellings the roots give alone. Each
* mount's real root comes back beside the nesting (`reals`), for what
* start reads there.
*/
private async readNesting(): Promise<Map<string, NestedMountEntry[]>> {
private async readNesting(): Promise<{ nesting: Map<string, NestedMountEntry[]>; reals: Map<string, string> }> {
const reals = new Map<string, string>();
// Each directory above a mount's configured path, with where it lies on
// disk, so a path that reaches another mount's root through a link, as
Expand Down Expand Up @@ -2031,7 +2039,7 @@ export class WorkspaceModule implements Module {
}
}
}
return nesting;
return { nesting, reals };
}

/** The mounts nested in `mountName`, by directory, name and real root where it has one (MountView's `nested`), in each spelling known. */
Expand Down Expand Up @@ -2120,20 +2128,52 @@ export class WorkspaceModule implements Module {
* disk spells it (diskSpelling), with `path` the mount-prefixed path so
* spelled. On a disk that folds names, a path spelled otherwise reaches
* disk's file, so every tool takes it by disk's name: one name per file,
* and the names its answers give are disk's. A handler that must read
* something at the moment it's asked, as handleSync reads the branch a path
* sync adopts on, reads it before calling this, since this awaits disk.
* and the names its answers give are disk's, except that a path that names
* a workspace entry exactly, or does once its directories take disk's
* spelling, keeps the entry's spelling. A path the workspace records an
* intent at, such as a deletion disk hasn't been given yet, counts as an
* entry here, so the calls that settle it reach it. The workspace's
* entries (entriesOf) stand in for disk where it has nothing yet, so a
* draft has one name too. Neither applies to a path that disk's spelling
* puts in a nested mount's directory.
* A handler that must read something at the moment it's asked, as
* handleSync reads the branch a path sync adopts on, reads it before
* calling this, since this awaits disk. The entries are read when the
* step needs them, some after those awaits, from the branch current then.
*/
private async parsePath(path: string): Promise<{ mount: MountState; relativePath: string; path: string }> {
const given = this.parsePathAsGiven(path);
const relativePath = await diskSpelling(resolve(given.mount.config.path), given.relativePath);
const relativePath = await diskSpelling(resolve(given.mount.config.path), given.relativePath, this.entriesOf(given.mount));
return {
mount: given.mount,
relativePath,
path: relativePath === given.relativePath ? path : `${given.mount.config.name}/${relativePath}`,
};
}

/**
* A mount's entries on the current branch, and the paths it records an
* intent at there, as diskSpelling reads them when it does, whether its
* root folds case as the last start read it (false before the first), and
* which nested mount's directory holds a path, by holderOf, as the
* nested-mount checks read it. diskSpelling keeps disk's spelling of a
* path that lies in a nested mount's directory as disk spells it, so the
* checks refuse at least what they would refuse without the entries.
*/
private entriesOf(mount: MountState): WorkspaceEntries {
const store = this.getStore();
const intents = this.intents.get(mount.config.name)!;
return {
// An intent recorded at a path, such as a deletion disk hasn't been
// given yet or a conflict, is the workspace's own name for the file
// there, as an entry is, so the calls that settle it reach it.
has: (path) => store.treeGet(mount.treeStateId, path) !== null || store.treeGet(intents.treeStateId, path) !== null,
under: (prefix) => store.treeList(mount.treeStateId, prefix).map((entry) => entry.path),
holder: (path) => this.holderOf(mount.config.name, path)?.name,
rootFoldsCase: this.rootFoldsCase.get(mount.config.name) ?? false,
};
}

/**
* Parse a mount-prefixed path into (mountName, relativePath), as spelled.
*/
Expand Down
Loading
Loading