diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c58e47f3..7af3a73c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -5,6 +5,8 @@ /src/core @celonis/astro /src/commands/configuration-management/ @celonis/astro /tests/commands/configuration-management/ @celonis/astro +/src/commands/workspace/ @celonis/astro +/tests/commands/workspace/ @celonis/astro /src/commands/t2tc/ @celonis/astro /tests/commands/t2tc/ @celonis/astro /src/commands/profile/ @celonis/astro diff --git a/src/commands/workspace/module.ts b/src/commands/workspace/module.ts new file mode 100644 index 00000000..56226c0c --- /dev/null +++ b/src/commands/workspace/module.ts @@ -0,0 +1,97 @@ +import { Command, OptionValues } from "commander"; +import { Context } from "../../core/command/cli-context"; +import { Configurator, IModule } from "../../core/command/module-handler"; +import { WorkspaceService } from "./workspace.service"; + +async function runWorkspaceCommand(action: () => Promise | T): Promise { + try { + await action(); + } catch (error) { + process.exitCode = 1; + throw error; + } +} + +class Module extends IModule { + public register(context: Context, configurator: Configurator): void { + const workspace = configurator.command("workspace").beta().description("Manage a package workspace."); + + workspace + .command("clone [directory]") + .beta() + .description("Clone a package workspace.") + .option("--branch ", "Clone a branch") + .action(this.clone); + + workspace + .command("checkout [branch]") + .beta() + .description("Select a package branch.") + .option("-b, --create ", "Create and select a branch") + .option("--discard", "Discard local workspace changes", false) + .option("--link-git", "Map the current Git branch", false) + .action(this.checkout); + + workspace.command("pull [paths...]").beta().description("Pull remote changes.").action(this.pull); + + workspace.command("status [directory]").beta().description("Show local changes.").action(this.status); + + workspace + .command("push [paths...]") + .beta() + .description("Push local changes.") + .option("--asset-type ", "Asset Type for new files") + .action(this.push); + + workspace + .command("move ") + .beta() + .description("Move a tracked file to another parent.") + .option("--record", "Record an existing move", false) + .action(this.move); + } + + private async clone(context: Context, command: Command, options: OptionValues): Promise { + await runWorkspaceCommand(() => + new WorkspaceService(context).clone(command.args[0], command.args[1], { branch: options.branch }) + ); + } + + private async checkout(context: Context, command: Command, options: OptionValues): Promise { + await runWorkspaceCommand(async () => { + const branch = options.create || command.args[0]; + if (!branch || (options.create && command.args[0])) { + throw new Error("Provide one branch name or use -b ."); + } + await new WorkspaceService(context).checkout(branch, { + create: Boolean(options.create), + discard: options.discard, + linkGit: options.linkGit, + }); + }); + } + + private async pull(context: Context, command: Command): Promise { + await runWorkspaceCommand(() => new WorkspaceService(context).pull(command.args)); + } + + private async status(context: Context, command: Command): Promise { + await runWorkspaceCommand(() => new WorkspaceService(context).statusWithGit(command.args[0])); + } + + private async push(context: Context, command: Command, options: OptionValues): Promise { + await runWorkspaceCommand(() => + new WorkspaceService(context).push(command.args, { + assetType: options.assetType, + }) + ); + } + + private async move(context: Context, command: Command, options: OptionValues): Promise { + await runWorkspaceCommand(() => + new WorkspaceService(context).move(command.args[0], command.args[1], options.record) + ); + } +} + +export = Module; diff --git a/src/commands/workspace/workspace-api.ts b/src/commands/workspace/workspace-api.ts new file mode 100644 index 00000000..26edd218 --- /dev/null +++ b/src/commands/workspace/workspace-api.ts @@ -0,0 +1,112 @@ +import { Context } from "../../core/command/cli-context"; +import { GracefulError } from "../../core/utils/logger"; +import { NodeFileWriteResponse, WorkspaceBranch, WorkspaceManifest } from "./workspace.models"; + +export class WorkspaceApi { + constructor(private readonly context: Context) {} + + public async readFile(packageKey: string, filePath: string): Promise<{ body: Buffer; eTag: string }> { + const response = await this.context.httpClient.getFileWithHeaders(this.fileUrl(packageKey, filePath)); + const eTag = response.headers.etag; + if (typeof eTag !== "string") { + throw new GracefulError(`File response does not contain an ETag: ${filePath}`); + } + return { body: response.data, eTag }; + } + + public async manifest( + packageKey: string, + ifNoneMatch?: string + ): Promise<{ manifest?: WorkspaceManifest; eTag: string; notModified: boolean }> { + const response = await this.context.httpClient.getFileWithHeaders( + `/pacman/api/core/staging/packages/${encodeURIComponent(packageKey)}/files`, + ifNoneMatch ? { "If-None-Match": ifNoneMatch } : {}, + [200, 304] + ); + const eTag = response.headers.etag; + if (typeof eTag !== "string" || !eTag) { + throw new GracefulError("Workspace manifest response does not contain a package ETag."); + } + if (response.status === 304) { + return { eTag, notModified: true }; + } + try { + return { + manifest: JSON.parse(response.data.toString("utf-8")) as WorkspaceManifest, + eTag, + notModified: false, + }; + } catch (error) { + const failure = new GracefulError("Workspace manifest response is invalid JSON."); + failure.cause = error; + throw failure; + } + } + + public putFile( + packageKey: string, + filePath: string, + assetType: string, + body: Buffer, + contentType: string, + headers: Record + ): Promise { + return this.context.httpClient.putFile( + `${this.fileUrl(packageKey, filePath)}?assetType=${encodeURIComponent(assetType)}`, + body, + contentType, + undefined, + headers + ); + } + + public createFolder(packageKey: string, folderPath: string): Promise { + return this.context.httpClient.putFile( + this.folderUrl(packageKey, folderPath), + Buffer.alloc(0), + "application/octet-stream", + undefined, + { "If-None-Match": "*" } + ); + } + + public moveFile( + packageKey: string, + sourcePath: string, + targetPath: string, + eTag: string + ): Promise { + return this.context.httpClient.patch( + this.fileUrl(packageKey, sourcePath), + { targetPath }, + { "If-Match": eTag } + ); + } + + public deleteFile(packageKey: string, filePath: string, eTag: string): Promise { + return this.context.httpClient.delete(this.fileUrl(packageKey, filePath), { "If-Match": eTag }); + } + + public createBranch(packageKey: string, branchKey: string): Promise { + return this.context.httpClient.post(`/pacman/api/core/packages/${encodeURIComponent(packageKey)}/branches`, { + branchKey, + version: "STAGING", + }); + } + + private fileUrl(packageKey: string, filePath: string): string { + return this.pathUrl(packageKey, "files", filePath); + } + + private folderUrl(packageKey: string, folderPath: string): string { + return this.pathUrl(packageKey, "folders", folderPath); + } + + private pathUrl(packageKey: string, collection: string, entryPath: string): string { + const encodedPath = entryPath + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); + return `/pacman/api/core/staging/packages/${encodeURIComponent(packageKey)}/${collection}/${encodedPath}`; + } +} diff --git a/src/commands/workspace/workspace-change-classifier.ts b/src/commands/workspace/workspace-change-classifier.ts new file mode 100644 index 00000000..63c970d0 --- /dev/null +++ b/src/commands/workspace/workspace-change-classifier.ts @@ -0,0 +1,215 @@ +import * as path from "node:path"; +import { ClassifiedWorkspaceChange, ExpectedWorkspaceFile } from "./workspace.models"; + +export function classifyWorkspaceChanges( + expectedFiles: ExpectedWorkspaceFile[], + visibleFiles: Map, + moveHints: Record +): ClassifiedWorkspaceChange[] { + const changes: ClassifiedWorkspaceChange[] = []; + const consumedPaths = new Set(); + const missing: ExpectedWorkspaceFile[] = []; + const visiblePathIndex = new Map([...visibleFiles.keys()].map(filePath => [filePath.toLowerCase(), filePath])); + + expectedFiles.forEach(file => + classifyExpectedFile( + file, + visibleFiles, + visiblePathIndex, + moveHints[file.nodeKey], + changes, + consumedPaths, + missing + ) + ); + + resolveDigestMoves(missing, visibleFiles, consumedPaths, changes); + + const newPaths = [...visibleFiles.keys()].filter(filePath => !consumedPaths.has(filePath)); + resolveMovedAndEdited(missing, newPaths, changes); + missing.forEach(file => + changes.push({ nodeKey: file.nodeKey, path: file.path, status: file.digest ? "deleted" : "unresolved" }) + ); + newPaths.forEach(filePath => changes.push({ path: filePath, status: "added" })); + + return changes.sort( + (left, right) => left.path.localeCompare(right.path) || left.status.localeCompare(right.status) + ); +} + +function classifyExpectedFile( + file: ExpectedWorkspaceFile, + visibleFiles: Map, + visiblePathIndex: Map, + hint: string | undefined, + changes: ClassifiedWorkspaceChange[], + consumedPaths: Set, + missing: ExpectedWorkspaceFile[] +): void { + const currentPath = visiblePathIndex.get(file.path.toLowerCase()); + if (!file.digest) { + if (hint || currentPath) { + classifyNewFile(file, hint, visibleFiles, visiblePathIndex, changes, consumedPaths); + } else { + missing.push(file); + } + return; + } + if (hint) { + classifyHintedFile(file, hint, visibleFiles, visiblePathIndex, changes, consumedPaths); + return; + } + if (currentPath) { + classifyCurrentFile(file, currentPath, visibleFiles, changes, consumedPaths); + return; + } + missing.push(file); +} + +function classifyCurrentFile( + file: ExpectedWorkspaceFile, + currentPath: string, + visibleFiles: Map, + changes: ClassifiedWorkspaceChange[], + consumedPaths: Set +): void { + consumedPaths.add(currentPath); + const changed = visibleFiles.get(currentPath) !== file.digest; + if (currentPath === file.path) { + if (changed) { + changes.push({ nodeKey: file.nodeKey, path: currentPath, status: "modified" }); + } + return; + } + let status: ClassifiedWorkspaceChange["status"] = "unresolved"; + if (sameLeaf(file.path, currentPath)) { + status = changed ? "moved, modified" : "moved"; + } + changes.push({ nodeKey: file.nodeKey, path: currentPath, status }); +} + +function classifyNewFile( + file: ExpectedWorkspaceFile, + hint: string | undefined, + visibleFiles: Map, + visiblePathIndex: Map, + changes: ClassifiedWorkspaceChange[], + consumedPaths: Set +): void { + const target = hint || file.path; + const targetPath = visiblePathIndex.get(target.toLowerCase()); + const sourcePath = visiblePathIndex.get(file.path.toLowerCase()); + const sourceStillPresent = Boolean(hint && hint.toLowerCase() !== file.path.toLowerCase() && sourcePath); + if (sourceStillPresent || !sameLeaf(file.path, target) || !targetPath || consumedPaths.has(targetPath)) { + changes.push({ nodeKey: file.nodeKey, path: target, status: "unresolved" }); + if (targetPath) { + consumedPaths.add(targetPath); + } + if (sourceStillPresent && sourcePath) { + consumedPaths.add(sourcePath); + } + return; + } + consumedPaths.add(targetPath); + changes.push({ nodeKey: file.nodeKey, path: target, status: "added" }); +} + +function classifyHintedFile( + file: ExpectedWorkspaceFile, + hint: string, + visibleFiles: Map, + visiblePathIndex: Map, + changes: ClassifiedWorkspaceChange[], + consumedPaths: Set +): void { + const targetPath = visiblePathIndex.get(hint.toLowerCase()); + const sourcePath = visiblePathIndex.get(file.path.toLowerCase()); + if (!sameLeaf(file.path, hint) || !targetPath || consumedPaths.has(targetPath)) { + changes.push({ nodeKey: file.nodeKey, path: hint, status: "unresolved" }); + if (targetPath) { + consumedPaths.add(targetPath); + } + if (sourcePath) { + consumedPaths.add(sourcePath); + } + return; + } + consumedPaths.add(targetPath); + changes.push({ + nodeKey: file.nodeKey, + path: hint, + status: visibleFiles.get(targetPath) === file.digest ? "moved" : "moved, modified", + }); +} + +function resolveDigestMoves( + missing: ExpectedWorkspaceFile[], + visibleFiles: Map, + consumedPaths: Set, + changes: ClassifiedWorkspaceChange[] +): void { + groupBy( + missing.filter(file => file.digest), + file => file.digest! + ).forEach((files, digest) => { + const candidates = [...visibleFiles.entries()] + .filter(([filePath, visibleDigest]) => !consumedPaths.has(filePath) && visibleDigest === digest) + .map(([filePath]) => filePath); + if (files.length === 1 && candidates.length === 1) { + consumedPaths.add(candidates[0]); + changes.push({ + nodeKey: files[0].nodeKey, + path: candidates[0], + status: sameLeaf(files[0].path, candidates[0]) ? "moved" : "unresolved", + }); + missing.splice(missing.indexOf(files[0]), 1); + return; + } + if (candidates.length > 0) { + files.forEach(file => { + changes.push({ nodeKey: file.nodeKey, path: file.path, status: "unresolved" }); + missing.splice(missing.indexOf(file), 1); + }); + candidates.forEach(filePath => { + consumedPaths.add(filePath); + changes.push({ path: filePath, status: "unresolved" }); + }); + } + }); +} + +function resolveMovedAndEdited( + missing: ExpectedWorkspaceFile[], + newPaths: string[], + changes: ClassifiedWorkspaceChange[] +): void { + const missingByBasename = groupBy(missing, file => path.posix.basename(file.path).toLowerCase()); + const pathsByBasename = groupBy(newPaths, filePath => path.posix.basename(filePath).toLowerCase()); + missingByBasename.forEach((files, basename) => { + const paths = pathsByBasename.get(basename); + if (!paths) { + return; + } + if (files.length === 1 && paths.length === 1) { + changes.push({ nodeKey: files[0].nodeKey, path: paths[0], status: "unresolved" }); + } else { + files.forEach(file => changes.push({ nodeKey: file.nodeKey, path: file.path, status: "unresolved" })); + paths.forEach(filePath => changes.push({ path: filePath, status: "unresolved" })); + } + files.forEach(file => missing.splice(missing.indexOf(file), 1)); + paths.forEach(filePath => newPaths.splice(newPaths.indexOf(filePath), 1)); + }); +} + +function sameLeaf(source: string, target: string): boolean { + return path.posix.basename(source) === path.posix.basename(target); +} + +function groupBy(values: T[], key: (value: T) => string): Map { + const groups = new Map(); + values.forEach(value => { + const groupKey = key(value); + groups.set(groupKey, [...(groups.get(groupKey) || []), value]); + }); + return groups; +} diff --git a/src/commands/workspace/workspace-git.service.ts b/src/commands/workspace/workspace-git.service.ts new file mode 100644 index 00000000..0908daeb --- /dev/null +++ b/src/commands/workspace/workspace-git.service.ts @@ -0,0 +1,113 @@ +import { createHash } from "node:crypto"; +import * as path from "node:path"; +import simpleGit from "simple-git"; +import { GracefulError } from "../../core/utils/logger"; +import { WorkspaceGitObservation } from "./workspace.models"; + +interface GitContext extends WorkspaceGitObservation { + root: string; +} + +export class WorkspaceGitService { + public async observe(workspaceRoot: string): Promise { + const context = await this.context(workspaceRoot); + return context ? { branch: context.branch, head: context.head } : undefined; + } + + public async mappedPacmanBranch( + workspaceRoot: string, + projectKey: string, + gitBranch: string + ): Promise { + const context = await this.context(workspaceRoot); + if (!context) { + return undefined; + } + return (await this.mappings(context.root, workspaceRoot, projectKey))[gitBranch]; + } + + public async link( + workspaceRoot: string, + projectKey: string, + gitBranch: string, + pacmanBranch: string + ): Promise { + const context = await this.context(workspaceRoot); + if (!context) { + throw new GracefulError("Workspace is not inside a Git worktree."); + } + if (context.branch !== gitBranch) { + throw new GracefulError("Git branch changed while linking the workspace."); + } + const mappings = await this.mappings(context.root, workspaceRoot, projectKey); + mappings[gitBranch] = pacmanBranch; + await this.run(context.root, [ + "config", + "--local", + this.mappingKey(context.root, workspaceRoot, projectKey), + JSON.stringify(mappings), + ]); + return { branch: context.branch, head: context.head }; + } + + private async context(workspaceRoot: string): Promise { + const gitRoot = await this.tryRun(workspaceRoot, ["rev-parse", "--show-toplevel"]); + if (!gitRoot) { + return undefined; + } + const head = await this.tryRun(gitRoot, ["rev-parse", "HEAD"]); + if (!head) { + return undefined; + } + const branch = await this.tryRun(gitRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"]); + return { root: path.resolve(gitRoot), branch: branch || "", head }; + } + + private async mappings( + gitRoot: string, + workspaceRoot: string, + projectKey: string + ): Promise> { + const raw = await this.tryRun(gitRoot, [ + "config", + "--local", + "--get", + this.mappingKey(gitRoot, workspaceRoot, projectKey), + ]); + if (!raw) { + return {}; + } + try { + const parsed = JSON.parse(raw) as Record; + if ( + !parsed || + typeof parsed !== "object" || + Array.isArray(parsed) || + !Object.values(parsed).every(value => typeof value === "string") + ) { + throw new Error("Invalid workspace mapping value."); + } + return parsed as Record; + } catch { + throw new GracefulError("Git contains an invalid Content CLI workspace mapping."); + } + } + + private mappingKey(gitRoot: string, workspaceRoot: string, projectKey: string): string { + const relativeRoot = path.relative(gitRoot, workspaceRoot).split(path.sep).join("/") || "."; + const id = createHash("sha256").update(`${projectKey}\0${relativeRoot}`).digest("hex").slice(0, 16); + return `content-cli-workspace.${id}.mappings`; + } + + private async tryRun(root: string, args: string[]): Promise { + try { + return await this.run(root, args); + } catch { + return undefined; + } + } + + private async run(root: string, args: string[]): Promise { + return (await simpleGit({ baseDir: root }).raw(args)).trim(); + } +} diff --git a/src/commands/workspace/workspace-path-projector.ts b/src/commands/workspace/workspace-path-projector.ts new file mode 100644 index 00000000..ae216314 --- /dev/null +++ b/src/commands/workspace/workspace-path-projector.ts @@ -0,0 +1,182 @@ +import { createHash } from "node:crypto"; +import * as path from "node:path"; +import { GracefulError } from "../../core/utils/logger"; +import { WorkspaceNode } from "./workspace.models"; + +const ROOT = "\u0000root"; + +export function projectWorkspacePaths(nodes: WorkspaceNode[], packageKey?: string): Map { + const byKey = new Map(); + nodes.forEach(node => { + if (byKey.has(node.nodeKey)) { + throw new GracefulError(`Duplicate node metadata key: ${node.nodeKey}.`); + } + byKey.set(node.nodeKey, node); + }); + const candidates = new Map(nodes.map(node => [node.nodeKey, candidateSegment(node)])); + const projected = projectedSegments(nodes, candidates, packageKey); + const paths = new Map(); + const resolving = new Set(); + const resolve = (node: WorkspaceNode): string => { + const cached = paths.get(node.nodeKey); + if (cached) { + return cached; + } + if (resolving.has(node.nodeKey)) { + throw new GracefulError(`Circular node hierarchy at ${node.nodeKey}.`); + } + resolving.add(node.nodeKey); + const parentKey = normalizedParent(node.parentNodeKey, packageKey); + const parent = parentKey === ROOT ? undefined : byKey.get(parentKey); + if (parentKey !== ROOT && (!parent || !isFolder(parent))) { + throw new GracefulError(`Invalid parent metadata for node ${node.nodeKey}.`); + } + const segment = projected.get(node.nodeKey)!; + const value = parent ? `${resolve(parent)}/${segment}` : segment; + validateVisiblePath(value); + resolving.delete(node.nodeKey); + paths.set(node.nodeKey, value); + return value; + }; + nodes.forEach(resolve); + return paths; +} + +export function projectedLeafAfterMove( + nodes: WorkspaceNode[], + nodeKey: string, + targetParentKey: string | undefined, + packageKey?: string +): string { + if (!nodes.some(node => node.nodeKey === nodeKey)) { + throw new GracefulError(`Tracked node metadata is missing: ${nodeKey}.`); + } + const moved = nodes.map(node => + node.nodeKey === nodeKey ? { ...node, parentNodeKey: targetParentKey || null } : node + ); + const candidates = new Map(moved.map(node => [node.nodeKey, candidateSegment(node)])); + return projectedSegments(moved, candidates, packageKey).get(nodeKey)!; +} + +function projectedSegments( + nodes: WorkspaceNode[], + candidates: Map, + packageKey?: string +): Map { + const projected = new Map(candidates); + const byParent = groupBy(nodes, node => normalizedParent(node.parentNodeKey, packageKey)); + byParent.forEach(siblings => { + groupBy(siblings, node => candidates.get(node.nodeKey)!.toLowerCase()).forEach(group => { + if (group.length > 1) { + disambiguate(group, siblings, candidates, projected); + } + }); + }); + return projected; +} + +function disambiguate( + group: WorkspaceNode[], + siblings: WorkspaceNode[], + candidates: Map, + projected: Map +): void { + const hashes = new Map(group.map(node => [node.nodeKey, sha256(node.nodeKey)])); + const groupKeys = new Set(group.map(node => node.nodeKey)); + const occupied = new Set( + siblings.filter(node => !groupKeys.has(node.nodeKey)).map(node => candidates.get(node.nodeKey)!.toLowerCase()) + ); + let length = 12; + while (length < 64) { + const prefixes = new Set(); + const uniquePrefixes = group.every(node => prefixes.add(hashes.get(node.nodeKey)!.slice(0, length))); + const available = group.every( + node => + !occupied.has( + addSuffix( + candidates.get(node.nodeKey)!, + isFolder(node), + hashes.get(node.nodeKey)!.slice(0, length) + ).toLowerCase() + ) + ); + if (uniquePrefixes && available) { + break; + } + length = Math.min(64, length + 4); + } + group.forEach(node => + projected.set( + node.nodeKey, + addSuffix(candidates.get(node.nodeKey)!, isFolder(node), hashes.get(node.nodeKey)!.slice(0, length)) + ) + ); +} + +function candidateSegment(node: WorkspaceNode): string { + const extension = isFolder(node) ? "" : `.${fileExtension(node.type)}`; + const segment = `${node.name}${extension}`; + if ( + !segment || + segment === "." || + segment === ".." || + segment.includes("/") || + segment.includes("\\") || + [...segment].some(character => { + const codePoint = character.codePointAt(0)!; + return codePoint < 32 || codePoint === 127; + }) + ) { + throw new GracefulError(`Invalid derived filesystem name for node ${node.nodeKey}.`); + } + return segment; +} + +function fileExtension(assetType: string): string { + switch (assetType.toUpperCase()) { + case "MD": + case "MARKDOWN_FILE": + return "md"; + case "HTML_CANVAS": + return "html"; + default: + return "json"; + } +} + +function addSuffix(segment: string, folder: boolean, suffix: string): string { + if (folder) { + return `${segment}~${suffix}`; + } + const extension = path.posix.extname(segment); + return extension ? `${segment.slice(0, -extension.length)}~${suffix}${extension}` : `${segment}~${suffix}`; +} + +function validateVisiblePath(value: string): void { + const segments = value.split("/"); + const first = segments[0].toLowerCase(); + if (first === ".package" || first === ".git") { + throw new GracefulError(`Invalid derived workspace path: ${value}.`); + } +} + +function normalizedParent(parentNodeKey: string | null | undefined, packageKey?: string): string { + return !parentNodeKey || parentNodeKey === packageKey ? ROOT : parentNodeKey; +} + +function isFolder(node: WorkspaceNode): boolean { + return node.type.toUpperCase() === "FOLDER"; +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function groupBy(values: T[], key: (value: T) => string): Map { + const groups = new Map(); + values.forEach(value => { + const groupKey = key(value); + groups.set(groupKey, [...(groups.get(groupKey) || []), value]); + }); + return groups; +} diff --git a/src/commands/workspace/workspace-path-selection.ts b/src/commands/workspace/workspace-path-selection.ts new file mode 100644 index 00000000..3da4d886 --- /dev/null +++ b/src/commands/workspace/workspace-path-selection.ts @@ -0,0 +1,63 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { GracefulError } from "../../core/utils/logger"; + +export interface WorkspaceSelectionCandidate { + value: T; + paths: string[]; +} + +interface Selection { + path: string; + directory: boolean; +} + +export function selectWorkspaceCandidates( + root: string, + values: string[], + candidates: Array> +): T[] { + if (values.length === 0) { + return candidates.map(candidate => candidate.value); + } + const candidatePaths = candidates.flatMap(candidate => candidate.paths); + const selections = values.map(value => selection(root, value, candidatePaths)); + return candidates + .filter(candidate => + selections.some(selected => candidate.paths.some(candidatePath => matches(selected, candidatePath))) + ) + .map(candidate => candidate.value); +} + +function selection(root: string, value: string, candidatePaths: string[]): Selection { + const absolute = path.resolve(process.cwd(), value); + const relative = path.relative(root, absolute).split(path.sep).join("/"); + const folded = relative.toLowerCase(); + if ( + relative === ".." || + relative.startsWith("../") || + path.isAbsolute(relative) || + folded === ".package" || + folded.startsWith(".package/") || + folded === ".git" || + folded.startsWith(".git/") + ) { + throw new GracefulError(`Invalid workspace path: ${value}`); + } + const exists = fs.existsSync(absolute); + if (exists && fs.lstatSync(absolute).isSymbolicLink()) { + throw new GracefulError(`Workspace contains an unsupported symbolic link: ${value}`); + } + const directory = exists + ? fs.lstatSync(absolute).isDirectory() + : candidatePaths.some(candidate => candidate.toLowerCase().startsWith(`${folded}/`)); + return { path: relative, directory }; +} + +function matches(selection: Selection, candidatePath: string): boolean { + const selected = selection.path.toLowerCase(); + const candidate = candidatePath.toLowerCase(); + return selection.directory + ? !selected || candidate === selected || candidate.startsWith(`${selected}/`) + : candidate === selected; +} diff --git a/src/commands/workspace/workspace-pull.service.ts b/src/commands/workspace/workspace-pull.service.ts new file mode 100644 index 00000000..5b01aca5 --- /dev/null +++ b/src/commands/workspace/workspace-pull.service.ts @@ -0,0 +1,813 @@ +import { createHash } from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { GracefulError } from "../../core/utils/logger"; +import { WorkspaceApi } from "./workspace-api"; +import { selectWorkspaceCandidates } from "./workspace-path-selection"; +import { projectWorkspacePaths } from "./workspace-path-projector"; +import { + ClassifiedWorkspaceChange, + ExpectedWorkspaceFile, + WorkspaceManifest, + WorkspaceManifestNode, + WorkspaceNode, + WorkspaceNodeMetadata, + WorkspacePullOutcome, + WorkspacePullStatus, + WorkspaceSnapshot, + WorkspaceState, +} from "./workspace.models"; + +const FORBIDDEN_METADATA_FIELDS = [ + "configuration", + "packageKey", + "packageNodeKey", + "branchKey", + "spaceId", + "creationDate", + "changeDate", + "revision", + "filesystemName", +]; + +interface PullOperation { + nodeKey: string; + path: string; + localPath?: string; + status: WorkspacePullStatus; + entry?: WorkspaceManifestNode; + localNode?: WorkspaceNode; + localChange?: ClassifiedWorkspaceChange; + replacedNodeKey?: string; + conflict?: string; + converged?: boolean; + verifyConvergence?: boolean; + downloadBody?: boolean; +} + +interface PullOperationContext { + snapshot: WorkspaceSnapshot; + localByKey: Map; + localPaths: Map; + localByPath: Map; + expectedByKey: Map; + changeByKey: Map; + replacedLocalKeys: Set; + recoveringCreateKeys: boolean; +} + +interface AppliedFolderMove { + sourcePath: string; + targetPath: string; +} + +export interface WorkspacePullResult { + outcomes: WorkspacePullOutcome[]; + state: WorkspaceState; +} + +export class WorkspacePullService { + constructor(private readonly api: WorkspaceApi) {} + + public async pull( + root: string, + snapshot: WorkspaceSnapshot, + localNodes: WorkspaceNode[], + paths: string[], + manifest: WorkspaceManifest, + recoveringCreateKeys: boolean = false + ): Promise { + this.validateManifest(manifest); + const operations = this.select( + root, + paths, + this.operations(snapshot, localNodes, manifest, recoveringCreateKeys) + ); + const state: WorkspaceState = { + ...snapshot.state, + baselineDigests: { ...snapshot.state.baselineDigests }, + baselineNodeETags: { ...snapshot.state.baselineNodeETags }, + moveHints: { ...snapshot.state.moveHints }, + }; + delete state.refreshRequired; + const outcomes: WorkspacePullOutcome[] = []; + const appliedFolderMoves: AppliedFolderMove[] = []; + for (const operation of this.order(operations)) { + try { + if (operation.conflict) { + throw new GracefulError(operation.conflict); + } + await this.apply(root, snapshot.packageKey, operation, manifest, state, appliedFolderMoves); + outcomes.push({ + path: operation.path, + status: operation.status, + nodeKey: operation.nodeKey, + success: true, + }); + } catch (error) { + outcomes.push({ + path: operation.path, + status: "conflict", + nodeKey: operation.nodeKey, + success: false, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return { outcomes, state }; + } + + public async hydrateRemoteBaseline( + state: WorkspaceState, + packageKey: string, + activeBranch: string, + manifest: WorkspaceManifest + ): Promise { + this.validateManifest(manifest); + const baselineDigests: Record = {}; + for (const entry of manifest.nodes) { + if (!this.isFolder(entry.metadata)) { + const remote = await this.api.readFile(packageKey, entry.path); + if (remote.eTag !== entry.eTag) { + throw new GracefulError(`Remote file changed while hydrating the baseline: ${entry.path}`); + } + baselineDigests[entry.nodeKey] = this.digestBuffer(remote.body); + } + } + const hydrated: WorkspaceState = { + schemaVersion: 1, + activePackageKey: packageKey, + activeBranch, + baselineDigests, + baselineNodeETags: Object.fromEntries(manifest.nodes.map((entry) => [entry.nodeKey, entry.eTag])), + moveHints: {}, + }; + if (state.git) { + hydrated.git = state.git; + } + return hydrated; + } + + public applyPushResults( + root: string, + state: WorkspaceState, + manifest: WorkspaceManifest, + outcomes: Array<{ + nodeKey?: string; + localNodeKey?: string; + status: string; + success: boolean; + remoteChanged?: boolean; + }> + ): WorkspaceState { + this.validateManifest(manifest); + const byNodeKey = new Map(manifest.nodes.map((entry) => [entry.nodeKey, entry])); + const next: WorkspaceState = { + ...state, + baselineDigests: { ...state.baselineDigests }, + baselineNodeETags: { ...state.baselineNodeETags }, + moveHints: { ...state.moveHints }, + }; + delete next.refreshRequired; + outcomes + .filter((outcome) => (outcome.success || outcome.remoteChanged) && outcome.nodeKey) + .forEach((outcome) => { + const nodeKey = outcome.nodeKey!; + if (outcome.localNodeKey && outcome.localNodeKey !== nodeKey) { + this.removeMetadata(root, outcome.localNodeKey); + delete next.baselineDigests[outcome.localNodeKey]; + delete next.baselineNodeETags[outcome.localNodeKey]; + delete next.moveHints[outcome.localNodeKey]; + } + const entry = byNodeKey.get(nodeKey); + if (!entry) { + if (outcome.status === "deleted") { + this.removeMetadata(root, nodeKey); + delete next.baselineDigests[nodeKey]; + delete next.baselineNodeETags[nodeKey]; + delete next.moveHints[nodeKey]; + } + return; + } + this.writeMetadataWithAncestors(root, entry, manifest); + next.baselineNodeETags[nodeKey] = entry.eTag; + if (!this.isFolder(entry.metadata) && outcome.success) { + const visible = this.resolve(root, entry.path); + if (fs.existsSync(visible) && fs.lstatSync(visible).isFile()) { + next.baselineDigests[nodeKey] = this.digest(visible); + } + } + delete next.moveHints[nodeKey]; + }); + return next; + } + + private operations( + snapshot: WorkspaceSnapshot, + localNodes: WorkspaceNode[], + manifest: WorkspaceManifest, + recoveringCreateKeys: boolean + ): PullOperation[] { + const localByKey = new Map(localNodes.map((node) => [node.nodeKey, node])); + const localPaths = this.localPaths(localNodes, snapshot.packageKey); + const localByPath = new Map( + [...localPaths].map(([nodeKey, localPath]) => [localPath.toLowerCase(), localByKey.get(nodeKey)!]) + ); + const expectedByKey = new Map(snapshot.expectedFiles.map((file) => [file.nodeKey, file])); + const changeByKey = new Map( + snapshot.changes.flatMap((change) => (change.nodeKey ? [[change.nodeKey, change] as const] : [])) + ); + const remoteByKey = new Map(manifest.nodes.map((entry) => [entry.nodeKey, entry])); + const replacedLocalKeys = new Set(); + const context: PullOperationContext = { + snapshot, + localByKey, + localPaths, + localByPath, + expectedByKey, + changeByKey, + replacedLocalKeys, + recoveringCreateKeys, + }; + const operations = manifest.nodes.flatMap((entry) => { + const operation = this.remoteOperation(entry, context); + return operation ? [operation] : []; + }); + operations.push(...this.deletedOperations(localNodes, remoteByKey, context)); + return operations; + } + + private remoteOperation(entry: WorkspaceManifestNode, context: PullOperationContext): PullOperation | undefined { + const localNode = context.localByKey.get(entry.nodeKey); + if (!localNode) { + return this.missingLocalOperation(entry, context); + } + const localPath = context.localPaths.get(entry.nodeKey); + if (!this.remoteChanged(entry, localNode, localPath, context.snapshot.state)) { + return undefined; + } + return this.changedRemoteOperation(entry, localNode, localPath, context); + } + + private missingLocalOperation(entry: WorkspaceManifestNode, context: PullOperationContext): PullOperation { + const provisional = context.recoveringCreateKeys + ? context.localByPath.get(entry.path.toLowerCase()) + : undefined; + if (context.recoveringCreateKeys && !this.isFolder(entry.metadata) && !provisional) { + const visiblePath = [...context.snapshot.visibleFiles.keys()].find( + (filePath) => filePath.toLowerCase() === entry.path.toLowerCase() + ); + if (visiblePath) { + return { + nodeKey: entry.nodeKey, + path: entry.path, + localPath: visiblePath, + status: "added", + entry, + verifyConvergence: true, + downloadBody: true, + }; + } + } + const provisionalChange = provisional ? context.changeByKey.get(provisional.nodeKey) : undefined; + const provisionalPath = provisional ? context.localPaths.get(provisional.nodeKey) : undefined; + if ( + !this.isFolder(entry.metadata) && + provisional && + !this.isFolder(provisional) && + provisional.type.toUpperCase() === entry.metadata.type.toUpperCase() && + provisionalChange?.status === "added" && + !context.expectedByKey.get(provisional.nodeKey)?.digest && + provisionalPath + ) { + context.replacedLocalKeys.add(provisional.nodeKey); + return { + nodeKey: entry.nodeKey, + path: entry.path, + localPath: provisionalPath, + status: "added", + entry, + localNode: provisional, + localChange: provisionalChange, + replacedNodeKey: provisional.nodeKey, + verifyConvergence: true, + downloadBody: true, + }; + } + const occupied = !this.isFolder(entry.metadata) && this.visibleAt(context.snapshot, entry.path); + return { + nodeKey: entry.nodeKey, + path: entry.path, + status: "added", + entry, + downloadBody: !this.isFolder(entry.metadata), + conflict: occupied ? `Remote file conflicts with an untracked local path: ${entry.path}` : undefined, + }; + } + + private changedRemoteOperation( + entry: WorkspaceManifestNode, + localNode: WorkspaceNode, + localPath: string | undefined, + context: PullOperationContext + ): PullOperation { + const localChange = context.changeByKey.get(entry.nodeKey); + const operation: PullOperation = { + nodeKey: entry.nodeKey, + path: entry.path, + localPath, + status: localPath && localPath.toLowerCase() !== entry.path.toLowerCase() ? "moved" : "modified", + entry, + localNode, + localChange, + downloadBody: + !this.isFolder(entry.metadata) && + context.snapshot.state.baselineNodeETags[entry.nodeKey] !== entry.eTag, + }; + if (!localChange) { + return operation; + } + if ( + context.recoveringCreateKeys && + localChange.status === "moved" && + localChange.path.toLowerCase() === entry.path.toLowerCase() && + context.snapshot.state.baselineNodeETags[entry.nodeKey] === entry.eTag + ) { + operation.converged = true; + operation.downloadBody = false; + return operation; + } + operation.conflict = `Local and remote changes conflict for node ${entry.nodeKey}.`; + return operation; + } + + private deletedOperations( + localNodes: WorkspaceNode[], + remoteByKey: Map, + context: PullOperationContext + ): PullOperation[] { + return localNodes.flatMap((node) => { + if (remoteByKey.has(node.nodeKey) || context.replacedLocalKeys.has(node.nodeKey)) { + return []; + } + const localPath = context.localPaths.get(node.nodeKey); + if (!localPath) { + return []; + } + const localChange = context.changeByKey.get(node.nodeKey); + return [ + { + nodeKey: node.nodeKey, + path: localPath, + localPath, + status: "deleted", + localNode: node, + localChange, + converged: localChange?.status === "deleted", + conflict: + localChange && localChange.status !== "deleted" + ? `Remote deletion conflicts with local changes for node ${node.nodeKey}.` + : undefined, + }, + ]; + }); + } + + private select(root: string, paths: string[], operations: PullOperation[]): PullOperation[] { + return selectWorkspaceCandidates( + root, + paths, + operations.map((operation) => ({ + value: operation, + paths: [operation.path, operation.localPath].filter((value): value is string => Boolean(value)), + })) + ); + } + + private order(operations: PullOperation[]): PullOperation[] { + const priority = (operation: PullOperation): number => { + if (operation.entry && this.isFolder(operation.entry.metadata) && operation.status !== "deleted") { + return 0; + } + if (operation.localNode && this.isFolder(operation.localNode) && operation.status === "deleted") { + return 2; + } + return 1; + }; + return [...operations].sort((left, right) => { + const leftPriority = priority(left); + const rightPriority = priority(right); + if (leftPriority !== rightPriority) { + return leftPriority - rightPriority; + } + if (leftPriority === 2) { + const depth = right.path.split("/").length - left.path.split("/").length; + if (depth !== 0) { + return depth; + } + } + return left.path.localeCompare(right.path); + }); + } + + private async apply( + root: string, + packageKey: string, + operation: PullOperation, + manifest: WorkspaceManifest, + state: WorkspaceState, + appliedFolderMoves: AppliedFolderMove[] + ): Promise { + if (operation.status === "deleted") { + this.applyDelete(root, operation); + this.removeMetadata(root, operation.nodeKey); + delete state.baselineDigests[operation.nodeKey]; + delete state.baselineNodeETags[operation.nodeKey]; + delete state.moveHints[operation.nodeKey]; + return; + } + const entry = operation.entry!; + if (this.isFolder(entry.metadata)) { + const appliedMove = this.applyFolder(root, operation, entry); + if (appliedMove) { + appliedFolderMoves.push(appliedMove); + } + } + let contentDigest: string | undefined; + if (!this.isFolder(entry.metadata) && !operation.converged) { + contentDigest = await this.applyFile(root, packageKey, operation, entry, appliedFolderMoves); + } + if (operation.replacedNodeKey) { + this.removeMetadata(root, operation.replacedNodeKey); + delete state.baselineDigests[operation.replacedNodeKey]; + delete state.baselineNodeETags[operation.replacedNodeKey]; + delete state.moveHints[operation.replacedNodeKey]; + } + this.writeMetadataWithAncestors(root, entry, manifest); + state.baselineNodeETags[entry.nodeKey] = entry.eTag; + if (contentDigest) { + state.baselineDigests[entry.nodeKey] = contentDigest; + } + delete state.moveHints[entry.nodeKey]; + } + + private async applyFile( + root: string, + packageKey: string, + operation: PullOperation, + entry: WorkspaceManifestNode, + appliedFolderMoves: AppliedFolderMove[] + ): Promise { + const target = this.resolve(root, entry.path); + const source = operation.localPath ? this.resolve(root, operation.localPath) : undefined; + const moved = Boolean(source && source.toLowerCase() !== target.toLowerCase()); + const existingTargetDigest = this.validateMovedTarget( + target, + source, + moved, + operation, + entry, + appliedFolderMoves + ); + if (existingTargetDigest) { + return existingTargetDigest; + } + fs.mkdirSync(path.dirname(target), { recursive: true }); + if (!operation.downloadBody) { + return this.moveWithoutDownload(source, target, moved); + } + const remote = await this.api.readFile(packageKey, entry.path); + if (remote.eTag !== entry.eTag) { + throw new GracefulError(`Remote file changed while applying the manifest: ${entry.path}`); + } + const remoteDigest = this.digestBuffer(remote.body); + this.applyDownloadedFile(source, target, operation, entry, remote.body, remoteDigest); + if (operation.verifyConvergence && moved && source && fs.existsSync(source)) { + fs.renameSync(source, target); + } + if (moved && source && fs.existsSync(source)) { + fs.rmSync(source); + } + return remoteDigest; + } + + private validateMovedTarget( + target: string, + source: string | undefined, + moved: boolean, + operation: PullOperation, + entry: WorkspaceManifestNode, + appliedFolderMoves: AppliedFolderMove[] + ): string | undefined { + if (!moved || !fs.existsSync(target)) { + return undefined; + } + if (!operation.downloadBody && source && !fs.existsSync(source) && fs.lstatSync(target).isFile()) { + return this.digest(target); + } + if ( + !fs.lstatSync(target).isFile() || + !operation.localPath || + !this.coveredByFolderMove(operation.localPath, entry.path, appliedFolderMoves) + ) { + throw new GracefulError(`Remote file move conflicts with an existing local path: ${entry.path}`); + } + return undefined; + } + + private moveWithoutDownload(source: string | undefined, target: string, moved: boolean): string { + if (moved && source && fs.existsSync(source)) { + fs.renameSync(source, target); + } + return this.digest(target); + } + + private applyDownloadedFile( + source: string | undefined, + target: string, + operation: PullOperation, + entry: WorkspaceManifestNode, + body: Buffer, + remoteDigest: string + ): void { + if (!operation.verifyConvergence) { + fs.writeFileSync(target, body); + return; + } + const localDigest = this.localFileDigest(source || target); + if (localDigest !== remoteDigest) { + throw new GracefulError(`Server-created file no longer matches the local workspace: ${entry.path}`); + } + } + + private localFileDigest(source: string | undefined): string | undefined { + return source && fs.existsSync(source) && fs.lstatSync(source).isFile() ? this.digest(source) : undefined; + } + + private coveredByFolderMove( + sourcePath: string, + targetPath: string, + appliedFolderMoves: AppliedFolderMove[] + ): boolean { + const source = sourcePath.toLowerCase(); + const target = targetPath.toLowerCase(); + return appliedFolderMoves.some((move) => { + const sourceRoot = move.sourcePath.toLowerCase(); + if (!source.startsWith(`${sourceRoot}/`)) { + return false; + } + const relative = source.slice(sourceRoot.length); + return target === `${move.targetPath.toLowerCase()}${relative}`; + }); + } + + private applyFolder( + root: string, + operation: PullOperation, + entry: WorkspaceManifestNode + ): AppliedFolderMove | undefined { + const target = this.resolve(root, entry.path); + const source = operation.localPath ? this.resolve(root, operation.localPath) : undefined; + const moved = Boolean(source && source.toLowerCase() !== target.toLowerCase()); + if (!moved) { + if (fs.existsSync(target) && !fs.lstatSync(target).isDirectory()) { + throw new GracefulError(`Remote folder conflicts with an existing local path: ${entry.path}`); + } + fs.mkdirSync(target, { recursive: true }); + return undefined; + } + const appliedMove = { sourcePath: operation.localPath!, targetPath: entry.path }; + if (fs.existsSync(target)) { + if (source && !fs.existsSync(source) && fs.lstatSync(target).isDirectory()) { + return appliedMove; + } + throw new GracefulError(`Remote folder move conflicts with an existing local path: ${entry.path}`); + } + if (!source || !fs.existsSync(source)) { + fs.mkdirSync(target, { recursive: true }); + return appliedMove; + } + if (!fs.lstatSync(source).isDirectory()) { + throw new GracefulError(`Remote folder move source is not a directory: ${operation.localPath}`); + } + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.renameSync(source, target); + return appliedMove; + } + + private applyDelete(root: string, operation: PullOperation): void { + if (operation.localNode && this.isFolder(operation.localNode)) { + const metadataDirectory = path.join(root, ".package", "nodes"); + const hasChildren = fs + .readdirSync(metadataDirectory) + .filter((file) => file.endsWith(".json") && file !== `${operation.nodeKey}.json`) + .some((file) => { + const metadata = JSON.parse( + fs.readFileSync(path.join(metadataDirectory, file), "utf-8") + ) as WorkspaceNodeMetadata; + return metadata.parentNodeKey === operation.nodeKey; + }); + if (hasChildren) { + throw new GracefulError(`Remote folder deletion still has local children: ${operation.path}`); + } + const visible = this.resolve(root, operation.localPath || operation.path); + if (fs.existsSync(visible)) { + fs.rmdirSync(visible); + } + return; + } + const visible = this.resolve(root, operation.localPath || operation.path); + if (fs.existsSync(visible)) { + fs.rmSync(visible); + } + } + + private writeMetadata(root: string, entry: WorkspaceManifestNode): void { + const metadataDirectory = path.join(root, ".package", "nodes"); + fs.mkdirSync(metadataDirectory, { recursive: true }); + fs.writeFileSync( + path.join(metadataDirectory, `${entry.nodeKey}.json`), + `${JSON.stringify(entry.metadata, null, 2)}\n` + ); + } + + private writeMetadataWithAncestors( + root: string, + entry: WorkspaceManifestNode, + manifest: WorkspaceManifest, + visited: Set = new Set() + ): void { + if (!visited.add(entry.nodeKey)) { + throw new GracefulError(`Circular workspace manifest hierarchy at ${entry.nodeKey}.`); + } + const parentKey = entry.metadata.parentNodeKey; + if (parentKey) { + const parent = manifest.nodes.find((candidate) => candidate.nodeKey === parentKey); + if (!parent || !this.isFolder(parent.metadata)) { + throw new GracefulError(`Workspace manifest has an invalid parent for node ${entry.nodeKey}.`); + } + this.writeMetadataWithAncestors(root, parent, manifest, visited); + } + this.writeMetadata(root, entry); + visited.delete(entry.nodeKey); + } + + private removeMetadata(root: string, nodeKey: string): void { + fs.rmSync(path.join(root, ".package", "nodes", `${nodeKey}.json`), { force: true }); + } + + private remoteChanged( + entry: WorkspaceManifestNode, + localNode: WorkspaceNode, + localPath: string | undefined, + state: WorkspaceState + ): boolean { + if ( + entry.path.toLowerCase() !== localPath?.toLowerCase() || + !this.sameMetadata(entry.metadata, this.nodeMetadata(localNode)) + ) { + return true; + } + return entry.eTag !== state.baselineNodeETags[entry.nodeKey]; + } + + private localPaths(nodes: WorkspaceNode[], packageKey: string): Map { + return projectWorkspacePaths(nodes, packageKey); + } + + private validateManifest(manifest: WorkspaceManifest): void { + if ( + !manifest || + !Array.isArray(manifest.nodes) || + Object.keys(manifest as unknown as Record).some((field) => field !== "nodes") + ) { + throw new GracefulError("Unsupported workspace manifest."); + } + const keys = new Set(); + const paths = new Set(); + manifest.nodes.forEach((entry) => { + const foldedPath = entry.path?.toLowerCase(); + const metadata = entry.metadata as unknown as Record; + const fields = entry as unknown as Record; + const folder = this.isFolder(entry.metadata); + if ( + !entry.nodeKey || + !this.validManifestPath(entry.path) || + !entry.metadata?.name || + !entry.metadata?.type || + "key" in metadata || + "nodeKey" in metadata || + FORBIDDEN_METADATA_FIELDS.some((field) => field in metadata) || + this.hasLegacyFilesystemName(entry.metadata) || + ["body", "kind", "assetType", "size", "contentDigest"].some((field) => field in fields) || + keys.has(entry.nodeKey) || + paths.has(foldedPath) || + !entry.eTag || + (folder ? entry.mediaType !== undefined && entry.mediaType !== null : !entry.mediaType) + ) { + throw new GracefulError("Unsupported workspace manifest."); + } + keys.add(entry.nodeKey); + paths.add(foldedPath); + }); + const byKey = new Map(manifest.nodes.map((entry) => [entry.nodeKey, entry])); + const resolved = new Set(); + const resolve = (entry: WorkspaceManifestNode, resolving: Set): void => { + if (resolved.has(entry.nodeKey)) { + return; + } + if (resolving.has(entry.nodeKey)) { + throw new GracefulError("Workspace manifest contains a circular hierarchy."); + } + resolving.add(entry.nodeKey); + const parentKey = entry.metadata.parentNodeKey; + if (parentKey) { + const parent = byKey.get(parentKey); + if (!parent || !this.isFolder(parent.metadata)) { + throw new GracefulError("Workspace manifest contains an invalid parent relationship."); + } + resolve(parent, resolving); + } + resolving.delete(entry.nodeKey); + resolved.add(entry.nodeKey); + }; + manifest.nodes.forEach((entry) => { + resolve(entry, new Set()); + }); + const projectedPaths = projectWorkspacePaths( + manifest.nodes.map((entry) => ({ nodeKey: entry.nodeKey, ...entry.metadata })) + ); + if (manifest.nodes.some((entry) => projectedPaths.get(entry.nodeKey) !== entry.path)) { + throw new GracefulError("Workspace manifest contains a path that does not match Node metadata."); + } + } + + private validManifestPath(value: string | undefined): boolean { + if (!value || value.includes("\\") || path.posix.isAbsolute(value)) { + return false; + } + const segments = value.split("/"); + const first = segments[0].toLowerCase(); + return ( + first !== ".package" && + first !== ".git" && + segments.every((segment) => Boolean(segment) && segment !== "." && segment !== "..") + ); + } + + private visibleAt(snapshot: WorkspaceSnapshot, filePath: string): boolean { + return [...snapshot.visibleFiles.keys()].some((value) => value.toLowerCase() === filePath.toLowerCase()); + } + + private sameMetadata(left: WorkspaceNodeMetadata, right: WorkspaceNodeMetadata): boolean { + return JSON.stringify(this.sorted(left)) === JSON.stringify(this.sorted(right)); + } + + private nodeMetadata(node: WorkspaceNode): WorkspaceNodeMetadata { + const { nodeKey: _nodeKey, ...metadata } = node; + return metadata; + } + + private sorted(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => this.sorted(item)); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, this.sorted(item)]) + ); + } + return value; + } + + private hasLegacyFilesystemName(node: WorkspaceNodeMetadata): boolean { + const fields = node as unknown as Record; + return ( + "filesystemName" in fields || + Boolean(node.metadata && "filesystemName" in node.metadata) || + Boolean(node.additionalFields && "filesystemName" in node.additionalFields) + ); + } + + private resolve(root: string, filePath: string): string { + const resolved = path.resolve(root, filePath); + if (resolved === root || !resolved.startsWith(`${root}${path.sep}`)) { + throw new GracefulError(`Invalid workspace path: ${filePath}`); + } + return resolved; + } + + private digest(file: string): string { + return this.digestBuffer(fs.readFileSync(file)); + } + + private digestBuffer(content: Buffer): string { + return `sha256:${createHash("sha256").update(content).digest("hex")}`; + } + + private isFolder(node: WorkspaceNodeMetadata): boolean { + return node.type.toUpperCase() === "FOLDER"; + } +} diff --git a/src/commands/workspace/workspace-push.service.ts b/src/commands/workspace/workspace-push.service.ts new file mode 100644 index 00000000..0512b393 --- /dev/null +++ b/src/commands/workspace/workspace-push.service.ts @@ -0,0 +1,262 @@ +import { createHash } from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { GracefulError } from "../../core/utils/logger"; +import { WorkspaceApi } from "./workspace-api"; +import { selectWorkspaceCandidates } from "./workspace-path-selection"; +import { + ClassifiedWorkspaceChange, + ExpectedWorkspaceFile, + WorkspacePushOutcome, + WorkspaceSnapshot, +} from "./workspace.models"; + +interface WorkspacePushOperationError extends Error { + remoteChanged: boolean; +} + +function errorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "string") { + return error; + } + try { + return JSON.stringify(error) || "Unknown workspace push failure."; + } catch { + return "Unknown workspace push failure."; + } +} + +function operationError(error: unknown, remoteChanged: boolean): WorkspacePushOperationError { + return Object.assign(new Error(errorMessage(error)), { remoteChanged }); +} + +function isOperationError(error: unknown): error is WorkspacePushOperationError { + return error instanceof Error && "remoteChanged" in error && typeof error.remoteChanged === "boolean"; +} + +export class WorkspacePushService { + constructor(private readonly api: WorkspaceApi) {} + + public async push( + root: string, + snapshot: WorkspaceSnapshot, + paths: string[], + assetType?: string + ): Promise { + const changes = this.order(this.select(root, snapshot, paths)); + const outcomes: WorkspacePushOutcome[] = []; + for (const change of changes) { + try { + const result = await this.pushChange(root, snapshot, change, assetType); + outcomes.push({ + ...change, + nodeKey: result?.nodeKey || change.nodeKey, + localNodeKey: result?.nodeKey !== change.nodeKey ? change.nodeKey : undefined, + success: true, + remoteChanged: true, + }); + } catch (error) { + outcomes.push({ + ...change, + success: false, + remoteChanged: isOperationError(error) && error.remoteChanged, + error: errorMessage(error), + }); + } + } + return outcomes; + } + + private select(root: string, snapshot: WorkspaceSnapshot, paths: string[]): ClassifiedWorkspaceChange[] { + const expectedByNodeKey = new Map(snapshot.expectedFiles.map(file => [file.nodeKey, file])); + const candidates = snapshot.changes.map(change => ({ + value: change, + paths: [change.path, change.nodeKey ? expectedByNodeKey.get(change.nodeKey)?.path : undefined].filter( + (value): value is string => Boolean(value) + ), + })); + return selectWorkspaceCandidates(root, paths, candidates); + } + + private order(changes: ClassifiedWorkspaceChange[]): ClassifiedWorkspaceChange[] { + const priority = (change: ClassifiedWorkspaceChange): number => { + if (change.kind === "folder" && change.status === "added") { + return 0; + } + switch (change.status) { + case "deleted": + return 1; + case "moved": + case "moved, modified": + return 2; + case "modified": + return 3; + case "added": + return 4; + case "unresolved": + return 5; + } + }; + return [...changes].sort((left, right) => { + const difference = priority(left) - priority(right); + if (difference !== 0) { + return difference; + } + if (left.kind === "folder" && right.kind === "folder" && left.status === "added") { + const depth = left.path.split("/").length - right.path.split("/").length; + if (depth !== 0) { + return depth; + } + } + return left.path.localeCompare(right.path); + }); + } + + private async pushChange( + root: string, + snapshot: WorkspaceSnapshot, + change: ClassifiedWorkspaceChange, + requestedAssetType?: string + ): Promise { + if (change.kind === "folder") { + if (change.status === "added") { + return this.api.createFolder(snapshot.packageKey, change.path); + } + throw new GracefulError("Incremental folder deletion or relocation is not supported."); + } + if (change.status === "unresolved") { + throw new GracefulError("File identity is unresolved. Record the intended move before pushing."); + } + const expected = change.nodeKey + ? snapshot.expectedFiles.find(file => file.nodeKey === change.nodeKey) + : undefined; + switch (change.status) { + case "added": + return this.api.putFile( + snapshot.packageKey, + change.path, + this.assetType(change, expected, requestedAssetType), + this.content(root, change.path), + this.contentType(change.path), + { "If-None-Match": "*" } + ); + case "modified": { + const eTag = await this.currentETag(snapshot.packageKey, this.requireExpected(expected), change.path); + return this.api.putFile( + snapshot.packageKey, + change.path, + this.requireExpected(expected).assetType, + this.content(root, change.path), + this.contentType(change.path), + { "If-Match": eTag } + ); + } + case "moved": { + const tracked = this.requireExpected(expected); + this.requireParentOnlyMove(tracked.path, change.path); + const eTag = await this.currentETag(snapshot.packageKey, tracked, tracked.path); + return this.api.moveFile(snapshot.packageKey, tracked.path, change.path, eTag); + } + case "moved, modified": { + const tracked = this.requireExpected(expected); + this.requireParentOnlyMove(tracked.path, change.path); + let eTag = await this.currentETag(snapshot.packageKey, tracked, tracked.path); + let moved = false; + if (tracked.path !== change.path) { + eTag = (await this.api.moveFile(snapshot.packageKey, tracked.path, change.path, eTag)).eTag; + moved = true; + } + try { + return await this.api.putFile( + snapshot.packageKey, + change.path, + tracked.assetType, + this.content(root, change.path), + this.contentType(change.path), + { "If-Match": eTag } + ); + } catch (error) { + throw operationError(error, moved); + } + } + case "deleted": { + const tracked = this.requireExpected(expected); + const eTag = await this.currentETag(snapshot.packageKey, tracked, tracked.path); + await this.api.deleteFile(snapshot.packageKey, tracked.path, eTag); + return; + } + } + } + + private assetType( + change: ClassifiedWorkspaceChange, + expected: ExpectedWorkspaceFile | undefined, + requestedAssetType: string | undefined + ): string { + if (expected) { + if (requestedAssetType && requestedAssetType.toLowerCase() !== expected.assetType.toLowerCase()) { + throw new GracefulError( + `Requested Asset Type does not match workspace metadata for ${change.path}: ${expected.assetType}` + ); + } + return expected.assetType; + } + if (!requestedAssetType) { + throw new GracefulError(`Asset Type is required for a new file: ${change.path}. Use --asset-type.`); + } + return requestedAssetType; + } + + private async currentETag(packageKey: string, expected: ExpectedWorkspaceFile, filePath: string): Promise { + if (!expected.digest) { + throw new GracefulError(`Missing synchronization digest: ${filePath}`); + } + const current = await this.api.readFile(packageKey, filePath); + if (this.digest(current.body) !== expected.digest) { + throw new GracefulError(`Remote file changed after the workspace was synchronized: ${filePath}`); + } + return current.eTag; + } + + private requireExpected(expected: ExpectedWorkspaceFile | undefined): ExpectedWorkspaceFile { + if (!expected) { + throw new GracefulError("Tracked file metadata is missing."); + } + return expected; + } + + private requireParentOnlyMove(source: string, target: string): void { + if (path.posix.basename(source) !== path.posix.basename(target)) { + throw new GracefulError("Filename-only rename is not supported; move the Node to another parent."); + } + } + + private content(root: string, filePath: string): Buffer { + const absolute = path.resolve(root, filePath); + if (!fs.existsSync(absolute) || !fs.lstatSync(absolute).isFile()) { + throw new GracefulError(`Workspace file is missing: ${filePath}`); + } + return fs.readFileSync(absolute); + } + + private contentType(filePath: string): string { + switch (path.posix.extname(filePath).toLowerCase()) { + case ".md": + return "text/markdown"; + case ".html": + case ".htm": + return "text/html"; + case ".json": + return "application/json"; + default: + return "application/octet-stream"; + } + } + + private digest(content: Buffer): string { + return `sha256:${createHash("sha256").update(content).digest("hex")}`; + } +} diff --git a/src/commands/workspace/workspace.models.ts b/src/commands/workspace/workspace.models.ts new file mode 100644 index 00000000..0751d308 --- /dev/null +++ b/src/commands/workspace/workspace.models.ts @@ -0,0 +1,135 @@ +export interface WorkspaceState { + schemaVersion: number; + activePackageKey: string; + activeBranch: string; + manifestETag?: string; + baselineDigests: Record; + baselineNodeETags: Record; + moveHints: Record; + git?: WorkspaceGitObservation; + refreshRequired?: boolean; +} + +export interface WorkspaceMoveHint { + sourcePath: string; + targetPath: string; +} + +export interface WorkspacePackageIdentity { + schemaVersion: number; + projectKey: string; +} + +export interface WorkspaceGitObservation { + branch: string; + head: string; +} + +export interface WorkspaceCloneOptions { + branch?: string; +} + +export interface WorkspaceCheckoutOptions { + create?: boolean; + discard?: boolean; + linkGit?: boolean; +} + +export interface WorkspaceBranch { + projectKey: string; + branchKey: string; + packageKey: string; +} + +export interface WorkspaceNodeMetadata { + name: string; + type: string; + parentNodeKey?: string | null; + schemaVersion?: number; + dependenciesConfiguration?: unknown; + metadata?: Record; + additionalFields?: Record; +} + +export interface WorkspaceNode extends WorkspaceNodeMetadata { + nodeKey: string; +} + +export interface ExpectedWorkspaceFile { + nodeKey: string; + path: string; + assetType: string; + digest?: string; +} + +export interface ExpectedWorkspaceFolder { + nodeKey: string; + path: string; +} + +export type WorkspaceChangeStatus = "added" | "deleted" | "modified" | "moved" | "moved, modified" | "unresolved"; + +export interface WorkspaceChange { + path: string; + status: WorkspaceChangeStatus; +} + +export interface ClassifiedWorkspaceChange extends WorkspaceChange { + nodeKey?: string; + kind?: "file" | "folder"; +} + +export interface WorkspaceSnapshot { + projectKey: string; + packageKey: string; + state: WorkspaceState; + expectedFiles: ExpectedWorkspaceFile[]; + expectedFolders: ExpectedWorkspaceFolder[]; + visibleFiles: Map; + visibleFolders: Set; + changes: ClassifiedWorkspaceChange[]; +} + +export interface WorkspacePushOptions { + assetType?: string; +} + +export interface WorkspaceManifest { + nodes: WorkspaceManifestNode[]; +} + +export interface WorkspaceManifestNode { + nodeKey: string; + path: string; + mediaType?: string | null; + eTag: string; + metadata: WorkspaceNodeMetadata; +} + +export type WorkspacePullStatus = "added" | "deleted" | "modified" | "moved" | "conflict"; + +export interface WorkspacePullOutcome { + path: string; + status: WorkspacePullStatus; + nodeKey: string; + success: boolean; + error?: string; +} + +export interface WorkspacePushOutcome { + path: string; + status: WorkspaceChangeStatus; + nodeKey?: string; + localNodeKey?: string; + kind?: "file" | "folder"; + success: boolean; + remoteChanged?: boolean; + error?: string; +} + +export interface NodeFileWriteResponse { + path: string; + nodeKey: string; + assetType: string; + eTag: string; +} diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts new file mode 100644 index 00000000..58c99086 --- /dev/null +++ b/src/commands/workspace/workspace.service.ts @@ -0,0 +1,1111 @@ +import { createHash } from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { Context } from "../../core/command/cli-context"; +import { GracefulError, logger } from "../../core/utils/logger"; +import { BranchUtils } from "../../core/utils/branches"; +import { WorkspaceApi } from "./workspace-api"; +import { classifyWorkspaceChanges } from "./workspace-change-classifier"; +import { WorkspaceGitService } from "./workspace-git.service"; +import { projectedLeafAfterMove, projectWorkspacePaths } from "./workspace-path-projector"; +import { WorkspacePullService } from "./workspace-pull.service"; +import { WorkspacePushService } from "./workspace-push.service"; +import { + ClassifiedWorkspaceChange, + ExpectedWorkspaceFile, + ExpectedWorkspaceFolder, + WorkspaceChange, + WorkspaceCheckoutOptions, + WorkspaceCloneOptions, + WorkspaceGitObservation, + WorkspaceManifest, + WorkspaceMoveHint, + WorkspaceNode, + WorkspaceNodeMetadata, + WorkspacePackageIdentity, + WorkspacePushOptions, + WorkspacePushOutcome, + WorkspaceSnapshot, + WorkspaceState, +} from "./workspace.models"; + +interface VisibleWorkspaceTree { + files: Map; + folders: Set; + emptyFolders: Set; +} + +const NON_SEMANTIC_NODE_FIELDS = [ + "configuration", + "invalidConfiguration", + "invalidContent", + "id", + "workingDraftId", + "activatedDraftId", + "stagingDraftId", + "prodDraftId", + "archivedDraftId", + "packageNodeId", + "packageKey", + "packageNodeKey", + "branchKey", + "spaceId", + "creationDate", + "changeDate", + "deletedAt", + "archivedAt", + "createdBy", + "updatedBy", + "deletedBy", + "archivedBy", + "optimisticLockVersion", + "revision", + "serverRevision", + "lastModified", + "lastModifiedAt", + "lastModifiedBy", + "filesystemName", +]; + +export { WorkspaceChange } from "./workspace.models"; + +export class WorkspaceService { + private readonly api: WorkspaceApi; + + constructor( + context: Context, + private readonly gitService: WorkspaceGitService = new WorkspaceGitService() + ) { + this.api = new WorkspaceApi(context); + } + + public async clone(projectKey: string, directory?: string, options: WorkspaceCloneOptions = {}): Promise { + const branch = this.branch(options.branch); + const packageKey = this.packageKey(projectKey, branch); + const target = path.resolve(process.cwd(), directory || projectKey); + if (fs.existsSync(target)) { + throw new GracefulError(`Destination already exists: ${target}`); + } + const remote = await this.remoteManifest(packageKey); + const parent = path.dirname(target); + let staging: string | undefined; + try { + fs.mkdirSync(parent, { recursive: true }); + staging = fs.mkdtempSync(path.join(parent, ".package-clone-")); + await this.materializeWorkspace(staging, projectKey, packageKey, branch, remote.manifest, remote.eTag); + if (fs.existsSync(target)) { + throw new GracefulError(`Destination already exists: ${target}`); + } + fs.renameSync(staging, target); + staging = undefined; + } catch (error) { + if (staging) { + fs.rmSync(staging, { recursive: true, force: true }); + } + throw error; + } + logger.info(`Cloned ${packageKey} to ${target}`); + } + + public async checkout(branchValue: string, options: WorkspaceCheckoutOptions = {}): Promise { + const root = this.root(); + const projectKey = this.packageIdentity(root).projectKey; + const branch = this.branch(branchValue); + const hasLocalState = fs.existsSync(this.statePath(root)); + const current = hasLocalState ? this.state(root) : undefined; + const packageKey = options.create + ? await this.createWorkspaceBranch(current, projectKey, branch) + : this.packageKey(projectKey, branch); + if (options.linkGit) { + const observation = await this.linkCurrentGitBranch(root, projectKey, branch); + const base: WorkspaceState = current || { + schemaVersion: 1, + activePackageKey: packageKey, + activeBranch: branch, + baselineDigests: {}, + baselineNodeETags: {}, + moveHints: {}, + }; + await this.hydrateGitBaseline(root, { ...base, git: observation }, packageKey, branch, observation); + logger.info(`${options.create ? "Created and selected" : "Selected"} ${packageKey}.`); + return; + } + if (options.create) { + await this.hydrateRemoteBaseline(root, current!, packageKey, branch); + logger.info(`Created and selected ${packageKey}.`); + return; + } + if (!options.discard && (!current || this.snapshot(root).changes.length !== 0)) { + throw new GracefulError("Workspace has local changes. Use --discard or push them before checkout."); + } + const remote = await this.remoteManifest(packageKey); + const temporary = fs.mkdtempSync(path.join(path.dirname(root), ".package-checkout-")); + try { + await this.materializeWorkspace(temporary, projectKey, packageKey, branch, remote.manifest, remote.eTag); + this.replaceWorkspaceContents(root, temporary); + } finally { + fs.rmSync(temporary, { recursive: true, force: true }); + } + logger.info(`${options.create ? "Created and selected" : "Selected"} ${packageKey}.`); + } + + public async pull(paths: string[] = []): Promise { + const root = this.root(); + const projectKey = this.packageIdentity(root).projectKey; + const hasLocalState = fs.existsSync(this.statePath(root)); + if (hasLocalState) { + const reconciled = await this.synchronizeGitTarget(root, false); + if (reconciled) { + logger.info(`Pulled ${this.state(root).activePackageKey}.`); + return; + } + } + const localState = hasLocalState ? this.state(root) : undefined; + const recoveringCreateKeys = Boolean(localState?.refreshRequired); + const { packageKey, restoredObservation } = await this.pullTarget(root, projectKey, localState); + const remote = await this.api.manifest(packageKey, localState?.manifestETag); + if (remote.notModified) { + logger.info(`Pulled ${packageKey}; the remote workspace is unchanged.`); + return; + } + const manifest = remote.manifest!; + const pullService = new WorkspacePullService(this.api); + if (!localState) { + await this.hydrateInitialPull( + root, + projectKey, + packageKey, + restoredObservation, + manifest, + remote.eTag, + pullService + ); + return; + } + const result = await pullService.pull( + root, + this.snapshot(root, recoveringCreateKeys), + this.nodes(root), + paths, + manifest, + recoveringCreateKeys + ); + const failed = result.outcomes.filter((outcome) => !outcome.success); + if (recoveringCreateKeys && (paths.length > 0 || failed.length > 0)) { + result.state.refreshRequired = true; + } + if (paths.length === 0 && failed.length === 0) { + result.state.manifestETag = remote.eTag; + } else { + delete result.state.manifestETag; + } + this.writeState(root, result.state); + result.outcomes.forEach((outcome) => + logger.info( + `${outcome.success ? "succeeded" : "failed"}: ${outcome.status} ${outcome.path}` + + (outcome.error ? ` (${outcome.error})` : "") + ) + ); + if (failed.length > 0) { + throw new GracefulError(`Workspace pull failed for ${failed.length} node(s).`); + } + logger.info(`Pulled ${packageKey}.`); + } + + private async hydrateInitialPull( + root: string, + projectKey: string, + packageKey: string, + restoredObservation: WorkspaceGitObservation | undefined, + manifest: WorkspaceManifest, + manifestETag: string, + pullService: WorkspacePullService + ): Promise { + if (manifest.nodes.length === 0) { + fs.mkdirSync(path.join(root, ".package", "nodes"), { recursive: true }); + } + const initial: WorkspaceState = { + schemaVersion: 1, + activePackageKey: packageKey, + activeBranch: this.branchFromPackageKey(projectKey, packageKey), + baselineDigests: {}, + baselineNodeETags: {}, + moveHints: {}, + }; + if (restoredObservation) { + initial.git = restoredObservation; + } + const hydrated = await pullService.hydrateRemoteBaseline(initial, packageKey, initial.activeBranch, manifest); + hydrated.manifestETag = manifestETag; + this.writeState(root, this.withRemotePathHints(root, hydrated, manifest)); + logger.info(`Pulled ${packageKey}.`); + } + + public status(directory?: string): WorkspaceChange[] { + const changes = this.snapshot(this.root(directory)).changes.map(({ path: filePath, status }) => ({ + path: filePath, + status, + })); + if (changes.length === 0) { + logger.info("Workspace is clean."); + } else { + changes.forEach((change) => logger.info(`${change.status}: ${change.path}`)); + } + return changes; + } + + public async statusWithGit(directory?: string): Promise { + const root = this.root(directory); + await this.synchronizeGitTarget(root, false); + return this.status(root); + } + + public async push(paths: string[] = [], options: WorkspacePushOptions = {}): Promise { + const root = this.root(); + await this.synchronizeGitTarget(root, true); + const snapshot = this.snapshot(root); + const invalidatedBeforePush: WorkspaceState = { ...snapshot.state }; + delete invalidatedBeforePush.manifestETag; + this.writeState(root, invalidatedBeforePush); + const outcomes: WorkspacePushOutcome[] = await new WorkspacePushService(this.api).push( + root, + snapshot, + paths, + options.assetType + ); + outcomes.forEach((outcome) => + logger.info( + `${outcome.success ? "succeeded" : "failed"}: ${outcome.status} ${outcome.path}` + + (outcome.error ? ` (${outcome.error})` : "") + ) + ); + const failed = outcomes.filter((outcome) => !outcome.success); + const remoteChanged = outcomes.some((outcome) => outcome.remoteChanged); + const retainedHints = this.retainedMoveHints(snapshot.state.moveHints, outcomes); + const invalidatedState: WorkspaceState = { + ...snapshot.state, + moveHints: retainedHints, + }; + delete invalidatedState.manifestETag; + delete invalidatedState.refreshRequired; + if (!remoteChanged) { + this.writeState(root, invalidatedState); + if (failed.length > 0) { + throw new GracefulError(`Workspace push failed for ${failed.length} path(s).`); + } + logger.info(paths.length > 0 ? "Selected paths have no changes." : "Workspace is clean."); + return; + } + try { + const remote = await this.api.manifest(snapshot.packageKey); + this.writeState( + root, + new WorkspacePullService(this.api).applyPushResults(root, invalidatedState, remote.manifest!, outcomes) + ); + } catch (error) { + this.writeState(root, { ...invalidatedState, refreshRequired: true }); + const failure = new GracefulError( + "Workspace changes reached the server, but local synchronization state could not be refreshed. Run workspace pull before retrying." + ); + failure.cause = error; + throw failure; + } + if (failed.length > 0) { + throw new GracefulError(`Workspace push failed for ${failed.length} path(s).`); + } + logger.info(`Pushed ${snapshot.packageKey}.`); + } + + public move(source: string, target: string, recordOnly: boolean = false): void { + const root = this.root(); + const sourcePath = this.relativeVisiblePath(root, source); + const targetPath = this.relativeVisiblePath(root, target); + const snapshot = this.snapshot(root); + const tracked = this.trackedFile(snapshot, sourcePath); + if (!tracked) { + throw new GracefulError(`Tracked file not found: ${sourcePath}`); + } + const targetOwned = snapshot.expectedFiles.some( + (file) => file.nodeKey !== tracked.nodeKey && file.path.toLowerCase() === targetPath.toLowerCase() + ); + if (targetOwned) { + throw new GracefulError(`Target path is already tracked: ${targetPath}`); + } + this.validateParentMove(root, snapshot, tracked, targetPath); + const absoluteSource = this.resolveVisiblePath(root, sourcePath); + const absoluteTarget = this.resolveVisiblePath(root, targetPath); + if (recordOnly) { + if (!fs.existsSync(absoluteTarget)) { + throw new GracefulError(`Moved file not found: ${targetPath}`); + } + snapshot.state.moveHints[tracked.nodeKey] = targetPath; + this.writeState(root, snapshot.state); + logger.info(`Recorded move: ${sourcePath} -> ${targetPath}`); + return; + } + if (!fs.existsSync(absoluteSource)) { + throw new GracefulError(`Tracked file not found: ${sourcePath}`); + } + if (fs.existsSync(absoluteTarget) && !this.sameFile(absoluteSource, absoluteTarget)) { + throw new GracefulError(`Target already exists: ${targetPath}`); + } + fs.mkdirSync(path.dirname(absoluteTarget), { recursive: true }); + fs.renameSync(absoluteSource, absoluteTarget); + snapshot.state.moveHints[tracked.nodeKey] = targetPath; + this.writeState(root, snapshot.state); + logger.info(`Moved: ${sourcePath} -> ${targetPath}`); + } + + private snapshot(root: string, allowRefreshRequired: boolean = false): WorkspaceSnapshot { + const projectKey = this.packageIdentity(root).projectKey; + const state = this.state(root); + if (state.refreshRequired && !allowRefreshRequired) { + throw new GracefulError("Workspace synchronization state needs refresh. Run workspace pull."); + } + if (BranchUtils.extractProjectKey(state.activePackageKey) !== projectKey) { + throw new GracefulError("Active Pacman package does not belong to this workspace project."); + } + const expectedFiles = this.expectedFiles(root, state, state.activePackageKey); + const expectedFolders = this.expectedFolders(root, state.activePackageKey); + const visible = this.visibleTree(root); + const fileChanges = classifyWorkspaceChanges( + expectedFiles, + visible.files, + this.moveHintTargets(state.moveHints) + ); + const folderChanges = this.classifyFolderChanges(expectedFolders, visible.folders, visible.emptyFolders); + return { + projectKey, + packageKey: state.activePackageKey, + state, + expectedFiles, + expectedFolders, + visibleFiles: visible.files, + visibleFolders: visible.folders, + changes: [...fileChanges, ...folderChanges].sort( + (left, right) => left.path.localeCompare(right.path) || left.status.localeCompare(right.status) + ), + }; + } + + private expectedFolders(root: string, packageKey: string): ExpectedWorkspaceFolder[] { + const nodes = this.nodes(root); + const pathByKey = projectWorkspacePaths(nodes, packageKey); + return nodes + .filter((node) => this.isFolder(node)) + .map((node) => ({ nodeKey: node.nodeKey, path: pathByKey.get(node.nodeKey)! })) + .sort((left, right) => left.path.localeCompare(right.path)); + } + + private classifyFolderChanges( + expectedFolders: ExpectedWorkspaceFolder[], + visibleFolders: Set, + emptyFolders: Set + ): ClassifiedWorkspaceChange[] { + const expectedByPath = new Map(expectedFolders.map((folder) => [folder.path.toLowerCase(), folder])); + const visibleByPath = new Map([...visibleFolders].map((folderPath) => [folderPath.toLowerCase(), folderPath])); + const missing = expectedFolders.filter((folder) => !visibleByPath.has(folder.path.toLowerCase())); + const additions = [...emptyFolders].filter((folderPath) => !expectedByPath.has(folderPath.toLowerCase())); + return [ + ...missing.map((folder) => ({ + nodeKey: folder.nodeKey, + path: folder.path, + status: "unresolved" as const, + kind: "folder" as const, + })), + ...additions.map((folderPath) => ({ + path: folderPath, + status: "added" as const, + kind: "folder" as const, + })), + ]; + } + + private expectedFiles(root: string, state: WorkspaceState, packageKey: string): ExpectedWorkspaceFile[] { + const nodes = this.nodes(root); + const byKey = new Map(nodes.map((node) => [node.nodeKey, node])); + const pathByKey = projectWorkspacePaths(nodes, packageKey); + const expected = nodes + .filter((node) => !this.isFolder(node)) + .map((node) => { + const baseline = state.baselineDigests[node.nodeKey]; + if (baseline && !/^sha256:[0-9a-f]{64}$/.test(baseline)) { + throw new GracefulError(`Invalid baseline digest for node ${node.nodeKey}.`); + } + const metadataPath = pathByKey.get(node.nodeKey)!; + const hint = state.moveHints[node.nodeKey]; + const sourcePath = this.isStructuredMoveHint(hint) + ? this.validateRelative(hint.sourcePath) + : metadataPath; + return { nodeKey: node.nodeKey, path: sourcePath, assetType: node.type, digest: baseline }; + }); + const foldedPaths = new Set(); + expected.forEach((file) => { + const foldedPath = file.path.toLowerCase(); + if (foldedPaths.has(foldedPath)) { + throw new GracefulError(`Duplicate workspace path in node metadata: ${file.path}`); + } + foldedPaths.add(foldedPath); + }); + Object.entries(state.moveHints).forEach(([nodeKey, targetPath]) => { + if (!byKey.has(nodeKey)) { + throw new GracefulError(`Move hint references unknown node ${nodeKey}.`); + } + if (this.isStructuredMoveHint(targetPath)) { + state.moveHints[nodeKey] = { + sourcePath: this.validateRelative(targetPath.sourcePath), + targetPath: this.validateRelative(targetPath.targetPath), + }; + } else { + state.moveHints[nodeKey] = this.validateRelative(targetPath); + } + }); + return expected.sort((left, right) => left.path.localeCompare(right.path)); + } + + private nodes(root: string): WorkspaceNode[] { + const directory = path.join(root, ".package", "nodes"); + if (!fs.existsSync(directory)) { + throw new GracefulError("Workspace does not contain .package/nodes metadata."); + } + return fs + .readdirSync(directory, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .sort((left, right) => left.name.localeCompare(right.name)) + .map((entry) => { + const nodeKey = entry.name.slice(0, -".json".length); + const metadata = JSON.parse( + fs.readFileSync(path.join(directory, entry.name), "utf-8") + ) as WorkspaceNodeMetadata; + const fields = metadata as unknown as Record; + if ( + !nodeKey || + !metadata.name || + !metadata.type || + "key" in fields || + "nodeKey" in fields || + NON_SEMANTIC_NODE_FIELDS.some((field) => field in fields) || + this.hasLegacyFilesystemName(metadata) + ) { + throw new GracefulError(`Invalid node metadata file: ${entry.name}`); + } + return { nodeKey, ...metadata }; + }); + } + + private visibleTree(root: string): VisibleWorkspaceTree { + const files = new Map(); + const folders = new Set(); + const foldedPaths = new Set(); + const visit = (directory: string, relativeDirectory: string): void => { + fs.readdirSync(directory, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)) + .forEach((entry) => { + if ( + !relativeDirectory && + (entry.name.toLowerCase() === ".package" || entry.name.toLowerCase() === ".git") + ) { + return; + } + const relative = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name; + const absolute = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + throw new GracefulError(`Workspace contains an unsupported symbolic link: ${relative}`); + } + if (entry.isDirectory()) { + const validated = this.validateRelative(relative); + const foldedPath = validated.toLowerCase(); + if (foldedPaths.has(foldedPath)) { + throw new GracefulError( + `Workspace contains duplicate case-insensitive paths: ${validated}` + ); + } + foldedPaths.add(foldedPath); + folders.add(validated); + visit(absolute, relative); + return; + } + if (!entry.isFile()) { + throw new GracefulError(`Workspace contains an unsupported entry: ${relative}`); + } + const validated = this.validateRelative(relative); + const foldedPath = validated.toLowerCase(); + if (foldedPaths.has(foldedPath)) { + throw new GracefulError(`Workspace contains duplicate case-insensitive paths: ${validated}`); + } + foldedPaths.add(foldedPath); + files.set(validated, this.digest(absolute)); + }); + }; + visit(root, ""); + const nonEmptyParents = new Set(); + [...files.keys(), ...folders].forEach((entryPath) => { + let parent = path.posix.dirname(entryPath); + while (parent !== ".") { + nonEmptyParents.add(parent.toLowerCase()); + parent = path.posix.dirname(parent); + } + }); + return { + files, + folders, + emptyFolders: new Set([...folders].filter((folderPath) => !nonEmptyParents.has(folderPath.toLowerCase()))), + }; + } + + private visibleFiles(root: string): Map { + return this.visibleTree(root).files; + } + + private trackedFile(snapshot: WorkspaceSnapshot, sourcePath: string): ExpectedWorkspaceFile | undefined { + const foldedSourcePath = sourcePath.toLowerCase(); + const expected = snapshot.expectedFiles.find((file) => file.path.toLowerCase() === foldedSourcePath); + if (expected) { + return expected; + } + const hinted = snapshot.expectedFiles.find( + (file) => this.moveHintTarget(snapshot.state.moveHints[file.nodeKey])?.toLowerCase() === foldedSourcePath + ); + if (hinted) { + return hinted; + } + const classified = snapshot.changes.find( + (change) => + change.path.toLowerCase() === foldedSourcePath && + change.nodeKey && + (change.status === "moved" || change.status === "moved, modified") + ); + return classified ? snapshot.expectedFiles.find((file) => file.nodeKey === classified.nodeKey) : undefined; + } + + private validateParentMove( + root: string, + snapshot: WorkspaceSnapshot, + tracked: ExpectedWorkspaceFile, + targetPath: string + ): void { + const currentLeaf = path.posix.basename(tracked.path); + if (path.posix.basename(targetPath) !== currentLeaf) { + throw new GracefulError("Filename-only rename is not supported; move the Node to another parent."); + } + const nodes = this.nodes(root); + const paths = projectWorkspacePaths(nodes, snapshot.packageKey); + const targetParentPath = path.posix.dirname(targetPath) === "." ? "" : path.posix.dirname(targetPath); + const targetParent = nodes.find((node) => this.isFolder(node) && paths.get(node.nodeKey) === targetParentPath); + const targetParentKey = targetParentPath + ? targetParent?.nodeKey || `__workspace_target__:${targetParentPath}` + : undefined; + if (projectedLeafAfterMove(nodes, tracked.nodeKey, targetParentKey, snapshot.packageKey) !== currentLeaf) { + throw new GracefulError("This parent move would change the Node's derived filename."); + } + } + + private hasLegacyFilesystemName(node: WorkspaceNodeMetadata): boolean { + const fields = node as unknown as Record; + return ( + "filesystemName" in fields || + Boolean(node.metadata && "filesystemName" in node.metadata) || + Boolean(node.additionalFields && "filesystemName" in node.additionalFields) + ); + } + + private async remoteManifest(packageKey: string): Promise<{ manifest: WorkspaceManifest; eTag: string }> { + const remote = await this.api.manifest(packageKey); + if (remote.notModified || !remote.manifest) { + throw new GracefulError("Workspace manifest response did not contain a manifest."); + } + return { manifest: remote.manifest, eTag: remote.eTag }; + } + + private async materializeWorkspace( + root: string, + projectKey: string, + packageKey: string, + branch: string, + manifest: WorkspaceManifest, + manifestETag: string + ): Promise { + if (BranchUtils.extractProjectKey(packageKey) !== projectKey) { + throw new GracefulError("Active Pacman package does not belong to this workspace project."); + } + const metadata = path.join(root, ".package"); + fs.mkdirSync(path.join(metadata, "nodes"), { recursive: true }); + fs.writeFileSync( + path.join(metadata, "package.json"), + `${JSON.stringify({ schemaVersion: 1, projectKey }, null, 2)}\n` + ); + fs.writeFileSync(path.join(metadata, ".gitignore"), "local/\n"); + const initial: WorkspaceState = { + schemaVersion: 1, + activePackageKey: packageKey, + activeBranch: branch, + baselineDigests: {}, + baselineNodeETags: {}, + moveHints: {}, + }; + this.writeState(root, initial); + const result = await new WorkspacePullService(this.api).pull(root, this.snapshot(root), [], [], manifest); + const failed = result.outcomes.filter((outcome) => !outcome.success); + if (failed.length > 0) { + throw new GracefulError(`Workspace hydration failed for ${failed.length} node(s).`); + } + result.state.manifestETag = manifestETag; + this.writeState(root, result.state); + } + + private async hydrateRemoteBaseline( + root: string, + state: WorkspaceState, + packageKey: string, + branch: string, + observation?: WorkspaceGitObservation + ): Promise { + const remote = await this.remoteManifest(packageKey); + const manifest = remote.manifest; + const localByKey = new Map(this.nodes(root).map((node) => [node.nodeKey, node])); + manifest.nodes.forEach((entry) => { + const local = localByKey.get(entry.nodeKey); + if (local && this.isFolder(local) !== this.isFolder(entry.metadata)) { + throw new GracefulError(`Node metadata type conflicts with the server for ${entry.nodeKey}.`); + } + }); + const base = { ...state }; + if (observation) { + base.git = observation; + } else { + delete base.git; + } + const hydrated = await new WorkspacePullService(this.api).hydrateRemoteBaseline( + base, + packageKey, + branch, + manifest + ); + hydrated.manifestETag = remote.eTag; + this.writeState(root, this.withRemotePathHints(root, hydrated, manifest)); + } + + private replaceWorkspaceContents(root: string, source: string): void { + if (root === path.parse(root).root) { + throw new GracefulError("Cannot pull into the filesystem root."); + } + const parent = path.dirname(root); + const backup = fs.mkdtempSync(path.join(parent, ".package-pull-backup-")); + const staging = fs.mkdtempSync(path.join(parent, ".package-pull-")); + let preserveBackup = false; + fs.rmSync(staging, { recursive: true }); + try { + fs.cpSync(source, staging, { recursive: true, force: false, errorOnExist: true }); + try { + this.moveEntries(root, backup, new Set([".git"])); + } catch (error) { + try { + this.moveEntries(backup, root); + } catch (restoreError) { + preserveBackup = true; + const failure = new GracefulError(`Pull failed; workspace backup remains at ${backup}.`); + failure.cause = restoreError; + throw failure; + } + throw error; + } + try { + this.moveEntries(staging, root); + } catch (error) { + try { + fs.readdirSync(root) + .filter((entry) => entry !== ".git") + .forEach((entry) => fs.rmSync(path.join(root, entry), { recursive: true, force: true })); + this.moveEntries(backup, root); + } catch (restoreError) { + preserveBackup = true; + const failure = new GracefulError(`Pull failed; workspace backup remains at ${backup}.`); + failure.cause = restoreError; + throw failure; + } + throw error; + } + } finally { + fs.rmSync(staging, { recursive: true, force: true }); + if (!preserveBackup) { + fs.rmSync(backup, { recursive: true, force: true }); + } + } + } + + private moveEntries(source: string, target: string, excluded: Set = new Set()): void { + fs.readdirSync(source) + .filter((entry) => !excluded.has(entry)) + .sort((left, right) => left.localeCompare(right)) + .forEach((entry) => fs.renameSync(path.join(source, entry), path.join(target, entry))); + } + + private sameFile(source: string, target: string): boolean { + const sourceStat = fs.lstatSync(source); + const targetStat = fs.lstatSync(target); + return ( + source.toLowerCase() === target.toLowerCase() && + sourceStat.dev === targetStat.dev && + sourceStat.ino === targetStat.ino + ); + } + + private root(directory?: string): string { + let current = path.resolve(process.cwd(), directory || "."); + while (!fs.existsSync(this.packageIdentityPath(current))) { + const parent = path.dirname(current); + if (parent === current) { + throw new GracefulError("No Pacman workspace found."); + } + current = parent; + } + return current; + } + + private state(root: string): WorkspaceState { + if (!fs.existsSync(this.statePath(root))) { + throw new GracefulError("Workspace synchronization state is missing. Run workspace pull."); + } + const parsed = JSON.parse(fs.readFileSync(this.statePath(root), "utf-8")) as Partial; + if ( + parsed.schemaVersion !== 1 || + !parsed.activePackageKey || + !parsed.activeBranch || + (parsed.manifestETag !== undefined && (typeof parsed.manifestETag !== "string" || !parsed.manifestETag)) || + !parsed.baselineDigests || + typeof parsed.baselineDigests !== "object" || + Array.isArray(parsed.baselineDigests) || + !Object.values(parsed.baselineDigests).every( + (value) => typeof value === "string" && /^sha256:[0-9a-f]{64}$/.test(value) + ) || + (parsed.baselineNodeETags !== undefined && + (typeof parsed.baselineNodeETags !== "object" || + parsed.baselineNodeETags === null || + Array.isArray(parsed.baselineNodeETags) || + !Object.values(parsed.baselineNodeETags).every( + (value) => typeof value === "string" && Boolean(value) + ))) || + (parsed.moveHints !== undefined && + (typeof parsed.moveHints !== "object" || + parsed.moveHints === null || + Array.isArray(parsed.moveHints) || + !Object.values(parsed.moveHints).every( + (value) => typeof value === "string" || this.isStructuredMoveHint(value) + ))) || + (parsed.git !== undefined && + (!parsed.git || + typeof parsed.git !== "object" || + typeof parsed.git.branch !== "string" || + typeof parsed.git.head !== "string" || + !/^[0-9a-f]{40,64}$/.test(parsed.git.head))) || + (parsed.refreshRequired !== undefined && parsed.refreshRequired !== true) + ) { + throw new GracefulError("Unsupported Pacman workspace state."); + } + const state: WorkspaceState = { + schemaVersion: parsed.schemaVersion, + activePackageKey: parsed.activePackageKey, + activeBranch: parsed.activeBranch, + baselineDigests: parsed.baselineDigests, + baselineNodeETags: parsed.baselineNodeETags || {}, + moveHints: parsed.moveHints || {}, + }; + if (parsed.manifestETag) { + state.manifestETag = parsed.manifestETag; + } + if (parsed.refreshRequired) { + state.refreshRequired = true; + } + if (parsed.git) { + state.git = parsed.git; + } + return state; + } + + private relativeVisiblePath(root: string, value: string): string { + const relative = path.relative(root, path.resolve(process.cwd(), value)); + return this.validateRelative(relative); + } + + private resolveVisiblePath(root: string, value: string): string { + const relative = this.validateRelative(value); + return path.resolve(root, relative); + } + + private validateRelative(value: string): string { + const normalized = value.split(path.sep).join("/"); + const folded = normalized.toLowerCase(); + if ( + !normalized || + path.isAbsolute(value) || + normalized === "." || + normalized === ".." || + folded === ".package" || + folded.startsWith(".package/") || + folded === ".git" || + folded.startsWith(".git/") || + normalized.startsWith("../") || + normalized.includes("/../") || + normalized.includes("/./") + ) { + throw new GracefulError(`Invalid workspace path: ${value}`); + } + return normalized; + } + + private moveHintTargets(hints: Record): Record { + return Object.fromEntries( + Object.entries(hints).map(([nodeKey, hint]) => [nodeKey, this.moveHintTarget(hint)!]) + ); + } + + private moveHintTarget(hint: string | WorkspaceMoveHint | undefined): string | undefined { + return this.isStructuredMoveHint(hint) ? hint.targetPath : hint; + } + + private isStructuredMoveHint(value: unknown): value is WorkspaceMoveHint { + return Boolean( + value && + typeof value === "object" && + !Array.isArray(value) && + typeof (value as WorkspaceMoveHint).sourcePath === "string" && + typeof (value as WorkspaceMoveHint).targetPath === "string" && + Object.keys(value).length === 2 + ); + } + + private retainedMoveHints( + hints: Record, + outcomes: WorkspacePushOutcome[] + ): Record { + const completedNodeKeys = new Set( + outcomes + .filter((outcome) => outcome.success || outcome.remoteChanged) + .flatMap((outcome) => (outcome.nodeKey ? [outcome.nodeKey] : [])) + ); + const retained = Object.fromEntries( + Object.entries(hints).filter(([nodeKey]) => !completedNodeKeys.has(nodeKey)) + ); + outcomes + .filter( + (outcome) => + !outcome.success && + !outcome.remoteChanged && + outcome.nodeKey && + (outcome.status === "moved" || outcome.status === "moved, modified") + ) + .forEach((outcome) => { + retained[outcome.nodeKey!] ||= hints[outcome.nodeKey!] || outcome.path; + }); + return retained; + } + + private statePath(root: string): string { + return path.join(root, ".package", "local", "state.json"); + } + + private async createWorkspaceBranch( + current: WorkspaceState | undefined, + projectKey: string, + branch: string + ): Promise { + if (!current) { + throw new GracefulError("Workspace synchronization state is missing. Select an existing branch first."); + } + if (branch === BranchUtils.MAIN_BRANCH_KEY) { + throw new GracefulError("The main branch already exists."); + } + const created = await this.api.createBranch(current.activePackageKey, branch); + if (created.projectKey !== projectKey || created.branchKey !== branch) { + throw new GracefulError("Created branch does not belong to this workspace project."); + } + return created.packageKey; + } + + private async pullTarget( + root: string, + projectKey: string, + localState: WorkspaceState | undefined + ): Promise<{ packageKey: string; restoredObservation?: WorkspaceGitObservation }> { + if (localState) { + return { packageKey: localState.activePackageKey }; + } + const restoredObservation = await this.gitService.observe(root); + if (!restoredObservation) { + return { packageKey: projectKey }; + } + if (!restoredObservation.branch) { + throw new GracefulError("Detached Git HEAD cannot select a Pacman workspace branch."); + } + const mappedBranch = await this.gitService.mappedPacmanBranch(root, projectKey, restoredObservation.branch); + if (!mappedBranch) { + throw new GracefulError( + `Git branch '${restoredObservation.branch}' is not mapped. Use workspace checkout --link-git.` + ); + } + return { + packageKey: this.packageKey(projectKey, this.branch(mappedBranch)), + restoredObservation, + }; + } + + private async synchronizeGitTarget(root: string, requirePushSafe: boolean): Promise { + const state = this.state(root); + const observation = await this.gitService.observe(root); + if (!state.git) { + return this.handleUnlinkedGit(observation, requirePushSafe); + } + if (!observation) { + return this.handleUnsafeGitState( + requirePushSafe, + "The linked Git worktree is unavailable; workspace push is blocked.", + "The linked Git worktree is unavailable; using the last Pacman baseline." + ); + } + if (observation.branch === state.git.branch) { + if (observation.head !== state.git.head) { + this.writeState(root, { ...state, git: observation }); + logger.info(`Observed Git branch '${observation.branch}' at ${observation.head}.`); + } + return false; + } + if (!observation.branch) { + return this.handleUnsafeGitState( + requirePushSafe, + "Detached Git HEAD cannot push a Pacman workspace.", + "Git HEAD is detached; using the last Pacman baseline." + ); + } + const projectKey = this.packageIdentity(root).projectKey; + const mappedBranch = await this.gitService.mappedPacmanBranch(root, projectKey, observation.branch); + if (!mappedBranch) { + return this.handleUnsafeGitState( + requirePushSafe, + `Git branch '${observation.branch}' is not mapped to a Pacman branch. Use workspace checkout --link-git.`, + `Git branch '${observation.branch}' is not mapped; using the last Pacman baseline.` + ); + } + const branch = this.branch(mappedBranch); + const packageKey = this.packageKey(projectKey, branch); + await this.hydrateGitBaseline(root, { ...state, git: observation }, packageKey, branch, observation); + logger.info(`Reconciled Git branch '${observation.branch}' with ${packageKey}.`); + return true; + } + + private async hydrateGitBaseline( + root: string, + state: WorkspaceState, + packageKey: string, + branch: string, + observation: WorkspaceGitObservation + ): Promise { + await this.hydrateRemoteBaseline(root, state, packageKey, branch, observation); + } + + private withRemotePathHints(root: string, state: WorkspaceState, manifest: WorkspaceManifest): WorkspaceState { + const remotePathByNodeKey = new Map( + manifest.nodes.filter((entry) => !this.isFolder(entry.metadata)).map((entry) => [entry.nodeKey, entry.path]) + ); + const moveHints: Record = Object.fromEntries( + this.expectedFiles(root, state, state.activePackageKey) + .filter((file) => { + const remotePath = remotePathByNodeKey.get(file.nodeKey); + return remotePath && remotePath.toLowerCase() !== file.path.toLowerCase(); + }) + .map((file) => [ + file.nodeKey, + { sourcePath: remotePathByNodeKey.get(file.nodeKey)!, targetPath: file.path }, + ]) + ); + return { ...state, moveHints }; + } + + private handleUnlinkedGit(observation: WorkspaceGitObservation | undefined, requirePushSafe: boolean): boolean { + if (requirePushSafe && observation) { + const detail = observation.branch ? `Git branch '${observation.branch}'` : "Detached Git HEAD"; + throw new GracefulError(`${detail} is not linked to a Pacman branch. Use workspace checkout --link-git.`); + } + return false; + } + + private handleUnsafeGitState(requirePushSafe: boolean, error: string, warning: string): boolean { + if (requirePushSafe) { + throw new GracefulError(error); + } + logger.warn(warning); + return false; + } + + private async linkCurrentGitBranch( + root: string, + projectKey: string, + pacmanBranch: string + ): Promise { + const observation = await this.gitService.observe(root); + if (!observation) { + throw new GracefulError("Workspace is not inside a Git worktree."); + } + if (!observation.branch) { + throw new GracefulError("Detached Git HEAD cannot be linked to a Pacman branch."); + } + return this.gitService.link(root, projectKey, observation.branch, pacmanBranch); + } + + private branch(value?: string): string { + const branch = (value || BranchUtils.MAIN_BRANCH_KEY).trim(); + if (!branch || branch.includes("@") || /[\u0000-\u001f\u007f]/.test(branch)) { + throw new GracefulError(`Invalid Pacman branch: ${value || ""}`); + } + return branch.toLowerCase() === BranchUtils.MAIN_BRANCH_KEY ? BranchUtils.MAIN_BRANCH_KEY : branch; + } + + private packageKey(projectKey: string, branch: string): string { + return branch === BranchUtils.MAIN_BRANCH_KEY ? projectKey : BranchUtils.constructBranchKey(projectKey, branch); + } + + private branchFromPackageKey(projectKey: string, packageKey: string): string { + if (packageKey === projectKey) { + return BranchUtils.MAIN_BRANCH_KEY; + } + const prefix = `${projectKey}@`; + if (!packageKey.startsWith(prefix)) { + throw new GracefulError("Active Pacman package does not belong to this workspace project."); + } + return this.branch(packageKey.slice(prefix.length)); + } + + private writeState(root: string, state: WorkspaceState): void { + fs.mkdirSync(path.dirname(this.statePath(root)), { recursive: true, mode: 0o700 }); + fs.writeFileSync(this.statePath(root), JSON.stringify(state, null, 2) + "\n", { mode: 0o600 }); + } + + private packageIdentity(root: string): WorkspacePackageIdentity { + const parsed = JSON.parse( + fs.readFileSync(this.packageIdentityPath(root), "utf-8") + ) as Partial; + if (parsed.schemaVersion !== 1 || !parsed.projectKey || Object.keys(parsed).length !== 2) { + throw new GracefulError("Unsupported Pacman package metadata."); + } + return { schemaVersion: parsed.schemaVersion, projectKey: parsed.projectKey }; + } + + private packageIdentityPath(root: string): string { + return path.join(root, ".package", "package.json"); + } + + private validateGitignore(root: string): void { + const gitignore = path.join(root, ".package", ".gitignore"); + const content = fs.existsSync(gitignore) ? fs.readFileSync(gitignore, "utf-8") : undefined; + if (content !== "local/\n" && content !== "local/\r\n") { + throw new GracefulError("Workspace .package/.gitignore must contain local/."); + } + } + + private digest(file: string): string { + return `sha256:${createHash("sha256").update(fs.readFileSync(file)).digest("hex")}`; + } + + private isFolder(node: WorkspaceNodeMetadata): boolean { + return node.type.toUpperCase() === "FOLDER"; + } +} diff --git a/src/core/http/http-client.ts b/src/core/http/http-client.ts index a66b62a2..7acb26c1 100644 --- a/src/core/http/http-client.ts +++ b/src/core/http/http-client.ts @@ -53,9 +53,17 @@ export class HttpClient { } public async getFile(url: string): Promise { + return (await this.getFileWithHeaders(url)).data; + } + + public async getFileWithHeaders( + url: string, + additionalHeaders: RawAxiosRequestHeaders = {}, + acceptedStatuses: number[] = [200] + ): Promise<{ data: Buffer; headers: Record; status: number }> { return new Promise((resolve, reject) => { this.axios.get(this.resolveUrl(url), { - headers: this.buildHeaders(), + headers: { ...this.buildHeaders(), ...additionalHeaders }, responseType: "stream", validateStatus: status => status >= 200 }).then(response => { @@ -64,12 +72,16 @@ export class HttpClient { data.push(chunk); }); response.data.on("end", () => { - if (response.status !== 200) { - reject(Buffer.concat(data as any).toString()); + if (!acceptedStatuses.includes(response.status)) { + reject(new Error(Buffer.concat(data as any).toString())); return; } - this.handleResponseStreamData(Buffer.concat(data as any), resolve, reject); + resolve({ + data: Buffer.concat(data as any), + headers: response.headers || {}, + status: response.status, + }); }); }).catch(err => { this.handleError(err, resolve, reject); @@ -77,7 +89,12 @@ export class HttpClient { }); } - public async postFile(url: string, formData: FormData, parameters?: {}): Promise { + public async postFile( + url: string, + formData: FormData, + parameters?: {}, + additionalHeaders: RawAxiosRequestHeaders = {} + ): Promise { return new Promise((resolve, reject) => { this.axios.post( this.resolveUrl(url), @@ -85,7 +102,8 @@ export class HttpClient { { headers: { ...this.buildHeaders("multipart/form-data"), - ...formData.getHeaders() + ...formData.getHeaders(), + ...additionalHeaders }, params: parameters } @@ -141,10 +159,49 @@ export class HttpClient { }) } - public async delete(url: string): Promise { + public async putFile( + url: string, + body: Buffer, + contentType: string, + parameters?: {}, + additionalHeaders: RawAxiosRequestHeaders = {} + ): Promise { + return new Promise((resolve, reject) => { + this.axios.put(this.resolveUrl(url), body, { + headers: { ...this.buildHeaders(contentType), ...additionalHeaders }, + params: parameters, + }).then(response => { + this.handleResponse(response, resolve, reject); + }).catch(err => { + this.handleError(err, resolve, reject); + }); + }).catch(e => { + throw new FatalError(e); + }); + } + + public async patch( + url: string, + body: object, + additionalHeaders: RawAxiosRequestHeaders = {} + ): Promise { + return new Promise((resolve, reject) => { + this.axios.patch(this.resolveUrl(url), JSON.stringify(body), { + headers: { ...this.buildHeaders("application/json;charset=utf-8"), ...additionalHeaders }, + }).then(response => { + this.handleResponse(response, resolve, reject); + }).catch(err => { + this.handleError(err, resolve, reject); + }); + }).catch(e => { + throw new FatalError(e); + }); + } + + public async delete(url: string, additionalHeaders: RawAxiosRequestHeaders = {}): Promise { return new Promise((resolve, reject) => { this.axios.delete(this.resolveUrl(url), { - headers: this.buildHeaders("application/json;charset=utf-8") + headers: { ...this.buildHeaders("application/json;charset=utf-8"), ...additionalHeaders } }).then(response => { this.handleResponse(response, resolve, reject); }).catch(err => { @@ -164,15 +221,15 @@ export class HttpClient { }).then(response => { if (this.checkBadRequest(response.status)) { // For error responses, collect as text for readable error messages - let errorData = ''; - response.data.setEncoding('utf8'); - response.data.on('data', (chunk: string) => { + let errorData = ""; + response.data.setEncoding("utf8"); + response.data.on("data", (chunk: string) => { errorData += chunk; }); - response.data.on('end', () => { + response.data.on("end", () => { this.handleBadRequest(response.status, errorData, reject); }); - response.data.on('error', (err: any) => { + response.data.on("error", (err: any) => { reject(`Error reading error response: ${err.message}`); }); } else { @@ -184,7 +241,7 @@ export class HttpClient { response.data.on("end", () => { this.handleResponseStreamData(Buffer.concat(data as any), resolve, reject); }); - response.data.on('error', (err: any) => { + response.data.on("error", (err: any) => { reject(`Error reading file data: ${err.message}`); }); } diff --git a/src/core/utils/file-service.ts b/src/core/utils/file-service.ts index 875402b4..c8c5e73e 100644 --- a/src/core/utils/file-service.ts +++ b/src/core/utils/file-service.ts @@ -112,13 +112,18 @@ export class FileService { return zipFilePath; } - public zipDirectoryAsSinglePackage(sourceDir: string): string { + public zipDirectoryAsSinglePackage(sourceDir: string, include?: (relativePath: string) => boolean): string { if (fs.lstatSync(sourceDir).isSymbolicLink()) { throw new FatalError("Source directory cannot be a symbolic link."); } const zip = new AdmZip(); - zip.addLocalFolder(sourceDir); + zip.addLocalFolder(sourceDir, "", filename => { + const relativePath = (path.isAbsolute(filename) ? path.relative(sourceDir, filename) : filename) + .split(path.sep) + .join("/"); + return include ? include(relativePath) : true; + }); const tempDir = path.join(os.tmpdir(), "content-cli-imports"); this.mkdirRecursive(tempDir); diff --git a/tests/commands/workspace/module.spec.ts b/tests/commands/workspace/module.spec.ts new file mode 100644 index 00000000..5f9eb511 --- /dev/null +++ b/tests/commands/workspace/module.spec.ts @@ -0,0 +1,81 @@ +import { Command } from "commander"; +import Module = require("../../../src/commands/workspace/module"); +import { WorkspaceService } from "../../../src/commands/workspace/workspace.service"; +import { Configurator } from "../../../src/core/command/module-handler"; +import { GracefulError } from "../../../src/core/utils/logger"; +import { testContext } from "../../utls/test-context"; +import { createMockConfigurator } from "../../utls/configurator-mock"; + +describe("Workspace module", () => { + it("registers a separate beta command family", () => { + const configurator = createMockConfigurator(); + + new Module().register(testContext, configurator); + + expect(configurator.command).toHaveBeenCalledWith("workspace"); + expect(configurator.command).toHaveBeenCalledWith("clone [directory]"); + expect(configurator.command).toHaveBeenCalledWith("checkout [branch]"); + expect(configurator.command).toHaveBeenCalledWith("pull [paths...]"); + expect(configurator.command).toHaveBeenCalledWith("status [directory]"); + expect(configurator.command).toHaveBeenCalledWith("push [paths...]"); + expect(configurator.command).toHaveBeenCalledWith("move "); + expect(configurator.beta).toHaveBeenCalledTimes(7); + expect(configurator.action).toHaveBeenCalledTimes(6); + }); + + it("dispatches workspace command arguments and options", async () => { + const clone = jest.spyOn(WorkspaceService.prototype, "clone").mockResolvedValue(); + const checkout = jest.spyOn(WorkspaceService.prototype, "checkout").mockResolvedValue(); + const pull = jest.spyOn(WorkspaceService.prototype, "pull").mockResolvedValue(); + const status = jest.spyOn(WorkspaceService.prototype, "statusWithGit").mockResolvedValue([]); + const push = jest.spyOn(WorkspaceService.prototype, "push").mockResolvedValue(); + const move = jest.spyOn(WorkspaceService.prototype, "move").mockReturnValue(); + + const execute = async (...args: string[]): Promise => { + const program = new Command(); + new Module().register(testContext, new Configurator(program, testContext)); + await program.parseAsync(["node", "content-cli", ...args]); + }; + + await execute("workspace", "clone", "package-key", "target", "--branch", "feature-a"); + await execute("workspace", "checkout", "feature-a", "--link-git"); + await execute("workspace", "checkout", "-b", "feature-b"); + await execute("workspace", "pull", "target", "other.md"); + await execute("workspace", "status", "target"); + await execute("workspace", "push", "target", "other.md", "--asset-type", "MARKDOWN_FILE"); + await execute("workspace", "move", "old.md", "new.md", "--record"); + + expect(clone).toHaveBeenCalledWith("package-key", "target", { branch: "feature-a" }); + expect(checkout).toHaveBeenNthCalledWith(1, "feature-a", { + create: false, + discard: false, + linkGit: true, + }); + expect(checkout).toHaveBeenNthCalledWith(2, "feature-b", { + create: true, + discard: false, + linkGit: false, + }); + expect(pull).toHaveBeenCalledWith(["target", "other.md"]); + expect(status).toHaveBeenCalledWith("target"); + expect(push).toHaveBeenCalledWith(["target", "other.md"], { + assetType: "MARKDOWN_FILE", + }); + expect(move).toHaveBeenCalledWith("old.md", "new.md", true); + }); + + it("marks beta workspace command failures as non-zero", async () => { + const previousExitCode = process.exitCode; + process.exitCode = 0; + jest.spyOn(WorkspaceService.prototype, "clone").mockRejectedValueOnce(new GracefulError("clone failed")); + const program = new Command(); + new Module().register(testContext, new Configurator(program, testContext)); + + try { + await program.parseAsync(["node", "content-cli", "workspace", "clone", "package-key"]); + expect(process.exitCode).toBe(1); + } finally { + process.exitCode = previousExitCode; + } + }); +}); diff --git a/tests/commands/workspace/workspace-change-classifier.spec.ts b/tests/commands/workspace/workspace-change-classifier.spec.ts new file mode 100644 index 00000000..3547fdbe --- /dev/null +++ b/tests/commands/workspace/workspace-change-classifier.spec.ts @@ -0,0 +1,148 @@ +import { classifyWorkspaceChanges } from "../../../src/commands/workspace/workspace-change-classifier"; +import { ExpectedWorkspaceFile } from "../../../src/commands/workspace/workspace.models"; + +function tracked(nodeKey: string, filePath: string, digest?: string): ExpectedWorkspaceFile { + return { nodeKey, path: filePath, assetType: "md", digest }; +} + +describe("Workspace change classifier", () => { + it("keeps unchanged files clean", () => { + expect( + classifyWorkspaceChanges( + [tracked("node-1", "Guides/Guide.md", "sha256:one")], + new Map([["Guides/Guide.md", "sha256:one"]]), + {} + ) + ).toEqual([]); + }); + + it("infers a uniquely matching unchanged move", () => { + expect( + classifyWorkspaceChanges( + [tracked("node-1", "Guides/Guide.md", "sha256:one")], + new Map([["Pages/Guide.md", "sha256:one"]]), + {} + ) + ).toEqual([{ nodeKey: "node-1", path: "Pages/Guide.md", status: "moved" }]); + }); + + it("keeps a uniquely matching filename rename unresolved", () => { + expect( + classifyWorkspaceChanges( + [tracked("node-1", "Guides/Guide.md", "sha256:one")], + new Map([["Pages/Renamed.md", "sha256:one"]]), + {} + ) + ).toEqual([{ nodeKey: "node-1", path: "Pages/Renamed.md", status: "unresolved" }]); + }); + + it("uses a recorded hint for a move followed by an edit", () => { + expect( + classifyWorkspaceChanges( + [tracked("node-1", "Guides/Guide.md", "sha256:one")], + new Map([["Pages/Guide.md", "sha256:two"]]), + { "node-1": "Pages/Guide.md" } + ) + ).toEqual([{ nodeKey: "node-1", path: "Pages/Guide.md", status: "moved, modified" }]); + }); + + it("uses a reconciliation hint when metadata already names the destination", () => { + expect( + classifyWorkspaceChanges( + [tracked("node-1", "Pages/Guide.md", "sha256:one")], + new Map([["Pages/Guide.md", "sha256:one"]]), + { "node-1": "Pages/Guide.md" } + ) + ).toEqual([{ nodeKey: "node-1", path: "Pages/Guide.md", status: "moved" }]); + }); + + it("keeps a recorded case-only filename rename unresolved", () => { + expect( + classifyWorkspaceChanges( + [tracked("node-1", "Guides/Guide.md", "sha256:one")], + new Map([["Guides/Guide.md", "sha256:one"]]), + { "node-1": "Guides/guide.md" } + ) + ).toEqual([{ nodeKey: "node-1", path: "Guides/guide.md", status: "unresolved" }]); + }); + + it("keeps a distinct deletion and addition separate", () => { + expect( + classifyWorkspaceChanges( + [tracked("node-1", "Guides/Old.md", "sha256:one")], + new Map([["Pages/New.md", "sha256:two"]]), + {} + ) + ).toEqual([ + { nodeKey: "node-1", path: "Guides/Old.md", status: "deleted" }, + { path: "Pages/New.md", status: "added" }, + ]); + }); + + it("keeps ambiguous moved and edited basenames unresolved", () => { + expect( + classifyWorkspaceChanges( + [ + tracked("node-1", "Guides/Guide.md", "sha256:one"), + tracked("node-2", "Tutorials/Guide.md", "sha256:two"), + ], + new Map([["Pages/Guide.md", "sha256:three"]]), + {} + ) + ).toEqual([ + { nodeKey: "node-1", path: "Guides/Guide.md", status: "unresolved" }, + { path: "Pages/Guide.md", status: "unresolved" }, + { nodeKey: "node-2", path: "Tutorials/Guide.md", status: "unresolved" }, + ]); + }); + + it("classifies a metadata-backed file without a baseline as added", () => { + expect( + classifyWorkspaceChanges( + [tracked("node-1", "Guides/New.md")], + new Map([["Guides/New.md", "sha256:new"]]), + {} + ) + ).toEqual([{ nodeKey: "node-1", path: "Guides/New.md", status: "added" }]); + }); + + it("keeps an unrecorded move without a baseline unresolved once", () => { + expect( + classifyWorkspaceChanges( + [tracked("node-1", "Guides/New.md")], + new Map([["Pages/New.md", "sha256:new"]]), + {} + ) + ).toEqual([{ nodeKey: "node-1", path: "Pages/New.md", status: "unresolved" }]); + }); + + it("classifies a recorded move without a baseline as added", () => { + expect( + classifyWorkspaceChanges( + [tracked("node-1", "Guides/New.md")], + new Map([["Pages/New.md", "sha256:new"]]), + { "node-1": "Pages/New.md" } + ) + ).toEqual([{ nodeKey: "node-1", path: "Pages/New.md", status: "added" }]); + }); + + it("keeps an invalid recorded move unresolved", () => { + expect( + classifyWorkspaceChanges( + [tracked("node-1", "Guides/Guide.md", "sha256:one")], + new Map([["Guides/Guide.md", "sha256:one"]]), + { "node-1": "Pages/Guide.md" } + ) + ).toEqual([{ nodeKey: "node-1", path: "Pages/Guide.md", status: "unresolved" }]); + }); + + it("keeps an unrecorded same-name move and edit unresolved", () => { + expect( + classifyWorkspaceChanges( + [tracked("node-1", "Guides/Guide.md", "sha256:one")], + new Map([["Pages/Guide.md", "sha256:two"]]), + {} + ) + ).toEqual([{ nodeKey: "node-1", path: "Pages/Guide.md", status: "unresolved" }]); + }); +}); diff --git a/tests/commands/workspace/workspace-git.service.spec.ts b/tests/commands/workspace/workspace-git.service.spec.ts new file mode 100644 index 00000000..c6a94df8 --- /dev/null +++ b/tests/commands/workspace/workspace-git.service.spec.ts @@ -0,0 +1,100 @@ +import { execFileSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { WorkspaceGitService } from "../../../src/commands/workspace/workspace-git.service"; + +describe("Workspace Git service", () => { + let repository: string; + let workspace: string; + + beforeEach(() => { + repository = fs.mkdtempSync(path.join(os.tmpdir(), "content-cli-workspace-git-")); + workspace = path.join(repository, "content"); + fs.mkdirSync(workspace); + git("init", "-b", "main"); + git("config", "user.email", "workspace-tests@example.invalid"); + git("config", "user.name", "Workspace Tests"); + git("-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "initial"); + }); + + afterEach(() => fs.rmSync(repository, { recursive: true, force: true })); + + it("observes the current Git branch and HEAD", async () => { + const observation = await new WorkspaceGitService().observe(workspace); + + expect(observation).toEqual({ branch: "main", head: git("rev-parse", "HEAD") }); + }); + + it("stores explicit mappings scoped to the workspace and project", async () => { + const service = new WorkspaceGitService(); + + await expect(service.link(workspace, "project-a", "main", "feature-a")).resolves.toEqual({ + branch: "main", + head: git("rev-parse", "HEAD"), + }); + await expect(service.mappedPacmanBranch(workspace, "project-a", "main")).resolves.toBe("feature-a"); + await expect(service.mappedPacmanBranch(workspace, "project-b", "main")).resolves.toBeUndefined(); + await expect(service.mappedPacmanBranch(repository, "project-a", "main")).resolves.toBeUndefined(); + }); + + it("reports detached HEAD without changing Git state", async () => { + const head = git("rev-parse", "HEAD"); + git("checkout", "--detach", head); + + await expect(new WorkspaceGitService().observe(workspace)).resolves.toEqual({ branch: "", head }); + }); + + it("rejects linking after the Git branch changes", async () => { + await expect(new WorkspaceGitService().link(workspace, "project-a", "other", "feature-a")).rejects.toThrow( + "Git branch changed while linking the workspace." + ); + }); + + it("rejects an invalid stored mapping", async () => { + const service = new WorkspaceGitService(); + await service.link(workspace, "project-a", "main", "feature-a"); + const mappingKey = git("config", "--local", "--name-only", "--get-regexp", "^content-cli-workspace\\."); + git("config", "--local", mappingKey, "[]"); + + await expect(service.mappedPacmanBranch(workspace, "project-a", "main")).rejects.toThrow( + "Git contains an invalid Content CLI workspace mapping." + ); + }); + + it("returns no observation before a repository has a HEAD", async () => { + const emptyRepository = fs.mkdtempSync(path.join(os.tmpdir(), "content-cli-workspace-empty-git-")); + gitIn(emptyRepository, "init", "-b", "main"); + try { + await expect(new WorkspaceGitService().observe(emptyRepository)).resolves.toBeUndefined(); + } finally { + fs.rmSync(emptyRepository, { recursive: true, force: true }); + } + }); + + it("returns no observation outside a Git worktree", async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "content-cli-workspace-no-git-")); + try { + await expect(new WorkspaceGitService().observe(directory)).resolves.toBeUndefined(); + await expect( + new WorkspaceGitService().mappedPacmanBranch(directory, "project-a", "main") + ).resolves.toBeUndefined(); + await expect(new WorkspaceGitService().link(directory, "project-a", "main", "feature-a")).rejects.toThrow( + "Workspace is not inside a Git worktree." + ); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + function git(...args: string[]): string { + return gitIn(repository, ...args); + } + + function gitIn(directory: string, ...args: string[]): string { + return execFileSync("git", ["-C", directory, ...args], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } +}); diff --git a/tests/commands/workspace/workspace-path-projector.spec.ts b/tests/commands/workspace/workspace-path-projector.spec.ts new file mode 100644 index 00000000..d5cc27af --- /dev/null +++ b/tests/commands/workspace/workspace-path-projector.spec.ts @@ -0,0 +1,59 @@ +import { createHash } from "node:crypto"; +import { + projectedLeafAfterMove, + projectWorkspacePaths, +} from "../../../src/commands/workspace/workspace-path-projector"; +import { WorkspaceNode } from "../../../src/commands/workspace/workspace.models"; + +describe("Workspace path projector", () => { + it("derives registered and fallback extensions from Asset Type", () => { + const nodes: WorkspaceNode[] = [ + { nodeKey: "folder", name: "Guides", type: "FOLDER" }, + { nodeKey: "markdown", name: "Guide", type: "md", parentNodeKey: "folder" }, + { nodeKey: "html", name: "Landing", type: "HTML_CANVAS" }, + { nodeKey: "board", name: "Metrics", type: "BOARD_V2" }, + ]; + + expect(Object.fromEntries(projectWorkspacePaths(nodes))).toEqual({ + folder: "Guides", + markdown: "Guides/Guide.md", + html: "Landing.html", + board: "Metrics.json", + }); + }); + + it("keeps the legacy Markdown Asset Type alias compatible", () => { + const nodes: WorkspaceNode[] = [{ nodeKey: "markdown", name: "Guide", type: "MARKDOWN_FILE" }]; + + expect(projectWorkspacePaths(nodes).get("markdown")).toBe("Guide.md"); + }); + + it("adds stable Node-key suffixes to every colliding sibling", () => { + const nodes: WorkspaceNode[] = [ + { nodeKey: "folder", name: "Guides", type: "FOLDER" }, + { nodeKey: "node-1", name: "Guide", type: "MARKDOWN_FILE", parentNodeKey: "folder" }, + { nodeKey: "node-2", name: "Guide", type: "MARKDOWN_FILE", parentNodeKey: "folder" }, + ]; + + const paths = projectWorkspacePaths(nodes); + + expect(paths.get("node-1")).toBe(`Guides/Guide~${shortHash("node-1")}.md`); + expect(paths.get("node-2")).toBe(`Guides/Guide~${shortHash("node-2")}.md`); + }); + + it("projects the leaf against target siblings before a parent move", () => { + const nodes: WorkspaceNode[] = [ + { nodeKey: "guides", name: "Guides", type: "FOLDER" }, + { nodeKey: "pages", name: "Pages", type: "FOLDER" }, + { nodeKey: "source", name: "Guide", type: "MARKDOWN_FILE", parentNodeKey: "guides" }, + { nodeKey: "target", name: "Guide", type: "MARKDOWN_FILE", parentNodeKey: "pages" }, + ]; + + expect(projectedLeafAfterMove(nodes, "source", "pages")).toBe(`Guide~${shortHash("source")}.md`); + expect(projectedLeafAfterMove(nodes, "source", "new-parent")).toBe("Guide.md"); + }); +}); + +function shortHash(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 12); +} diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts new file mode 100644 index 00000000..30e90d48 --- /dev/null +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -0,0 +1,1656 @@ +import { createHash } from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { WorkspaceService } from "../../../src/commands/workspace/workspace.service"; +import { WorkspaceGitService } from "../../../src/commands/workspace/workspace-git.service"; +import { logger } from "../../../src/core/utils/logger"; +import { testContext } from "../../utls/test-context"; +import { + mockAxiosDelete, + mockAxiosGet, + mockAxiosGetError, + mockAxiosGetWithStatus, + mockAxiosPatch, + mockAxiosPost, + mockAxiosPut, + mockAxiosPutError, + mockedAxiosInstance, +} from "../../utls/http-requests-mock"; + +const PACKAGE_KEY = "pkg-1"; +const BRANCH = "feature-a"; +const BRANCH_PACKAGE_KEY = `${PACKAGE_KEY}@${BRANCH}`; +function fileUrl(filePath: string, packageKey: string = PACKAGE_KEY): string { + return `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${encodeURIComponent(packageKey)}/files/${filePath}`; +} + +function fileWriteUrl(filePath: string, assetType: string = "md", packageKey: string = PACKAGE_KEY): string { + return `${fileUrl(filePath, packageKey)}?assetType=${encodeURIComponent(assetType)}`; +} + +function folderUrl(folderPath: string, packageKey: string = PACKAGE_KEY): string { + return `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${encodeURIComponent(packageKey)}/folders/${folderPath}`; +} + +function manifestUrl(packageKey: string = PACKAGE_KEY): string { + return `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${encodeURIComponent(packageKey)}/files`; +} + +interface TestFile { + nodeKey: string; + path: string; + content: string; + assetType?: string; +} + +function digest(value: string): string { + return `sha256:${createHash("sha256").update(value).digest("hex")}`; +} + +function eTag(value: string): string { + return `"${createHash("sha256").update(value).digest("hex")}"`; +} + +function metadata(files: TestFile[]): Record { + const nodes: Record = {}; + const folders = new Map(); + files.forEach((file) => { + const segments = file.path.split("/"); + let parentNodeKey: string | null = null; + for (let index = 0; index < segments.length - 1; index += 1) { + const folderPath = segments.slice(0, index + 1).join("/"); + let folderKey = folders.get(folderPath); + if (!folderKey) { + folderKey = + folderPath === "Guides" + ? "folder-1" + : `folder-${createHash("sha256").update(folderPath).digest("hex").slice(0, 12)}`; + folders.set(folderPath, folderKey); + nodes[folderKey] = { + name: segments[index], + type: "FOLDER", + parentNodeKey, + }; + } + parentNodeKey = folderKey; + } + nodes[file.nodeKey] = { + name: path.posix.basename(file.path, path.posix.extname(file.path)), + type: file.assetType ?? "md", + parentNodeKey, + }; + }); + return nodes; +} + +function state(files: TestFile[], moveHints: Record = {}): object { + const nodes = metadata(files); + return { + schemaVersion: 1, + activePackageKey: PACKAGE_KEY, + activeBranch: "main", + manifestETag: eTag("manifest"), + baselineDigests: Object.fromEntries(files.map((file) => [file.nodeKey, digest(file.content)])), + baselineNodeETags: Object.fromEntries( + Object.keys(nodes).map((nodeKey) => { + const file = files.find((candidate) => candidate.nodeKey === nodeKey); + return [nodeKey, eTag(file ? file.content : nodeKey)]; + }) + ), + moveHints, + }; +} + +function manifest(files: TestFile[]): Buffer { + const nodes = metadata(files) as Record; + const byKey = new Map(Object.entries(nodes)); + const paths = new Map(); + const resolvePath = (nodeKey: string): string => { + const cached = paths.get(nodeKey); + if (cached) { + return cached; + } + const metadataNode = nodes[nodeKey]; + const parentKey = metadataNode.parentNodeKey; + const segment = `${metadataNode.name}${metadataNode.type === "FOLDER" ? "" : ".md"}`; + const filePath = parentKey && byKey.has(parentKey) ? `${resolvePath(parentKey)}/${segment}` : segment; + paths.set(nodeKey, filePath); + return filePath; + }; + return Buffer.from( + JSON.stringify({ + nodes: Object.entries(nodes).map(([nodeKey, node]) => { + const file = files.find((candidate) => candidate.nodeKey === nodeKey); + return file + ? { + nodeKey, + path: resolvePath(nodeKey), + mediaType: "text/markdown", + eTag: eTag(file.content), + metadata: node, + } + : { nodeKey, path: resolvePath(nodeKey), eTag: eTag(nodeKey), metadata: node }; + }), + }) + ); +} + +function mockManifest( + files: TestFile[], + packageKey: string = PACKAGE_KEY, + manifestETag: string = eTag("manifest"), + mockBodies: boolean = false +): void { + mockAxiosGet(manifestUrl(packageKey), manifest(files), { etag: manifestETag }); + if (mockBodies) { + files.forEach((file) => + mockAxiosGet(fileUrl(file.path, packageKey), Buffer.from(file.content), { etag: eTag(file.content) }) + ); + } +} + +function mockWorkspaceHydration( + files: TestFile[], + revision: string = eTag("revision-1"), + packageKey: string = PACKAGE_KEY +): void { + mockManifest(files, packageKey, revision, true); +} + +function writeWorkspace( + files: TestFile[] = [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }] +): void { + fs.mkdirSync(path.join(process.cwd(), ".package", "nodes"), { recursive: true }); + fs.mkdirSync(path.join(process.cwd(), ".package", "local"), { recursive: true }); + fs.writeFileSync(path.join(process.cwd(), ".package", ".gitignore"), "local/\n"); + fs.writeFileSync( + path.join(process.cwd(), ".package", "package.json"), + JSON.stringify({ schemaVersion: 1, projectKey: PACKAGE_KEY }) + ); + fs.writeFileSync(path.join(process.cwd(), ".package", "local", "state.json"), JSON.stringify(state(files))); + Object.entries(metadata(files)).forEach(([nodeKey, node]) => { + fs.writeFileSync(path.join(process.cwd(), ".package", "nodes", `${nodeKey}.json`), JSON.stringify(node)); + }); + files.forEach((file) => { + fs.mkdirSync(path.dirname(path.join(process.cwd(), file.path)), { recursive: true }); + fs.writeFileSync(path.join(process.cwd(), file.path), file.content); + }); +} + +function removeWorkspace(): void { + [ + ".git", + ".package", + "Guides", + "Pages", + "Other", + "Empty", + "New", + "New.md", + "Bulk", + "Added", + "Deleted", + "Old", + "Selected", + "Source", + "Target", + "Unselected", + PACKAGE_KEY, + "branch-workspace", + "empty-workspace", + ].forEach((entry) => fs.rmSync(path.join(process.cwd(), entry), { recursive: true, force: true })); +} + +function mockGit( + observation: { branch: string; head: string } | undefined, + mappedBranch?: string +): jest.Mocked { + return { + observe: jest.fn().mockResolvedValue(observation), + mappedPacmanBranch: jest.fn().mockResolvedValue(mappedBranch), + link: jest.fn().mockResolvedValue(observation), + } as unknown as jest.Mocked; +} + +describe("Workspace service", () => { + beforeEach(removeWorkspace); + afterEach(removeWorkspace); + + it("clones a workspace from the manifest and point file API", async () => { + mockWorkspaceHydration([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]); + const rename = jest.spyOn(fs, "renameSync"); + + try { + await new WorkspaceService(testContext).clone(PACKAGE_KEY); + + expect(fs.readFileSync(path.join(process.cwd(), PACKAGE_KEY, "Guides", "Guide.md"), "utf-8")).toBe( + "original" + ); + expect(fs.readFileSync(path.join(process.cwd(), PACKAGE_KEY, ".package", ".gitignore"), "utf-8")).toBe( + "local/\n" + ); + expect( + JSON.parse( + fs.readFileSync(path.join(process.cwd(), PACKAGE_KEY, ".package", "local", "state.json"), "utf-8") + ) + ).toEqual({ + schemaVersion: 1, + activePackageKey: PACKAGE_KEY, + activeBranch: "main", + manifestETag: eTag("revision-1"), + baselineDigests: { "node-1": digest("original") }, + baselineNodeETags: { + "folder-1": eTag("folder-1"), + "node-1": eTag("original"), + }, + moveHints: {}, + }); + expect( + JSON.parse( + fs.readFileSync(path.join(process.cwd(), PACKAGE_KEY, ".package", "nodes", "node-1.json"), "utf-8") + ) + ).toEqual({ name: "Guide", type: "md", parentNodeKey: "folder-1" }); + const cloneRename = rename.mock.calls.find((call) => call[1] === path.join(process.cwd(), PACKAGE_KEY)); + expect(cloneRename).toBeDefined(); + expect(path.dirname(cloneRename![0].toString())).toBe(path.dirname(cloneRename![1].toString())); + } finally { + rename.mockRestore(); + } + }); + + it("rejects a clone response without an ETag", async () => { + mockAxiosGet(manifestUrl(), manifest([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }])); + + await expect(new WorkspaceService(testContext).clone(PACKAGE_KEY)).rejects.toThrow( + "Workspace manifest response does not contain a package ETag." + ); + }); + + it("rejects a clone when a file changes after the manifest is read", async () => { + const files = [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]; + mockManifest(files); + mockAxiosGet(fileUrl(files[0].path), Buffer.from("changed"), { etag: eTag("changed") }); + + await expect(new WorkspaceService(testContext).clone(PACKAGE_KEY)).rejects.toThrow( + "Workspace hydration failed for 1 node(s)." + ); + expect(fs.existsSync(path.join(process.cwd(), PACKAGE_KEY))).toBe(false); + }); + + it("clones an empty package and hydrates an empty nodes directory", async () => { + mockWorkspaceHydration([], eTag("empty-revision"), PACKAGE_KEY); + + await new WorkspaceService(testContext).clone(PACKAGE_KEY, "empty-workspace"); + + const root = path.join(process.cwd(), "empty-workspace"); + expect(fs.readdirSync(path.join(root, ".package", "nodes"))).toEqual([]); + expect(JSON.parse(fs.readFileSync(path.join(root, ".package", "local", "state.json"), "utf-8"))).toEqual({ + schemaVersion: 1, + activePackageKey: PACKAGE_KEY, + activeBranch: "main", + manifestETag: eTag("empty-revision"), + baselineDigests: {}, + baselineNodeETags: {}, + moveHints: {}, + }); + }); + + it("clones a selected branch while keeping stable project identity", async () => { + mockWorkspaceHydration( + [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "branch" }], + eTag("branch-revision"), + BRANCH_PACKAGE_KEY + ); + + await new WorkspaceService(testContext).clone(PACKAGE_KEY, "branch-workspace", { branch: BRANCH }); + + const root = path.join(process.cwd(), "branch-workspace"); + expect(JSON.parse(fs.readFileSync(path.join(root, ".package", "package.json"), "utf-8"))).toEqual({ + schemaVersion: 1, + projectKey: PACKAGE_KEY, + }); + expect(JSON.parse(fs.readFileSync(path.join(root, ".package", "local", "state.json"), "utf-8"))).toMatchObject({ + activePackageKey: BRANCH_PACKAGE_KEY, + activeBranch: BRANCH, + }); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it("checks out an existing branch atomically", async () => { + writeWorkspace(); + mockWorkspaceHydration( + [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "branch" }], + eTag("branch-revision"), + BRANCH_PACKAGE_KEY + ); + + await new WorkspaceService(testContext).checkout(BRANCH); + + expect(fs.readFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "utf-8")).toBe("branch"); + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package", "local", "state.json"), "utf-8")) + ).toMatchObject({ + activePackageKey: BRANCH_PACKAGE_KEY, + activeBranch: BRANCH, + moveHints: {}, + }); + }); + + it("creates a branch from the active remote target and retains local edits", async () => { + writeWorkspace(); + fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "local edit"); + mockAxiosPost(`https://myTeam.celonis.cloud/pacman/api/core/packages/${PACKAGE_KEY}/branches`, { + projectKey: PACKAGE_KEY, + branchKey: BRANCH, + packageKey: BRANCH_PACKAGE_KEY, + }); + mockWorkspaceHydration( + [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }], + eTag("branch-revision"), + BRANCH_PACKAGE_KEY + ); + const service = new WorkspaceService(testContext); + + await service.checkout(BRANCH, { create: true }); + + expect(fs.readFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "utf-8")).toBe("local edit"); + expect(service.status()).toEqual([{ path: "Guides/Guide.md", status: "modified" }]); + expect(mockedAxiosInstance.post).toHaveBeenCalledWith( + `https://myTeam.celonis.cloud/pacman/api/core/packages/${PACKAGE_KEY}/branches`, + JSON.stringify({ branchKey: BRANCH, version: "STAGING" }), + expect.anything() + ); + }); + + it("links a Git branch without overwriting checked-out files", async () => { + writeWorkspace(); + fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "git content"); + const observation = { branch: "git-feature", head: "a".repeat(40) }; + const git = mockGit(observation); + mockManifest( + [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }], + BRANCH_PACKAGE_KEY, + eTag("manifest"), + true + ); + const service = new WorkspaceService(testContext, git); + + await service.checkout(BRANCH, { linkGit: true }); + + expect(git.link).toHaveBeenCalledWith(process.cwd(), PACKAGE_KEY, "git-feature", BRANCH); + expect(fs.readFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "utf-8")).toBe("git content"); + expect(service.status()).toEqual([{ path: "Guides/Guide.md", status: "modified" }]); + }); + + it("rehydrates a mapped Pacman baseline after an external Git branch switch", async () => { + writeWorkspace(); + const statePath = path.join(process.cwd(), ".package", "local", "state.json"); + fs.writeFileSync( + statePath, + JSON.stringify({ + ...state([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]), + git: { branch: "main", head: "a".repeat(40) }, + moveHints: { "node-1": "Old.md" }, + }) + ); + const observation = { branch: "git-feature", head: "b".repeat(40) }; + const git = mockGit(observation, BRANCH); + mockManifest( + [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }], + BRANCH_PACKAGE_KEY, + eTag("manifest"), + true + ); + const service = new WorkspaceService(testContext, git); + + await expect(service.statusWithGit()).resolves.toEqual([]); + + expect(fs.readFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "utf-8")).toBe("original"); + expect(JSON.parse(fs.readFileSync(statePath, "utf-8"))).toMatchObject({ + activePackageKey: BRANCH_PACKAGE_KEY, + activeBranch: BRANCH, + moveHints: {}, + git: observation, + }); + }); + + it("observes a Git advance without replacing the Pacman baseline", async () => { + writeWorkspace(); + fs.mkdirSync(path.join(process.cwd(), "Pages")); + fs.renameSync(path.join(process.cwd(), "Guides/Guide.md"), path.join(process.cwd(), "Pages/Guide.md")); + const statePath = path.join(process.cwd(), ".package", "local", "state.json"); + fs.writeFileSync( + statePath, + JSON.stringify({ + ...state([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]), + git: { branch: "main", head: "a".repeat(40) }, + moveHints: { "node-1": "Pages/Guide.md" }, + }) + ); + const observation = { branch: "main", head: "b".repeat(40) }; + const git = mockGit(observation); + mockManifest([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]); + const service = new WorkspaceService(testContext, git); + + await expect(service.statusWithGit()).resolves.toEqual([{ path: "Pages/Guide.md", status: "moved" }]); + + expect(JSON.parse(fs.readFileSync(statePath, "utf-8"))).toMatchObject({ + activePackageKey: PACKAGE_KEY, + moveHints: { "node-1": "Pages/Guide.md" }, + git: observation, + }); + expect(git.mappedPacmanBranch).not.toHaveBeenCalled(); + expect(mockedAxiosInstance.get).not.toHaveBeenCalled(); + }); + + it("continues an incremental pull after Git advances on the mapped branch", async () => { + writeWorkspace(); + const statePath = path.join(process.cwd(), ".package", "local", "state.json"); + fs.writeFileSync( + statePath, + JSON.stringify({ + ...state([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]), + git: { branch: "main", head: "a".repeat(40) }, + }) + ); + const observation = { branch: "main", head: "b".repeat(40) }; + mockManifest( + [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }], + PACKAGE_KEY, + eTag("manifest"), + true + ); + mockAxiosGet(fileUrl("Guides/Guide.md"), Buffer.from("remote"), { etag: eTag("remote") }); + + await new WorkspaceService(testContext, mockGit(observation)).pull(); + + expect(fs.readFileSync(path.join(process.cwd(), "Guides/Guide.md"), "utf-8")).toBe("remote"); + expect(new WorkspaceService(testContext, mockGit(observation)).status()).toEqual([]); + expect(JSON.parse(fs.readFileSync(statePath, "utf-8"))).toMatchObject({ git: observation }); + }); + + it("blocks pushes from detached or unmapped switched Git branches", async () => { + writeWorkspace(); + const statePath = path.join(process.cwd(), ".package", "local", "state.json"); + fs.writeFileSync( + statePath, + JSON.stringify({ + ...state([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]), + git: { branch: "main", head: "a".repeat(40) }, + }) + ); + + await expect( + new WorkspaceService(testContext, mockGit({ branch: "", head: "b".repeat(40) })).push() + ).rejects.toThrow("Detached Git HEAD"); + await expect( + new WorkspaceService(testContext, mockGit({ branch: "unmapped", head: "c".repeat(40) })).push() + ).rejects.toThrow("is not mapped to a Pacman branch"); + }); + + it("blocks pushes from a Git worktree that has not been linked", async () => { + writeWorkspace(); + + await expect( + new WorkspaceService(testContext, mockGit({ branch: "main", head: "a".repeat(40) })).push() + ).rejects.toThrow("is not linked to a Pacman branch"); + }); + + it("pulls only a changed remote body into a clean existing workspace", async () => { + writeWorkspace(); + fs.mkdirSync(path.join(process.cwd(), ".git")); + fs.writeFileSync(path.join(process.cwd(), ".git", "marker"), "keep"); + const workspaceInode = fs.statSync(process.cwd()).ino; + mockManifest( + [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }], + PACKAGE_KEY, + eTag("manifest"), + true + ); + mockAxiosGet(fileUrl("Guides/Guide.md"), Buffer.from("remote"), { etag: eTag("remote") }); + + await new WorkspaceService(testContext).pull(); + + expect(fs.statSync(process.cwd()).ino).toBe(workspaceInode); + expect(fs.readFileSync(path.join(process.cwd(), ".git", "marker"), "utf-8")).toBe("keep"); + expect(fs.readFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "utf-8")).toBe("remote"); + expect(new WorkspaceService(testContext).status()).toEqual([]); + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package", "local", "state.json"), "utf-8")) + ).toMatchObject({ + baselineDigests: { "node-1": digest("remote") }, + moveHints: {}, + }); + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package", "local", "state.json"), "utf-8")) + ).not.toHaveProperty("serverRevision"); + }); + + it("replays the stored manifest ETag and skips body reads after a 304", async () => { + writeWorkspace(); + const storedETag = eTag("manifest"); + mockAxiosGetWithStatus(manifestUrl(), 304, Buffer.alloc(0), { etag: storedETag }); + + await new WorkspaceService(testContext).pull(); + + expect(mockedAxiosInstance.get).toHaveBeenCalledTimes(1); + expect(mockedAxiosInstance.get).toHaveBeenCalledWith( + manifestUrl(), + expect.objectContaining({ headers: expect.objectContaining({ "If-None-Match": storedETag }) }) + ); + expect(fs.readFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "utf-8")).toBe("original"); + }); + + it("downloads only three changed bodies from a one hundred file manifest", async () => { + const original = Array.from({ length: 100 }, (_, index) => ({ + nodeKey: `node-${index}`, + path: `Bulk/File-${index}.md`, + content: `original-${index}`, + })); + const changedIndexes = new Set([1, 50, 99]); + const remote = original.map((file, index) => + changedIndexes.has(index) ? { ...file, content: `remote-${index}` } : file + ); + writeWorkspace(original); + mockManifest(remote); + remote.forEach((file, index) => { + if (changedIndexes.has(index)) { + mockAxiosGet(fileUrl(file.path), Buffer.from(file.content), { etag: eTag(file.content) }); + } + }); + + await new WorkspaceService(testContext).pull(); + + const bodyReads = (mockedAxiosInstance.get as jest.Mock).mock.calls.filter(([url]) => url !== manifestUrl()); + expect(bodyReads).toHaveLength(3); + changedIndexes.forEach((index) => { + expect(fs.readFileSync(path.join(process.cwd(), remote[index].path), "utf-8")).toBe(`remote-${index}`); + }); + }); + + it("limits pull to a selected directory and leaves omitted remote changes untouched", async () => { + const original = [ + { nodeKey: "node-1", path: "Selected/One.md", content: "one" }, + { nodeKey: "node-2", path: "Selected/Nested/Two.md", content: "two" }, + { nodeKey: "node-3", path: "Unselected/Three.md", content: "three" }, + ]; + const remote = original.map((file) => ({ ...file, content: `${file.content} remote` })); + writeWorkspace(original); + mockManifest(remote); + remote.slice(0, 2).forEach((file) => { + mockAxiosGet(fileUrl(file.path), Buffer.from(file.content), { etag: eTag(file.content) }); + }); + + await new WorkspaceService(testContext).pull(["selected"]); + + expect(fs.readFileSync(path.join(process.cwd(), remote[0].path), "utf-8")).toBe("one remote"); + expect(fs.readFileSync(path.join(process.cwd(), remote[1].path), "utf-8")).toBe("two remote"); + expect(fs.readFileSync(path.join(process.cwd(), remote[2].path), "utf-8")).toBe("three"); + expect((mockedAxiosInstance.get as jest.Mock).mock.calls.filter(([url]) => url !== manifestUrl())).toHaveLength( + 2 + ); + }); + + it("applies clean remote additions moves deletions and modifications by node identity", async () => { + const original = [ + { nodeKey: "node-1", path: "Guides/Modified.md", content: "one" }, + { nodeKey: "node-2", path: "Old/Moved.md", content: "two" }, + { nodeKey: "node-3", path: "Deleted/Gone.md", content: "three" }, + ]; + const remote = [ + { ...original[0], content: "one remote" }, + { ...original[1], path: "New/Moved.md" }, + { nodeKey: "node-4", path: "Added/New.md", content: "four" }, + ]; + writeWorkspace(original); + mockManifest(remote); + mockAxiosGet(fileUrl(remote[0].path), Buffer.from(remote[0].content), { etag: eTag(remote[0].content) }); + mockAxiosGet(fileUrl(remote[2].path), Buffer.from(remote[2].content), { etag: eTag(remote[2].content) }); + + await new WorkspaceService(testContext).pull(); + + expect(fs.readFileSync(path.join(process.cwd(), remote[0].path), "utf-8")).toBe("one remote"); + expect(fs.readFileSync(path.join(process.cwd(), remote[1].path), "utf-8")).toBe("two"); + expect(fs.existsSync(path.join(process.cwd(), original[1].path))).toBe(false); + expect(fs.existsSync(path.join(process.cwd(), original[2].path))).toBe(false); + expect(fs.readFileSync(path.join(process.cwd(), remote[2].path), "utf-8")).toBe("four"); + expect(fs.existsSync(path.join(process.cwd(), "Old"))).toBe(false); + expect(fs.existsSync(path.join(process.cwd(), "Deleted"))).toBe(false); + expect(fs.statSync(path.join(process.cwd(), "New")).isDirectory()).toBe(true); + expect(fs.statSync(path.join(process.cwd(), "Added")).isDirectory()).toBe(true); + expect(new WorkspaceService(testContext).status()).toEqual([]); + }); + + it("updates a file after its parent folder moves remotely", async () => { + const original = [{ nodeKey: "node-1", path: "Source/Shared/Guide.md", content: "original" }]; + const remote = [{ nodeKey: "node-1", path: "Target/Shared/Guide.md", content: "remote" }]; + writeWorkspace(original); + const sharedKey = `folder-${createHash("sha256").update("Source/Shared").digest("hex").slice(0, 12)}`; + const remoteManifest = JSON.parse(manifest(remote).toString("utf-8")); + const sharedFolder = remoteManifest.nodes.find((node) => node.path === "Target/Shared"); + sharedFolder.nodeKey = sharedKey; + remoteManifest.nodes.find((node) => node.nodeKey === "node-1").metadata.parentNodeKey = sharedKey; + mockAxiosGet(manifestUrl(), Buffer.from(JSON.stringify(remoteManifest)), { etag: eTag("manifest") }); + mockAxiosGet(fileUrl(remote[0].path), Buffer.from(remote[0].content), { etag: eTag(remote[0].content) }); + + await new WorkspaceService(testContext).pull(); + + expect(fs.existsSync(path.join(process.cwd(), "Source"))).toBe(false); + expect(fs.readFileSync(path.join(process.cwd(), remote[0].path), "utf-8")).toBe("remote"); + expect(new WorkspaceService(testContext).status()).toEqual([]); + }); + + it("pulls a workspace whose root metadata still names the concrete Package parent", async () => { + const original = [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]; + writeWorkspace(original); + const folderMetadataPath = path.join(process.cwd(), ".package", "nodes", "folder-1.json"); + const folderMetadata = JSON.parse(fs.readFileSync(folderMetadataPath, "utf-8")); + fs.writeFileSync(folderMetadataPath, JSON.stringify({ ...folderMetadata, parentNodeKey: PACKAGE_KEY })); + mockManifest(original); + + await new WorkspaceService(testContext).pull(); + + expect(new WorkspaceService(testContext).status()).toEqual([]); + expect(JSON.parse(fs.readFileSync(folderMetadataPath, "utf-8"))).toMatchObject({ + parentNodeKey: null, + }); + }); + + it("deletes nested remote folders deepest first", async () => { + const original = [{ nodeKey: "node-1", path: "Deleted/Parent/Child/Guide.md", content: "one" }]; + writeWorkspace(original); + mockManifest([]); + + await new WorkspaceService(testContext).pull(); + + expect(fs.existsSync(path.join(process.cwd(), "Deleted"))).toBe(false); + expect(fs.readdirSync(path.join(process.cwd(), ".package", "nodes"))).toEqual([]); + expect(new WorkspaceService(testContext).status()).toEqual([]); + }); + + it("reports a local and remote conflict while advancing an independent successful node", async () => { + const original = [ + { nodeKey: "node-1", path: "Guides/Conflict.md", content: "one" }, + { nodeKey: "node-2", path: "Guides/Success.md", content: "two" }, + ]; + const remote = [ + { ...original[0], content: "one remote" }, + { ...original[1], content: "two remote" }, + ]; + writeWorkspace(original); + fs.writeFileSync(path.join(process.cwd(), original[0].path), "one local"); + mockManifest(remote); + mockAxiosGet(fileUrl(remote[1].path), Buffer.from(remote[1].content), { etag: eTag(remote[1].content) }); + + await expect(new WorkspaceService(testContext).pull()).rejects.toThrow("Workspace pull failed for 1 node(s)"); + + expect(fs.readFileSync(path.join(process.cwd(), original[0].path), "utf-8")).toBe("one local"); + expect(fs.readFileSync(path.join(process.cwd(), original[1].path), "utf-8")).toBe("two remote"); + const localState = JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package/local/state.json"), "utf-8")); + expect(localState.baselineDigests).toEqual({ + "node-1": digest("one"), + "node-2": digest("two remote"), + }); + expect(localState).not.toHaveProperty("serverRevision"); + }); + + it("hydrates local state after an external Git restore without overwriting files", async () => { + writeWorkspace(); + fs.rmSync(path.join(process.cwd(), ".package", "local"), { recursive: true }); + fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "git change"); + mockManifest( + [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }], + PACKAGE_KEY, + eTag("manifest"), + true + ); + + await new WorkspaceService(testContext).pull(); + + expect(fs.readFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "utf-8")).toBe("git change"); + expect(new WorkspaceService(testContext).status()).toEqual([{ path: "Guides/Guide.md", status: "modified" }]); + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package", "local", "state.json"), "utf-8")) + ).toMatchObject({ baselineDigests: { "node-1": digest("remote") } }); + }); + + it("restores the mapped Pacman branch after an external Git restore", async () => { + writeWorkspace(); + fs.rmSync(path.join(process.cwd(), ".package", "local"), { recursive: true }); + fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "git branch content"); + const observation = { branch: "git-feature", head: "b".repeat(40) }; + const git = mockGit(observation, BRANCH); + mockManifest( + [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "branch baseline" }], + BRANCH_PACKAGE_KEY, + eTag("manifest"), + true + ); + + await new WorkspaceService(testContext, git).pull(); + + expect(fs.readFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "utf-8")).toBe("git branch content"); + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package", "local", "state.json"), "utf-8")) + ).toMatchObject({ + activePackageKey: BRANCH_PACKAGE_KEY, + activeBranch: BRANCH, + git: observation, + }); + }); + + it("hydrates a Git-restored workspace with CRLF metadata ignore rules", async () => { + writeWorkspace(); + fs.rmSync(path.join(process.cwd(), ".package", "local"), { recursive: true }); + fs.writeFileSync(path.join(process.cwd(), ".package", ".gitignore"), "local/\r\n"); + mockManifest( + [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }], + PACKAGE_KEY, + eTag("manifest"), + true + ); + + const service = new WorkspaceService(testContext); + await service.pull(); + + expect(service.status()).toEqual([]); + }); + + it("keeps Git-restored path drift as a move for the next push", async () => { + writeWorkspace([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "original" }]); + fs.rmSync(path.join(process.cwd(), ".package", "local"), { recursive: true }); + mockManifest( + [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }], + PACKAGE_KEY, + eTag("manifest"), + true + ); + const service = new WorkspaceService(testContext); + + await service.pull(); + + expect(service.status()).toEqual([{ path: "Pages/Guide.md", status: "moved" }]); + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package", "local", "state.json"), "utf-8")) + ).toMatchObject({ + moveHints: { + "node-1": { sourcePath: "Guides/Guide.md", targetPath: "Pages/Guide.md" }, + }, + }); + + mockAxiosGet(fileUrl("Guides/Guide.md"), Buffer.from("original"), { etag: eTag("file-1") }); + mockAxiosPatch(fileUrl("Guides/Guide.md"), { + path: "Pages/Guide.md", + nodeKey: "node-1", + assetType: "MARKDOWN_FILE", + eTag: eTag("file-2"), + }); + mockManifest([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "original" }]); + await service.push(); + + expect(mockedAxiosInstance.patch).toHaveBeenCalledWith( + fileUrl("Guides/Guide.md"), + JSON.stringify({ targetPath: "Pages/Guide.md" }), + expect.objectContaining({ headers: expect.objectContaining({ "If-Match": eTag("file-1") }) }) + ); + expect(service.status()).toEqual([]); + }); + + it("preserves local-only changes during an incremental pull", async () => { + writeWorkspace(); + fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "local"); + + mockManifest([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]); + + await new WorkspaceService(testContext).pull(); + expect(fs.readFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "utf-8")).toBe("local"); + }); + + it("rejects manifest metadata containing a legacy filesystem name", async () => { + writeWorkspace(); + const body = JSON.parse( + manifest([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]).toString() + ); + body.nodes.find((node: { nodeKey: string }) => node.nodeKey === "node-1").metadata.additionalFields = { + filesystemName: "Renamed.md", + }; + mockAxiosGet(manifestUrl(), Buffer.from(JSON.stringify(body)), { etag: eTag("manifest") }); + + await expect(new WorkspaceService(testContext).pull()).rejects.toThrow("Unsupported workspace manifest"); + }); + + it("rejects legacy manifest shape and nested Node identity", async () => { + writeWorkspace(); + const body = JSON.parse( + manifest([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]).toString() + ); + const file = body.nodes.find((node: { nodeKey: string }) => node.nodeKey === "node-1"); + file.kind = "file"; + file.assetType = file.metadata.type; + file.size = 8; + file.metadata.key = file.nodeKey; + mockAxiosGet(manifestUrl(), Buffer.from(JSON.stringify(body)), { etag: eTag("manifest") }); + + await expect(new WorkspaceService(testContext).pull()).rejects.toThrow("Unsupported workspace manifest"); + }); + + it("rejects a manifest path that does not match derived Node metadata", async () => { + writeWorkspace(); + const body = JSON.parse( + manifest([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]).toString() + ); + body.nodes.find((node: { nodeKey: string }) => node.nodeKey === "node-1").path = "Guides/Renamed.md"; + mockAxiosGet(manifestUrl(), Buffer.from(JSON.stringify(body)), { etag: eTag("manifest") }); + + await expect(new WorkspaceService(testContext).pull()).rejects.toThrow( + "path that does not match Node metadata" + ); + }); + + it("infers an unchanged move performed by another tool without writing path state", () => { + writeWorkspace(); + fs.mkdirSync(path.join(process.cwd(), "Pages")); + fs.renameSync(path.join(process.cwd(), "Guides", "Guide.md"), path.join(process.cwd(), "Pages", "Guide.md")); + + expect(new WorkspaceService(testContext).status()).toEqual([{ path: "Pages/Guide.md", status: "moved" }]); + const workspaceState = JSON.parse( + fs.readFileSync(path.join(process.cwd(), ".package", "local", "state.json"), "utf-8") + ); + expect(workspaceState).not.toHaveProperty("files"); + expect(workspaceState).not.toHaveProperty("basePath"); + expect(workspaceState).not.toHaveProperty("currentPath"); + expect(workspaceState.moveHints).toEqual({}); + }); + + it("requires an exceptional hint for a move followed by an edit", () => { + writeWorkspace(); + fs.mkdirSync(path.join(process.cwd(), "Pages")); + fs.renameSync(path.join(process.cwd(), "Guides", "Guide.md"), path.join(process.cwd(), "Pages", "Guide.md")); + fs.writeFileSync(path.join(process.cwd(), "Pages", "Guide.md"), "changed"); + const service = new WorkspaceService(testContext); + + expect(service.status()).toEqual([{ path: "Pages/Guide.md", status: "unresolved" }]); + + service.move("Guides/Guide.md", "Pages/Guide.md", true); + + expect(service.status()).toEqual([{ path: "Pages/Guide.md", status: "moved, modified" }]); + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package", "local", "state.json"), "utf-8")) + ).toMatchObject({ + moveHints: { "node-1": "Pages/Guide.md" }, + }); + }); + + it("records a move when the source casing differs from metadata", () => { + writeWorkspace(); + fs.mkdirSync(path.join(process.cwd(), "Pages")); + fs.renameSync(path.join(process.cwd(), "Guides", "Guide.md"), path.join(process.cwd(), "Pages", "Guide.md")); + const service = new WorkspaceService(testContext); + + service.move("guides/guide.md", "Pages/Guide.md", true); + + expect(service.status()).toEqual([{ path: "Pages/Guide.md", status: "moved" }]); + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package", "local", "state.json"), "utf-8")) + ).toMatchObject({ moveHints: { "node-1": "Pages/Guide.md" } }); + }); + + it("reports duplicate digest move candidates as unresolved", () => { + writeWorkspace([ + { nodeKey: "node-1", path: "Guides/One.md", content: "same" }, + { nodeKey: "node-2", path: "Guides/Two.md", content: "same" }, + ]); + fs.mkdirSync(path.join(process.cwd(), "New")); + fs.renameSync(path.join(process.cwd(), "Guides", "One.md"), path.join(process.cwd(), "New", "Alpha.md")); + fs.renameSync(path.join(process.cwd(), "Guides", "Two.md"), path.join(process.cwd(), "New", "Beta.md")); + + const changes = new WorkspaceService(testContext).status(); + + expect(changes).toHaveLength(4); + expect(changes.every((change) => change.status === "unresolved")).toBe(true); + }); + + it("reports clean, modified, added, and deleted files", () => { + writeWorkspace(); + const service = new WorkspaceService(testContext); + expect(service.status()).toEqual([]); + + fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "changed"); + expect(service.status()).toEqual([{ path: "Guides/Guide.md", status: "modified" }]); + + fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "original"); + fs.writeFileSync(path.join(process.cwd(), "Other.md"), "new"); + expect(service.status()).toEqual([{ path: "Other.md", status: "added" }]); + + fs.rmSync(path.join(process.cwd(), "Other.md")); + fs.rmSync(path.join(process.cwd(), "Guides", "Guide.md")); + expect(service.status()).toEqual([{ path: "Guides/Guide.md", status: "deleted" }]); + }); + + it("moves a tracked file without maintaining a path index", () => { + writeWorkspace(); + const service = new WorkspaceService(testContext); + + service.move("Guides/Guide.md", "Pages/Guide.md"); + + expect(service.status()).toEqual([{ path: "Pages/Guide.md", status: "moved" }]); + expect(fs.existsSync(path.join(process.cwd(), "Pages", "Guide.md"))).toBe(true); + }); + + it("rejects an explicit filename-only rename", () => { + writeWorkspace(); + + expect(() => new WorkspaceService(testContext).move("Guides/Guide.md", "Pages/Renamed.md")).toThrow( + "Filename-only rename is not supported" + ); + expect(fs.existsSync(path.join(process.cwd(), "Guides", "Guide.md"))).toBe(true); + }); + + it("reports an unchanged filename rename by another tool as unresolved", () => { + writeWorkspace(); + fs.renameSync(path.join(process.cwd(), "Guides", "Guide.md"), path.join(process.cwd(), "Guides", "Renamed.md")); + + expect(new WorkspaceService(testContext).status()).toEqual([ + { path: "Guides/Renamed.md", status: "unresolved" }, + ]); + }); + + it("records an explicit move after the source was edited", () => { + writeWorkspace(); + fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "changed"); + const service = new WorkspaceService(testContext); + + service.move("Guides/Guide.md", "Pages/Guide.md"); + + expect(service.status()).toEqual([{ path: "Pages/Guide.md", status: "moved, modified" }]); + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package", "local", "state.json"), "utf-8")) + ).toMatchObject({ + moveHints: { "node-1": "Pages/Guide.md" }, + }); + }); + + it("rejects a move onto another metadata-derived path", () => { + writeWorkspace([ + { nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }, + { nodeKey: "node-2", path: "Pages/Guide.md", content: "other" }, + ]); + + expect(() => new WorkspaceService(testContext).move("Guides/Guide.md", "Pages/Guide.md")).toThrow( + "Target path is already tracked" + ); + expect(fs.existsSync(path.join(process.cwd(), "Guides", "Guide.md"))).toBe(true); + }); + + it("rejects a move for an untracked source", () => { + writeWorkspace(); + + expect(() => new WorkspaceService(testContext).move("Other.md", "Pages/Other.md")).toThrow( + "Tracked file not found" + ); + }); + + it("rejects a recorded move when the target is missing", () => { + writeWorkspace(); + + expect(() => new WorkspaceService(testContext).move("Guides/Guide.md", "Pages/Guide.md", true)).toThrow( + "Moved file not found" + ); + }); + + it("rejects a missing source and an existing untracked target", () => { + writeWorkspace(); + const source = path.join(process.cwd(), "Guides", "Guide.md"); + const target = path.join(process.cwd(), "Pages", "Guide.md"); + fs.rmSync(source); + + expect(() => new WorkspaceService(testContext).move("Guides/Guide.md", "Pages/Guide.md")).toThrow( + "Tracked file not found" + ); + + fs.mkdirSync(path.dirname(target)); + fs.writeFileSync(source, "original"); + fs.writeFileSync(target, "other"); + expect(() => new WorkspaceService(testContext).move("Guides/Guide.md", "Pages/Guide.md")).toThrow( + "Target already exists" + ); + }); + + it("rejects circular node metadata", () => { + writeWorkspace(); + const folderPath = path.join(process.cwd(), ".package", "nodes", "folder-1.json"); + const folder = JSON.parse(fs.readFileSync(folderPath, "utf-8")); + folder.parentNodeKey = "folder-1"; + fs.writeFileSync(folderPath, JSON.stringify(folder)); + + expect(() => new WorkspaceService(testContext).status()).toThrow("Circular node hierarchy"); + }); + + it("rejects volatile fields in stable node metadata", () => { + writeWorkspace(); + const nodePath = path.join(process.cwd(), ".package", "nodes", "node-1.json"); + const node = JSON.parse(fs.readFileSync(nodePath, "utf-8")); + node.changeDate = "2026-08-19T12:00:00Z"; + fs.writeFileSync(nodePath, JSON.stringify(node)); + + expect(() => new WorkspaceService(testContext).status()).toThrow("Invalid node metadata file"); + }); + + it("derives stable Node-key suffixes for colliding display names", () => { + writeWorkspace([ + { nodeKey: "node-1", path: "Guides/One.md", content: "one" }, + { nodeKey: "node-2", path: "Guides/Two.md", content: "two" }, + ]); + const secondPath = path.join(process.cwd(), ".package", "nodes", "node-2.json"); + const second = JSON.parse(fs.readFileSync(secondPath, "utf-8")); + second.name = "One"; + fs.writeFileSync(secondPath, JSON.stringify(second)); + const firstLeaf = `One~${createHash("sha256").update("node-1").digest("hex").slice(0, 12)}.md`; + const secondLeaf = `One~${createHash("sha256").update("node-2").digest("hex").slice(0, 12)}.md`; + fs.renameSync(path.join(process.cwd(), "Guides", "One.md"), path.join(process.cwd(), "Guides", firstLeaf)); + fs.renameSync(path.join(process.cwd(), "Guides", "Two.md"), path.join(process.cwd(), "Guides", secondLeaf)); + + expect(new WorkspaceService(testContext).status()).toEqual([]); + }); + + it("rejects legacy filesystem names in stable Node metadata", () => { + writeWorkspace(); + const nodePath = path.join(process.cwd(), ".package", "nodes", "node-1.json"); + const node = JSON.parse(fs.readFileSync(nodePath, "utf-8")); + node.additionalFields = { filesystemName: "Renamed.md" }; + fs.writeFileSync(nodePath, JSON.stringify(node)); + + expect(() => new WorkspaceService(testContext).status()).toThrow("Invalid node metadata file"); + }); + + it("rejects duplicate case-insensitive paths in the visible tree", () => { + writeWorkspace(); + const originalReaddir = fs.readdirSync; + const fileEntry = (name: string) => ({ + name, + isSymbolicLink: () => false, + isDirectory: () => false, + isFile: () => true, + }); + const readdir = jest.spyOn(fs, "readdirSync").mockImplementation((( + directory: fs.PathLike, + options?: object + ) => { + if (path.resolve(directory.toString()) === process.cwd()) { + return [fileEntry("Visible.md"), fileEntry("visible.md")]; + } + return originalReaddir(directory, options as never); + }) as never); + const service = new WorkspaceService(testContext); + const digestingService = service as unknown as { digest(file: string): string }; + jest.spyOn(digestingService, "digest").mockReturnValue(digest("visible")); + + try { + expect(() => service.status()).toThrow("duplicate case-insensitive paths"); + } finally { + readdir.mockRestore(); + } + }); + + it("rejects a case-only filename rename", () => { + writeWorkspace(); + const source = path.join(process.cwd(), "Guides", "Guide.md"); + const target = path.join(process.cwd(), "Guides", "guide.md"); + const existsSync = fs.existsSync; + const lstatSync = fs.lstatSync; + const exists = jest.spyOn(fs, "existsSync").mockImplementation((candidate) => { + return candidate.toString() === target || existsSync(candidate); + }); + const lstat = jest.spyOn(fs, "lstatSync").mockImplementation((candidate) => { + return candidate.toString() === target ? lstatSync(source) : lstatSync(candidate); + }); + + try { + const service = new WorkspaceService(testContext); + expect(() => service.move("Guides/Guide.md", "Guides/guide.md")).toThrow( + "Filename-only rename is not supported" + ); + } finally { + lstat.mockRestore(); + exists.mockRestore(); + } + }); + + it("rejects clone over an existing destination", async () => { + fs.mkdirSync(path.join(process.cwd(), PACKAGE_KEY)); + + await expect(new WorkspaceService(testContext).clone(PACKAGE_KEY)).rejects.toThrow( + "Destination already exists" + ); + }); + + it("pushes only three changed files from a one hundred file workspace", async () => { + const original = Array.from({ length: 100 }, (_, index) => ({ + nodeKey: `node-${index}`, + path: `Bulk/File-${index}.md`, + content: `original-${index}`, + })); + const changedIndexes = new Set([1, 50, 99]); + writeWorkspace(original); + original.forEach((file, index) => { + if (!changedIndexes.has(index)) { + return; + } + fs.writeFileSync(path.join(process.cwd(), file.path), `changed-${index}`); + mockAxiosGet(fileUrl(file.path), Buffer.from(file.content), { etag: eTag(`file-${index}`) }); + mockAxiosPut(fileWriteUrl(file.path), { + path: file.path, + nodeKey: file.nodeKey, + assetType: "MARKDOWN_FILE", + eTag: eTag(`changed-${index}`), + }); + }); + const remote = original.map((file, index) => ({ + ...file, + content: changedIndexes.has(index) ? `changed-${index}` : file.content, + })); + mockManifest(remote); + + await new WorkspaceService(testContext).push(); + + expect(mockedAxiosInstance.put).toHaveBeenCalledTimes(3); + expect(mockedAxiosInstance.patch).not.toHaveBeenCalled(); + expect(mockedAxiosInstance.delete).not.toHaveBeenCalled(); + expect(new WorkspaceService(testContext).status()).toEqual([]); + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package/local/state.json"), "utf-8")) + ).not.toHaveProperty("serverRevision"); + }); + + it("limits a path-selected push and does not treat omitted paths as deletions", async () => { + const original = [ + { nodeKey: "node-1", path: "Selected/One.md", content: "one" }, + { nodeKey: "node-2", path: "Selected/Two.md", content: "two" }, + { nodeKey: "node-3", path: "Unselected/Three.md", content: "three" }, + ]; + writeWorkspace(original); + fs.writeFileSync(path.join(process.cwd(), "Selected/One.md"), "one changed"); + fs.writeFileSync(path.join(process.cwd(), "Selected/Two.md"), "two changed"); + fs.rmSync(path.join(process.cwd(), "Unselected/Three.md")); + mockAxiosGet(fileUrl("Selected/One.md"), Buffer.from("one"), { etag: eTag("one") }); + mockAxiosPut(fileWriteUrl("Selected/One.md"), { + path: "Selected/One.md", + nodeKey: "node-1", + assetType: "MARKDOWN_FILE", + eTag: eTag("one changed"), + }); + mockManifest([{ ...original[0], content: "one changed" }, original[1], original[2]]); + + await new WorkspaceService(testContext).push(["Selected/One.md"]); + + expect(mockedAxiosInstance.put).toHaveBeenCalledTimes(1); + expect(mockedAxiosInstance.delete).not.toHaveBeenCalled(); + expect(new WorkspaceService(testContext).status()).toEqual([ + { path: "Selected/Two.md", status: "modified" }, + { path: "Unselected/Three.md", status: "deleted" }, + ]); + }); + + it("does not report the whole workspace clean when selected push paths have no changes", async () => { + const files = [ + { nodeKey: "node-1", path: "Selected/Clean.md", content: "clean" }, + { nodeKey: "node-2", path: "Unselected/Dirty.md", content: "original" }, + ]; + writeWorkspace(files); + fs.writeFileSync(path.join(process.cwd(), "Unselected/Dirty.md"), "changed"); + const info = jest.spyOn(logger, "info"); + + await new WorkspaceService(testContext).push(["Selected/Clean.md"]); + + expect(info).toHaveBeenCalledWith("Selected paths have no changes."); + expect(info).not.toHaveBeenCalledWith("Workspace is clean."); + expect(mockedAxiosInstance.put).not.toHaveBeenCalled(); + expect(new WorkspaceService(testContext).status()).toEqual([ + { path: "Unselected/Dirty.md", status: "modified" }, + ]); + }); + + it("preserves move hints for files omitted from a path-selected push", async () => { + const original = [ + { nodeKey: "node-1", path: "Guides/One.md", content: "one" }, + { nodeKey: "node-2", path: "Guides/Two.md", content: "two" }, + ]; + writeWorkspace(original); + const service = new WorkspaceService(testContext); + service.move("Guides/One.md", "Pages/One.md"); + service.move("Guides/Two.md", "Pages/Two.md"); + mockAxiosGet(fileUrl("Guides/One.md"), Buffer.from("one"), { etag: eTag("one") }); + mockAxiosPatch(fileUrl("Guides/One.md"), { + path: "Pages/One.md", + nodeKey: "node-1", + assetType: "MARKDOWN_FILE", + eTag: eTag("one-moved"), + }); + mockManifest([{ ...original[0], path: "Pages/One.md" }, original[1]]); + + await service.push(["Pages/One.md"]); + + expect(service.status()).toEqual([{ path: "Pages/Two.md", status: "moved" }]); + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package/local/state.json"), "utf-8")) + ).toMatchObject({ moveHints: { "node-2": "Pages/Two.md" } }); + }); + + it("expands a case-insensitive selected directory to its changed descendants", async () => { + const original = [ + { nodeKey: "node-1", path: "Selected/One.md", content: "one" }, + { nodeKey: "node-2", path: "Selected/Nested/Two.md", content: "two" }, + { nodeKey: "node-3", path: "Unselected/Three.md", content: "three" }, + ]; + writeWorkspace(original); + original.forEach((file) => fs.writeFileSync(path.join(process.cwd(), file.path), `${file.content} changed`)); + original.slice(0, 2).forEach((file) => { + mockAxiosGet(fileUrl(file.path), Buffer.from(file.content), { etag: eTag(file.nodeKey) }); + mockAxiosPut(fileWriteUrl(file.path), { + path: file.path, + nodeKey: file.nodeKey, + assetType: "MARKDOWN_FILE", + eTag: eTag(`${file.nodeKey}-changed`), + }); + }); + mockManifest( + original.map((file, index) => (index < 2 ? { ...file, content: `${file.content} changed` } : file)) + ); + + await new WorkspaceService(testContext).push(["selected"]); + + expect(mockedAxiosInstance.put).toHaveBeenCalledTimes(2); + expect(new WorkspaceService(testContext).status()).toEqual([ + { path: "Unselected/Three.md", status: "modified" }, + ]); + }); + + it("sends the metadata Asset Type for an addition", async () => { + const local = { nodeKey: "local-node", path: "Guides/New.md", content: "new" }; + const remote = { ...local, nodeKey: "server-node" }; + writeWorkspace([local]); + const statePath = path.join(process.cwd(), ".package", "local", "state.json"); + fs.writeFileSync(statePath, JSON.stringify({ ...state([local]), baselineDigests: {} })); + mockAxiosPut(fileWriteUrl(local.path), { + path: local.path, + nodeKey: remote.nodeKey, + assetType: "MARKDOWN_FILE", + eTag: eTag("new"), + }); + mockManifest([remote]); + + await new WorkspaceService(testContext).push(); + + expect(mockedAxiosInstance.put).toHaveBeenCalledWith( + fileWriteUrl(local.path), + Buffer.from("new"), + expect.objectContaining({ headers: expect.objectContaining({ "If-None-Match": "*" }) }) + ); + expect((mockedAxiosInstance.put as jest.Mock).mock.calls[0][0]).toContain("assetType=md"); + expect(new WorkspaceService(testContext).status()).toEqual([]); + expect(fs.existsSync(path.join(process.cwd(), ".package/nodes/local-node.json"))).toBe(false); + expect(fs.existsSync(path.join(process.cwd(), ".package/nodes/server-node.json"))).toBe(true); + }); + + it("requires and sends an explicit Asset Type for a bare new file", async () => { + const remote = { nodeKey: "server-node", path: "New.md", content: "new", assetType: "MARKDOWN_FILE" }; + writeWorkspace([]); + fs.writeFileSync(path.join(process.cwd(), remote.path), remote.content); + mockAxiosPut(fileWriteUrl(remote.path, remote.assetType), { + path: remote.path, + nodeKey: remote.nodeKey, + assetType: remote.assetType, + eTag: eTag(remote.content), + }); + mockManifest([remote]); + + await new WorkspaceService(testContext).push([remote.path], { assetType: remote.assetType }); + + expect(mockedAxiosInstance.put).toHaveBeenCalledWith( + fileWriteUrl(remote.path, remote.assetType), + Buffer.from(remote.content), + expect.objectContaining({ headers: expect.objectContaining({ "If-None-Match": "*" }) }) + ); + expect(new WorkspaceService(testContext).status()).toEqual([]); + }); + + it("rejects a bare new file without an Asset Type", async () => { + writeWorkspace([]); + fs.writeFileSync(path.join(process.cwd(), "New.md"), "new"); + + await expect(new WorkspaceService(testContext).push(["New.md"])).rejects.toThrow( + "Workspace push failed for 1 path(s)" + ); + + expect(mockedAxiosInstance.put).not.toHaveBeenCalled(); + }); + + it("pushes an empty directory through the folder API", async () => { + const files = [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]; + writeWorkspace(files); + fs.mkdirSync(path.join(process.cwd(), "Empty")); + mockAxiosPut(folderUrl("Empty"), { + path: "Empty", + nodeKey: "empty-folder", + assetType: "FOLDER", + eTag: eTag("empty-folder"), + }); + const remoteManifest = JSON.parse(manifest(files).toString("utf-8")); + remoteManifest.nodes.push({ + nodeKey: "empty-folder", + path: "Empty", + eTag: eTag("empty-folder"), + metadata: { name: "Empty", type: "FOLDER", parentNodeKey: null }, + }); + mockAxiosGet(manifestUrl(), Buffer.from(JSON.stringify(remoteManifest)), { etag: eTag("manifest-2") }); + + await new WorkspaceService(testContext).push(); + + expect(mockedAxiosInstance.put).toHaveBeenCalledWith( + folderUrl("Empty"), + Buffer.alloc(0), + expect.objectContaining({ headers: expect.objectContaining({ "If-None-Match": "*" }) }) + ); + expect(new WorkspaceService(testContext).status()).toEqual([]); + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package/nodes/empty-folder.json"), "utf-8")) + ).toMatchObject({ name: "Empty", type: "FOLDER" }); + }); + + it("retains refresh recovery when a server-created file no longer matches locally", async () => { + const local = { nodeKey: "local-node", path: "Guides/New.md", content: "new" }; + const remote = { ...local, nodeKey: "server-node" }; + writeWorkspace([local]); + const statePath = path.join(process.cwd(), ".package", "local", "state.json"); + fs.writeFileSync(statePath, JSON.stringify({ ...state([local]), baselineDigests: {} })); + mockAxiosPut(fileWriteUrl(local.path), { + path: local.path, + nodeKey: remote.nodeKey, + assetType: "MARKDOWN_FILE", + eTag: eTag("new"), + }); + mockAxiosGetError(manifestUrl(), 503, { message: "unavailable" }); + const service = new WorkspaceService(testContext, mockGit(undefined)); + + await expect(service.push()).rejects.toThrow("local synchronization state could not be refreshed"); + fs.writeFileSync(path.join(process.cwd(), local.path), "newer local edit"); + mockManifest([remote], PACKAGE_KEY, eTag("manifest"), true); + + await expect(service.pull()).rejects.toThrow("Workspace pull failed for 1 node(s)"); + + expect(fs.readFileSync(path.join(process.cwd(), local.path), "utf-8")).toBe("newer local edit"); + expect(JSON.parse(fs.readFileSync(statePath, "utf-8"))).toMatchObject({ + refreshRequired: true, + baselineDigests: {}, + }); + expect(fs.existsSync(path.join(process.cwd(), ".package/nodes/local-node.json"))).toBe(true); + expect(fs.existsSync(path.join(process.cwd(), ".package/nodes/server-node.json"))).toBe(false); + + fs.writeFileSync(path.join(process.cwd(), local.path), "new"); + mockManifest([remote]); + await service.pull(); + + expect(fs.existsSync(path.join(process.cwd(), ".package/nodes/local-node.json"))).toBe(false); + expect(fs.existsSync(path.join(process.cwd(), ".package/nodes/server-node.json"))).toBe(true); + expect(service.status()).toEqual([]); + expect(JSON.parse(fs.readFileSync(statePath, "utf-8"))).toMatchObject({ + baselineDigests: { "server-node": digest("new") }, + }); + expect(JSON.parse(fs.readFileSync(statePath, "utf-8"))).not.toHaveProperty("refreshRequired"); + }); + + it("recovers an untracked server-created file by path and digest", async () => { + const remote = { nodeKey: "server-node", path: "New.md", content: "new", assetType: "MARKDOWN_FILE" }; + writeWorkspace([]); + fs.writeFileSync(path.join(process.cwd(), remote.path), remote.content); + mockAxiosPut(fileWriteUrl(remote.path, "MARKDOWN_FILE"), { + path: remote.path, + nodeKey: remote.nodeKey, + assetType: "MARKDOWN_FILE", + eTag: eTag(remote.content), + }); + mockAxiosGetError(manifestUrl(), 503, { message: "unavailable" }); + const service = new WorkspaceService(testContext, mockGit(undefined)); + + await expect(service.push([], { assetType: remote.assetType })).rejects.toThrow( + "local synchronization state could not be refreshed" + ); + mockManifest([remote], PACKAGE_KEY, eTag("manifest"), true); + await service.pull(); + + expect(fs.readFileSync(path.join(process.cwd(), remote.path), "utf-8")).toBe(remote.content); + expect(fs.existsSync(path.join(process.cwd(), ".package/nodes/server-node.json"))).toBe(true); + expect(service.status()).toEqual([]); + const refreshed = JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package/local/state.json"), "utf-8")); + expect(refreshed).toMatchObject({ baselineDigests: { "server-node": digest(remote.content) } }); + expect(refreshed).not.toHaveProperty("refreshRequired"); + }); + + it("retains a stale file for retry when its conditional update fails", async () => { + writeWorkspace(); + fs.writeFileSync(path.join(process.cwd(), "Guides/Guide.md"), "changed"); + mockAxiosGet(fileUrl("Guides/Guide.md"), Buffer.from("original"), { etag: eTag("file-1") }); + mockAxiosPutError(fileWriteUrl("Guides/Guide.md"), 412, { message: "stale" }); + + await expect(new WorkspaceService(testContext).push()).rejects.toThrow("Workspace push failed for 1 path(s)"); + + expect(new WorkspaceService(testContext).status()).toEqual([{ path: "Guides/Guide.md", status: "modified" }]); + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package/local/state.json"), "utf-8")) + ).toMatchObject({ baselineDigests: { "node-1": digest("original") } }); + }); + + it("replays a weak file ETag unchanged for an incremental update", async () => { + const weakETag = `W/${eTag("file-1")}`; + writeWorkspace(); + fs.writeFileSync(path.join(process.cwd(), "Guides/Guide.md"), "changed"); + mockAxiosGet(fileUrl("Guides/Guide.md"), Buffer.from("original"), { etag: weakETag }); + mockAxiosPut(fileWriteUrl("Guides/Guide.md"), { + path: "Guides/Guide.md", + nodeKey: "node-1", + assetType: "MARKDOWN_FILE", + eTag: eTag("changed"), + }); + mockManifest([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "changed" }]); + + await new WorkspaceService(testContext).push(); + + expect(mockedAxiosInstance.put).toHaveBeenCalledWith( + fileWriteUrl("Guides/Guide.md"), + expect.any(Buffer), + expect.objectContaining({ headers: expect.objectContaining({ "If-Match": weakETag }) }) + ); + }); + + it("refreshes successful files while retaining partial failures for retry", async () => { + const original = [ + { nodeKey: "node-1", path: "Guides/One.md", content: "one" }, + { nodeKey: "node-2", path: "Guides/Two.md", content: "two" }, + ]; + writeWorkspace(original); + original.forEach((file) => fs.writeFileSync(path.join(process.cwd(), file.path), `${file.content} changed`)); + mockAxiosGet(fileUrl("Guides/One.md"), Buffer.from("one"), { etag: eTag("one") }); + mockAxiosGet(fileUrl("Guides/Two.md"), Buffer.from("two"), { etag: eTag("two") }); + mockAxiosPut(fileWriteUrl("Guides/One.md"), { + path: "Guides/One.md", + nodeKey: "node-1", + assetType: "MARKDOWN_FILE", + eTag: eTag("one changed"), + }); + mockAxiosPutError(fileWriteUrl("Guides/Two.md"), 412, { message: "stale" }); + mockManifest([{ ...original[0], content: "one changed" }, original[1]]); + + await expect(new WorkspaceService(testContext).push()).rejects.toThrow("Workspace push failed for 1 path(s)"); + + expect(new WorkspaceService(testContext).status()).toEqual([{ path: "Guides/Two.md", status: "modified" }]); + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package/local/state.json"), "utf-8")) + ).toMatchObject({ + baselineDigests: { "node-1": digest("one changed"), "node-2": digest("two") }, + }); + }); + + it("deletes a tracked file without pruning its parent folder", async () => { + writeWorkspace(); + fs.rmSync(path.join(process.cwd(), "Guides/Guide.md")); + mockAxiosGet(fileUrl("Guides/Guide.md"), Buffer.from("original"), { etag: eTag("file-1") }); + mockAxiosDelete(fileUrl("Guides/Guide.md")); + mockManifest([]); + + await new WorkspaceService(testContext).push(); + + expect(mockedAxiosInstance.delete).toHaveBeenCalledWith( + fileUrl("Guides/Guide.md"), + expect.objectContaining({ headers: expect.objectContaining({ "If-Match": eTag("file-1") }) }) + ); + expect(new WorkspaceService(testContext).status()).toEqual([]); + }); + + it("moves then updates a moved and edited file", async () => { + writeWorkspace(); + fs.mkdirSync(path.join(process.cwd(), "Pages")); + fs.renameSync(path.join(process.cwd(), "Guides/Guide.md"), path.join(process.cwd(), "Pages/Guide.md")); + fs.writeFileSync(path.join(process.cwd(), "Pages/Guide.md"), "changed"); + const service = new WorkspaceService(testContext); + service.move("Guides/Guide.md", "Pages/Guide.md", true); + mockAxiosGet(fileUrl("Guides/Guide.md"), Buffer.from("original"), { etag: eTag("file-1") }); + mockAxiosPatch(fileUrl("Guides/Guide.md"), { + path: "Pages/Guide.md", + nodeKey: "node-1", + assetType: "MARKDOWN_FILE", + eTag: eTag("file-2"), + }); + mockAxiosPut(fileWriteUrl("Pages/Guide.md"), { + path: "Pages/Guide.md", + nodeKey: "node-1", + assetType: "MARKDOWN_FILE", + eTag: eTag("file-3"), + }); + mockManifest([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "changed" }]); + + await service.push(); + + expect(mockedAxiosInstance.patch).toHaveBeenCalledWith( + fileUrl("Guides/Guide.md"), + JSON.stringify({ targetPath: "Pages/Guide.md" }), + expect.objectContaining({ headers: expect.objectContaining({ "If-Match": eTag("file-1") }) }) + ); + expect(mockedAxiosInstance.put).toHaveBeenCalledWith( + fileWriteUrl("Pages/Guide.md"), + Buffer.from("changed"), + expect.objectContaining({ headers: expect.objectContaining({ "If-Match": eTag("file-2") }) }) + ); + expect((mockedAxiosInstance.patch as jest.Mock).mock.invocationCallOrder[0]).toBeLessThan( + (mockedAxiosInstance.put as jest.Mock).mock.invocationCallOrder[0] + ); + expect(service.status()).toEqual([]); + }); + + it("moves a tracked file before creating a replacement at its old path", async () => { + writeWorkspace(); + const service = new WorkspaceService(testContext); + service.move("Guides/Guide.md", "Pages/Guide.md"); + fs.writeFileSync(path.join(process.cwd(), "Guides/Guide.md"), "replacement"); + expect(service.status()).toEqual([ + { path: "Guides/Guide.md", status: "added" }, + { path: "Pages/Guide.md", status: "moved" }, + ]); + mockAxiosGet(fileUrl("Guides/Guide.md"), Buffer.from("original"), { etag: eTag("file-1") }); + mockAxiosPatch(fileUrl("Guides/Guide.md"), { + path: "Pages/Guide.md", + nodeKey: "node-1", + assetType: "MARKDOWN_FILE", + eTag: eTag("file-2"), + }); + mockAxiosPut(fileWriteUrl("Guides/Guide.md", "MARKDOWN_FILE"), { + path: "Guides/Guide.md", + nodeKey: "node-2", + assetType: "MARKDOWN_FILE", + eTag: eTag("file-3"), + }); + mockManifest([ + { nodeKey: "node-1", path: "Pages/Guide.md", content: "original" }, + { nodeKey: "node-2", path: "Guides/Guide.md", content: "replacement" }, + ]); + + await service.push([], { assetType: "MARKDOWN_FILE" }); + + expect((mockedAxiosInstance.patch as jest.Mock).mock.invocationCallOrder[0]).toBeLessThan( + (mockedAxiosInstance.put as jest.Mock).mock.invocationCallOrder[0] + ); + expect(service.status()).toEqual([]); + }); + + it("refreshes after a moved file is relocated but its content update fails", async () => { + writeWorkspace(); + const service = new WorkspaceService(testContext); + service.move("Guides/Guide.md", "Pages/Guide.md"); + fs.writeFileSync(path.join(process.cwd(), "Pages/Guide.md"), "changed"); + mockAxiosGet(fileUrl("Guides/Guide.md"), Buffer.from("original"), { etag: eTag("file-1") }); + mockAxiosPatch(fileUrl("Guides/Guide.md"), { + path: "Pages/Guide.md", + nodeKey: "node-1", + assetType: "MARKDOWN_FILE", + eTag: eTag("file-2"), + }); + mockAxiosPutError(fileWriteUrl("Pages/Guide.md"), 412, { message: "stale" }); + mockManifest([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "original" }]); + + await expect(service.push()).rejects.toThrow("Workspace push failed for 1 path(s)"); + + expect(service.status()).toEqual([{ path: "Pages/Guide.md", status: "modified" }]); + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package/local/state.json"), "utf-8")) + ).toMatchObject({ + baselineDigests: { "node-1": digest("original") }, + moveHints: {}, + }); + }); + + it("clears an applied move hint when an incremental push refresh is recovered by pull", async () => { + writeWorkspace(); + fs.mkdirSync(path.join(process.cwd(), "Pages")); + fs.renameSync(path.join(process.cwd(), "Guides", "Guide.md"), path.join(process.cwd(), "Pages", "Guide.md")); + const service = new WorkspaceService(testContext); + service.move("Guides/Guide.md", "Pages/Guide.md", true); + mockAxiosGet(fileUrl("Guides/Guide.md"), Buffer.from("original"), { etag: eTag("file-1") }); + mockAxiosPatch(fileUrl("Guides/Guide.md"), { + path: "Pages/Guide.md", + nodeKey: "node-1", + assetType: "MARKDOWN_FILE", + eTag: eTag("file-2"), + }); + mockAxiosGetError(manifestUrl(), 503, { message: "unavailable" }); + + await expect(service.push()).rejects.toThrow("local synchronization state could not be refreshed"); + + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package", "local", "state.json"), "utf-8")) + ).toMatchObject({ refreshRequired: true, moveHints: {} }); + mockManifest([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "original" }]); + await service.pull(); + + expect(service.status()).toEqual([]); + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package", "local", "state.json"), "utf-8")) + ).toMatchObject({ moveHints: {} }); + }); + + it("does not push unresolved file identities", async () => { + writeWorkspace(); + fs.mkdirSync(path.join(process.cwd(), "Pages")); + fs.renameSync(path.join(process.cwd(), "Guides", "Guide.md"), path.join(process.cwd(), "Pages", "Guide.md")); + fs.writeFileSync(path.join(process.cwd(), "Pages", "Guide.md"), "changed"); + + await expect(new WorkspaceService(testContext).push()).rejects.toThrow("Workspace push failed for 1 path(s)"); + expect(mockedAxiosInstance.post).not.toHaveBeenCalled(); + }); + + it("reserves the metadata directory case-insensitively", () => { + writeWorkspace(); + + expect(() => new WorkspaceService(testContext).move("Guides/Guide.md", ".PACKAGE/Guide.md")).toThrow( + "Invalid workspace path" + ); + }); +}); diff --git a/tests/core/utils/file.service.spec.ts b/tests/core/utils/file.service.spec.ts index dcd71be6..9e25f707 100644 --- a/tests/core/utils/file.service.spec.ts +++ b/tests/core/utils/file.service.spec.ts @@ -98,6 +98,22 @@ describe("FileService", () => { expect(entries).toContain("nodes/node-1.json"); expect(entries.some(name => name.endsWith(".zip"))).toBe(false); }); + + test("Should exclude filtered workspace paths", () => { + const metadata = path.join(tempDir, ".package"); + fs.mkdirSync(path.join(metadata, "local"), { recursive: true }); + fs.writeFileSync(path.join(metadata, "package.json"), "{}"); + fs.writeFileSync(path.join(metadata, "local", "state.json"), "{}"); + + const zipPath = fileService.zipDirectoryAsSinglePackage( + tempDir, + relativePath => !relativePath.startsWith(".package/local") + ); + const entries = new AdmZip(zipPath).getEntries().map(entry => entry.entryName); + + expect(entries).toContain(".package/package.json"); + expect(entries).not.toContain(".package/local/state.json"); + }); }); describe("writeBufferToFileWithGivenName", () => { diff --git a/tests/utls/http-requests-mock.ts b/tests/utls/http-requests-mock.ts index 50734e5b..025d99b1 100644 --- a/tests/utls/http-requests-mock.ts +++ b/tests/utls/http-requests-mock.ts @@ -1,5 +1,5 @@ import { AxiosInstance } from "axios"; -import {Readable} from "stream"; +import { Readable } from "stream"; import { AxiosInitializer } from "../../src/core/http/axios-initializer"; const mockedAxiosInstance = {} as AxiosInstance; @@ -8,19 +8,24 @@ const CUI_PDF_COVER_PATH = "/api/team/cui-settings/cui-pdf-cover"; const mockedGetResponseByUrl = new Map(); const mockedGetStatusByUrl = new Map(); +const mockedGetHeadersByUrl = new Map>(); const mockedGetErrorByUrl = new Map(); const mockedPostResponseByUrl = new Map(); const mockedPostErrorByUrl = new Map(); const mockedPostRequestBodyByUrl = new Map(); const mockedPutErrorByUrl = new Map(); const mockedDeleteResponseByUrl = new Map(); +const mockedDeleteErrorByUrl = new Map(); +const mockedPatchResponseByUrl = new Map(); +const mockedPatchErrorByUrl = new Map(); -const mockAxios = () : void => { +const mockAxios = (): void => { AxiosInitializer.initializeAxios = jest.fn().mockReturnValue(mockedAxiosInstance); mockedAxiosInstance.get = jest.fn(); mockedAxiosInstance.post = jest.fn(); mockedAxiosInstance.put = jest.fn(); + mockedAxiosInstance.patch = jest.fn(); mockedAxiosInstance.delete = jest.fn(); (mockedAxiosInstance.get as jest.Mock).mockImplementation((requestUrl: string) => { @@ -34,11 +39,12 @@ const mockAxios = () : void => { if (data instanceof Buffer) { const readableStream = new Readable(); - readableStream.push(data) + readableStream.push(data); readableStream.push(null); return Promise.resolve({ - status: 200, + status, data: readableStream, + headers: mockedGetHeadersByUrl.get(requestUrl) || {}, }); } else { return Promise.resolve({ status, data }); @@ -49,7 +55,7 @@ const mockAxios = () : void => { if (requestUrl.endsWith(CUI_PDF_COVER_PATH)) { return Promise.resolve({ status: 403, data: "" }); } - fail("API call not mocked.") + fail("API call not mocked."); }); (mockedAxiosInstance.post as jest.Mock).mockImplementation((requestUrl: string, data: any) => { @@ -63,7 +69,7 @@ const mockAxios = () : void => { return Promise.resolve(response); } - fail("API call not mocked.") + fail("API call not mocked."); }); (mockedAxiosInstance.put as jest.Mock).mockImplementation((requestUrl: string, data: any) => { @@ -77,19 +83,48 @@ const mockAxios = () : void => { return Promise.resolve(response); } - fail("API call not mocked.") + fail("API call not mocked."); }); -} -const mockAxiosGet = (url: string, responseData: any) => { + (mockedAxiosInstance.patch as jest.Mock).mockImplementation((requestUrl: string) => { + if (mockedPatchErrorByUrl.has(requestUrl)) { + const { status, data } = mockedPatchErrorByUrl.get(requestUrl)!; + return Promise.reject({ response: { status, data } }); + } + if (mockedPatchResponseByUrl.has(requestUrl)) { + return Promise.resolve({ data: mockedPatchResponseByUrl.get(requestUrl) }); + } + fail("API call not mocked."); + }); + + (mockedAxiosInstance.delete as jest.Mock).mockImplementation((requestUrl: string) => { + if (mockedDeleteErrorByUrl.has(requestUrl)) { + const { status, data } = mockedDeleteErrorByUrl.get(requestUrl)!; + return Promise.reject({ response: { status, data } }); + } + if (mockedDeleteResponseByUrl.has(requestUrl)) { + return Promise.resolve({ data: undefined, status: 204 }); + } + fail("API call not mocked."); + }); +}; + +const mockAxiosGet = (url: string, responseData: any, headers: Record = {}) => { mockedGetResponseByUrl.set(url, responseData); + mockedGetHeadersByUrl.set(url, headers); mockedGetStatusByUrl.delete(url); mockedGetErrorByUrl.delete(url); }; -const mockAxiosGetWithStatus = (url: string, status: number, responseData: any) => { +const mockAxiosGetWithStatus = ( + url: string, + status: number, + responseData: any, + headers: Record = {} +) => { mockedGetResponseByUrl.set(url, responseData); mockedGetStatusByUrl.set(url, status); + mockedGetHeadersByUrl.set(url, headers); mockedGetErrorByUrl.delete(url); }; @@ -120,25 +155,37 @@ const mockAxiosPutError = (url: string, status: number, data: any) => { const mockAxiosDelete = (url: string) => { mockedDeleteResponseByUrl.set(url, undefined); - (mockedAxiosInstance.delete as jest.Mock).mockImplementation((requestUrl: string) => { - if (mockedDeleteResponseByUrl.has(requestUrl)) { - return Promise.resolve({ data: undefined, status: 204 }); - } else { - fail("API call not mocked.") - } - }) -} +}; + +const mockAxiosDeleteError = (url: string, status: number, data: any) => { + mockedDeleteErrorByUrl.set(url, { status, data }); + mockedDeleteResponseByUrl.delete(url); +}; + +const mockAxiosPatch = (url: string, responseData: any) => { + mockedPatchResponseByUrl.set(url, responseData); + mockedPatchErrorByUrl.delete(url); +}; + +const mockAxiosPatchError = (url: string, status: number, data: any) => { + mockedPatchErrorByUrl.set(url, { status, data }); + mockedPatchResponseByUrl.delete(url); +}; afterEach(() => { mockedGetResponseByUrl.clear(); mockedGetStatusByUrl.clear(); + mockedGetHeadersByUrl.clear(); mockedGetErrorByUrl.clear(); mockedPostResponseByUrl.clear(); mockedPostErrorByUrl.clear(); mockedPostRequestBodyByUrl.clear(); mockedPutErrorByUrl.clear(); mockedDeleteResponseByUrl.clear(); -}) + mockedDeleteErrorByUrl.clear(); + mockedPatchResponseByUrl.clear(); + mockedPatchErrorByUrl.clear(); +}); export { mockedAxiosInstance, @@ -151,5 +198,8 @@ export { mockAxiosPut, mockAxiosPutError, mockAxiosDelete, - mockedPostRequestBodyByUrl + mockAxiosDeleteError, + mockAxiosPatch, + mockAxiosPatchError, + mockedPostRequestBodyByUrl, };