From 3dabc47ccd49dfbac37ae0de89eb0457a60424b5 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Wed, 19 Aug 2026 15:13:01 +0200 Subject: [PATCH 01/46] Add beta package workspace commands Includes-AI-Code: true --- .github/CODEOWNERS | 2 + src/commands/workspace/module.ts | 50 +++++ src/commands/workspace/workspace-api.ts | 16 ++ src/commands/workspace/workspace.service.ts | 179 ++++++++++++++++++ tests/commands/workspace/module.spec.ts | 19 ++ .../workspace/workspace.service.spec.ts | 85 +++++++++ 6 files changed, 351 insertions(+) create mode 100644 src/commands/workspace/module.ts create mode 100644 src/commands/workspace/workspace-api.ts create mode 100644 src/commands/workspace/workspace.service.ts create mode 100644 tests/commands/workspace/module.spec.ts create mode 100644 tests/commands/workspace/workspace.service.spec.ts 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..88db102f --- /dev/null +++ b/src/commands/workspace/module.ts @@ -0,0 +1,50 @@ +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"; + +class Module extends IModule { + public register(context: Context, configurator: Configurator): void { + const workspace = configurator.command("workspace").beta().description("Manage a package workspace."); + + workspace + .command("checkout [directory]") + .beta() + .description("Check out a package.") + .action(this.checkout); + + workspace.command("status [directory]").beta().description("Show local changes.").action(this.status); + + workspace + .command("push [directory]") + .beta() + .description("Push local changes.") + .option("--overwrite", "Replace missing remote files", false) + .action(this.push); + + workspace + .command("move ") + .beta() + .description("Move a tracked file.") + .option("--record", "Record an existing move", false) + .action(this.move); + } + + private async checkout(context: Context, command: Command): Promise { + await new WorkspaceService(context).checkout(command.args[0], command.args[1]); + } + + private async status(context: Context, command: Command): Promise { + new WorkspaceService(context).status(command.args[0]); + } + + private async push(context: Context, command: Command, options: OptionValues): Promise { + await new WorkspaceService(context).push(command.args[0], options.overwrite); + } + + private async move(context: Context, command: Command, options: OptionValues): Promise { + 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..981a821f --- /dev/null +++ b/src/commands/workspace/workspace-api.ts @@ -0,0 +1,16 @@ +import * as FormData from "form-data"; +import { Context } from "../../core/command/cli-context"; + +export class WorkspaceApi { + constructor(private readonly context: Context) {} + + public checkout(packageKey: string): Promise { + return this.context.httpClient.getFile( + `/pacman/api/core/staging/packages/${encodeURIComponent(packageKey)}/file-archive` + ); + } + + public push(data: FormData, overwrite: boolean): Promise { + return this.context.httpClient.postFile("/pacman/api/core/staging/packages/file-archive", data, { overwrite }); + } +} diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts new file mode 100644 index 00000000..353ef60c --- /dev/null +++ b/src/commands/workspace/workspace.service.ts @@ -0,0 +1,179 @@ +import { createHash } from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as FormData from "form-data"; +import AdmZip = require("adm-zip"); +import { Context } from "../../core/command/cli-context"; +import { fileService } from "../../core/utils/file-service"; +import { GracefulError, logger } from "../../core/utils/logger"; +import { WorkspaceApi } from "./workspace-api"; + +interface WorkspaceFile { + nodeKey: string; + basePath: string; + currentPath: string; + digest: string; +} + +interface WorkspaceIndex { + version: number; + packageKey: string; + files: WorkspaceFile[]; +} + +export interface WorkspaceChange { + path: string; + status: "deleted" | "modified" | "moved" | "moved, modified"; +} + +export class WorkspaceService { + private readonly api: WorkspaceApi; + + constructor(context: Context) { + this.api = new WorkspaceApi(context); + } + + public async checkout(packageKey: string, directory?: string): Promise { + const target = path.resolve(process.cwd(), directory || packageKey); + if (fs.existsSync(target)) { + throw new GracefulError(`Destination already exists: ${target}`); + } + const archive = await this.api.checkout(packageKey); + const zip = new AdmZip(archive); + if (!zip.getEntry(".pacman/index.json")) { + throw new GracefulError("Archive does not contain .pacman/index.json"); + } + const temporary = fileService.extractZipBufferToTempDirectory(archive); + try { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.renameSync(temporary, target); + } catch (error) { + fs.rmSync(temporary, { recursive: true, force: true }); + throw error; + } + logger.info(`Checked out ${packageKey} to ${target}`); + } + + public status(directory?: string): WorkspaceChange[] { + const root = this.root(directory); + const index = this.index(root); + const changes = index.files.flatMap(file => { + const absolutePath = this.resolveVisiblePath(root, file.currentPath); + if (!fs.existsSync(absolutePath)) { + return [{ path: file.currentPath, status: "deleted" as const }]; + } + const moved = file.basePath !== file.currentPath; + const modified = this.digest(absolutePath) !== file.digest; + if (!moved && !modified) { + return []; + } + const status = moved && modified ? "moved, modified" : moved ? "moved" : "modified"; + return [{ path: file.currentPath, status } as WorkspaceChange]; + }); + if (changes.length === 0) { + logger.info("Workspace is clean."); + } else { + changes.forEach(change => logger.info(`${change.status}: ${change.path}`)); + } + return changes; + } + + public async push(directory?: string, overwrite: boolean = false): Promise { + const root = this.root(directory); + const index = this.index(root); + index.files.forEach(file => { + if (!fs.existsSync(this.resolveVisiblePath(root, file.currentPath))) { + throw new GracefulError(`Tracked file is missing: ${file.currentPath}`); + } + }); + const zipPath = fileService.zipDirectoryAsSinglePackage(root); + try { + const form = new FormData(); + form.append("packageFile", fs.createReadStream(zipPath), { filename: "workspace.zip" }); + await this.api.push(form, overwrite); + } finally { + fs.rmSync(zipPath, { force: true }); + } + logger.info(`Pushed ${index.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 index = this.index(root); + const tracked = index.files.find(file => file.currentPath === sourcePath); + if (!tracked) { + throw new GracefulError(`Tracked file not found: ${sourcePath}`); + } + 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}`); + } + } else { + if (fs.existsSync(absoluteTarget)) { + throw new GracefulError(`Target already exists: ${targetPath}`); + } + fs.mkdirSync(path.dirname(absoluteTarget), { recursive: true }); + fs.renameSync(absoluteSource, absoluteTarget); + } + tracked.currentPath = targetPath; + fs.writeFileSync(this.indexPath(root), JSON.stringify(index, null, 2) + "\n", { mode: 0o600 }); + logger.info(`Recorded move: ${sourcePath} -> ${targetPath}`); + } + + private root(directory?: string): string { + let current = path.resolve(process.cwd(), directory || "."); + while (!fs.existsSync(this.indexPath(current))) { + const parent = path.dirname(current); + if (parent === current) { + throw new GracefulError("No Pacman workspace found."); + } + current = parent; + } + return current; + } + + private index(root: string): WorkspaceIndex { + const parsed = JSON.parse(fs.readFileSync(this.indexPath(root), "utf-8")) as WorkspaceIndex; + if (parsed.version !== 1 || !parsed.packageKey || !Array.isArray(parsed.files)) { + throw new GracefulError("Unsupported Pacman workspace index."); + } + return parsed; + } + + 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("/"); + if ( + !normalized || + path.isAbsolute(value) || + normalized === ".." || + normalized === ".pacman" || + normalized.startsWith(".pacman/") || + normalized.startsWith("../") + ) { + throw new GracefulError(`Invalid workspace path: ${value}`); + } + return normalized; + } + + private indexPath(root: string): string { + return path.join(root, ".pacman", "index.json"); + } + + private digest(file: string): string { + return `sha256:${createHash("sha256").update(fs.readFileSync(file)).digest("hex")}`; + } +} diff --git a/tests/commands/workspace/module.spec.ts b/tests/commands/workspace/module.spec.ts new file mode 100644 index 00000000..15780fed --- /dev/null +++ b/tests/commands/workspace/module.spec.ts @@ -0,0 +1,19 @@ +import Module = require("../../../src/commands/workspace/module"); +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("checkout [directory]"); + expect(configurator.command).toHaveBeenCalledWith("status [directory]"); + expect(configurator.command).toHaveBeenCalledWith("push [directory]"); + expect(configurator.command).toHaveBeenCalledWith("move "); + expect(configurator.beta).toHaveBeenCalledTimes(5); + expect(configurator.action).toHaveBeenCalledTimes(4); + }); +}); diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts new file mode 100644 index 00000000..a0232682 --- /dev/null +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -0,0 +1,85 @@ +import { createHash } from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import AdmZip = require("adm-zip"); +import { WorkspaceService } from "../../../src/commands/workspace/workspace.service"; +import { testContext } from "../../utls/test-context"; +import { mockAxiosGet, mockAxiosPost, mockedAxiosInstance } from "../../utls/http-requests-mock"; + +const PACKAGE_KEY = "pkg-1"; +const CHECKOUT_URL = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${PACKAGE_KEY}/file-archive`; +const PUSH_URL = "https://myTeam.celonis.cloud/pacman/api/core/staging/packages/file-archive"; + +function digest(value: string): string { + return `sha256:${createHash("sha256").update(value).digest("hex")}`; +} + +function index(basePath: string = "Guides/Guide.md", currentPath: string = basePath): object { + return { + version: 1, + packageKey: PACKAGE_KEY, + files: [{ nodeKey: "node-1", basePath, currentPath, digest: digest("original") }], + }; +} + +function writeWorkspace(): void { + fs.mkdirSync(path.join(process.cwd(), ".pacman"), { recursive: true }); + fs.mkdirSync(path.join(process.cwd(), "Guides"), { recursive: true }); + fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "original"); + fs.writeFileSync(path.join(process.cwd(), ".pacman", "index.json"), JSON.stringify(index())); +} + +describe("Workspace service", () => { + beforeEach(() => { + [".pacman", "Guides", "Pages", PACKAGE_KEY].forEach((entry) => + fs.rmSync(path.join(process.cwd(), entry), { recursive: true, force: true }) + ); + }); + + it("checks out a filesystem archive", async () => { + const zip = new AdmZip(); + zip.addFile(".pacman/index.json", Buffer.from(JSON.stringify(index()))); + zip.addFile("Guides/Guide.md", Buffer.from("original")); + mockAxiosGet(CHECKOUT_URL, zip.toBuffer()); + + await new WorkspaceService(testContext).checkout(PACKAGE_KEY); + + expect(fs.readFileSync(path.join(process.cwd(), PACKAGE_KEY, "Guides", "Guide.md"), "utf-8")).toBe("original"); + }); + + it("records a move and reports later content changes together", () => { + writeWorkspace(); + const service = new WorkspaceService(testContext); + + service.move("Guides/Guide.md", "Pages/Guide.md"); + fs.writeFileSync(path.join(process.cwd(), "Pages", "Guide.md"), "changed"); + + expect(service.status()).toEqual([{ path: "Pages/Guide.md", status: "moved, modified" }]); + expect(JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "index.json"), "utf-8"))).toMatchObject({ + files: [{ nodeKey: "node-1", currentPath: "Pages/Guide.md" }], + }); + }); + + it("records a move already performed by another tool", () => { + writeWorkspace(); + fs.mkdirSync(path.join(process.cwd(), "Pages")); + fs.renameSync(path.join(process.cwd(), "Guides", "Guide.md"), path.join(process.cwd(), "Pages", "Guide.md")); + + new WorkspaceService(testContext).move("Guides/Guide.md", "Pages/Guide.md", true); + + expect(fs.existsSync(path.join(process.cwd(), "Pages", "Guide.md"))).toBe(true); + }); + + it("pushes the workspace archive", async () => { + writeWorkspace(); + mockAxiosPost(PUSH_URL, {}); + + await new WorkspaceService(testContext).push(undefined, true); + + expect(mockedAxiosInstance.post).toHaveBeenCalledWith( + PUSH_URL, + expect.anything(), + expect.objectContaining({ params: { overwrite: true } }) + ); + }); +}); From 41320c917124be526d36459b1721e592c8bba305 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Wed, 19 Aug 2026 15:39:31 +0200 Subject: [PATCH 02/46] Raise workspace command coverage Includes-AI-Code: true --- src/commands/workspace/workspace.service.ts | 5 +- .../workspace/workspace.service.spec.ts | 47 +++++++++++++++++-- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index 353ef60c..f40031c2 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -67,7 +67,10 @@ export class WorkspaceService { if (!moved && !modified) { return []; } - const status = moved && modified ? "moved, modified" : moved ? "moved" : "modified"; + let status: WorkspaceChange["status"] = "modified"; + if (moved) { + status = modified ? "moved, modified" : "moved"; + } return [{ path: file.currentPath, status } as WorkspaceChange]; }); if (changes.length === 0) { diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index a0232682..6cba9944 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -29,12 +29,15 @@ function writeWorkspace(): void { fs.writeFileSync(path.join(process.cwd(), ".pacman", "index.json"), JSON.stringify(index())); } +function removeWorkspace(): void { + [".pacman", "Guides", "Pages", PACKAGE_KEY].forEach(entry => + fs.rmSync(path.join(process.cwd(), entry), { recursive: true, force: true }) + ); +} + describe("Workspace service", () => { - beforeEach(() => { - [".pacman", "Guides", "Pages", PACKAGE_KEY].forEach((entry) => - fs.rmSync(path.join(process.cwd(), entry), { recursive: true, force: true }) - ); - }); + beforeEach(removeWorkspace); + afterEach(removeWorkspace); it("checks out a filesystem archive", async () => { const zip = new AdmZip(); @@ -70,6 +73,40 @@ describe("Workspace service", () => { expect(fs.existsSync(path.join(process.cwd(), "Pages", "Guide.md"))).toBe(true); }); + it("reports clean, modified, moved, 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"); + service.move("Guides/Guide.md", "Pages/Guide.md"); + expect(service.status()).toEqual([{ path: "Pages/Guide.md", status: "moved" }]); + + fs.rmSync(path.join(process.cwd(), "Pages", "Guide.md")); + expect(service.status()).toEqual([{ path: "Pages/Guide.md", status: "deleted" }]); + }); + + it("rejects checkout over an existing destination", async () => { + fs.mkdirSync(path.join(process.cwd(), PACKAGE_KEY)); + + await expect(new WorkspaceService(testContext).checkout(PACKAGE_KEY)).rejects.toThrow( + "Destination already exists" + ); + }); + + it("rejects an archive without a workspace index", async () => { + const zip = new AdmZip(); + zip.addFile("Guides/Guide.md", Buffer.from("original")); + mockAxiosGet(CHECKOUT_URL, zip.toBuffer()); + + await expect(new WorkspaceService(testContext).checkout(PACKAGE_KEY)).rejects.toThrow( + "Archive does not contain .pacman/index.json" + ); + }); + it("pushes the workspace archive", async () => { writeWorkspace(); mockAxiosPost(PUSH_URL, {}); From c36ae12c2ba7fd8178d50c5b1560bad67f81ca6f Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Wed, 19 Aug 2026 15:57:52 +0200 Subject: [PATCH 03/46] Fix workspace checkout and push state Includes-AI-Code: true --- src/commands/workspace/workspace.service.ts | 55 ++++++++++++++++--- .../workspace/workspace.service.spec.ts | 41 ++++++++++++-- 2 files changed, 84 insertions(+), 12 deletions(-) diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index f40031c2..2431a596 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -44,12 +44,24 @@ export class WorkspaceService { throw new GracefulError("Archive does not contain .pacman/index.json"); } const temporary = fileService.extractZipBufferToTempDirectory(archive); + const parent = path.dirname(target); + let staging: string | undefined; try { - fs.mkdirSync(path.dirname(target), { recursive: true }); - fs.renameSync(temporary, target); + fs.mkdirSync(parent, { recursive: true }); + staging = fs.mkdtempSync(path.join(parent, ".pacman-checkout-")); + fs.rmSync(staging, { recursive: true }); + fs.cpSync(temporary, staging, { recursive: true, force: false, errorOnExist: true }); + if (fs.existsSync(target)) { + throw new GracefulError(`Destination already exists: ${target}`); + } + fs.renameSync(staging, target); } catch (error) { - fs.rmSync(temporary, { recursive: true, force: true }); + if (staging) { + fs.rmSync(staging, { recursive: true, force: true }); + } throw error; + } finally { + fs.rmSync(temporary, { recursive: true, force: true }); } logger.info(`Checked out ${packageKey} to ${target}`); } @@ -91,9 +103,11 @@ export class WorkspaceService { }); const zipPath = fileService.zipDirectoryAsSinglePackage(root); try { + const pushedIndex = this.pushedIndex(index, zipPath); const form = new FormData(); form.append("packageFile", fs.createReadStream(zipPath), { filename: "workspace.zip" }); await this.api.push(form, overwrite); + this.writeIndex(root, pushedIndex); } finally { fs.rmSync(zipPath, { force: true }); } @@ -123,7 +137,7 @@ export class WorkspaceService { fs.renameSync(absoluteSource, absoluteTarget); } tracked.currentPath = targetPath; - fs.writeFileSync(this.indexPath(root), JSON.stringify(index, null, 2) + "\n", { mode: 0o600 }); + this.writeIndex(root, index); logger.info(`Recorded move: ${sourcePath} -> ${targetPath}`); } @@ -159,12 +173,13 @@ export class WorkspaceService { private validateRelative(value: string): string { const normalized = value.split(path.sep).join("/"); + const folded = normalized.toLowerCase(); if ( !normalized || path.isAbsolute(value) || normalized === ".." || - normalized === ".pacman" || - normalized.startsWith(".pacman/") || + folded === ".pacman" || + folded.startsWith(".pacman/") || normalized.startsWith("../") ) { throw new GracefulError(`Invalid workspace path: ${value}`); @@ -176,7 +191,33 @@ export class WorkspaceService { return path.join(root, ".pacman", "index.json"); } + private pushedIndex(index: WorkspaceIndex, zipPath: string): WorkspaceIndex { + const archive = new AdmZip(zipPath); + return { + ...index, + files: index.files.map(file => { + const entry = archive.getEntry(file.currentPath); + if (!entry || entry.isDirectory) { + throw new GracefulError(`Tracked file is missing from archive: ${file.currentPath}`); + } + return { + ...file, + basePath: file.currentPath, + digest: this.digestContent(entry.getData()), + }; + }), + }; + } + + private writeIndex(root: string, index: WorkspaceIndex): void { + fs.writeFileSync(this.indexPath(root), JSON.stringify(index, null, 2) + "\n", { mode: 0o600 }); + } + private digest(file: string): string { - return `sha256:${createHash("sha256").update(fs.readFileSync(file)).digest("hex")}`; + return this.digestContent(fs.readFileSync(file)); + } + + private digestContent(content: Buffer): string { + return `sha256:${createHash("sha256").update(content).digest("hex")}`; } } diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index 6cba9944..5b3dccd1 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -44,10 +44,20 @@ describe("Workspace service", () => { zip.addFile(".pacman/index.json", Buffer.from(JSON.stringify(index()))); zip.addFile("Guides/Guide.md", Buffer.from("original")); mockAxiosGet(CHECKOUT_URL, zip.toBuffer()); - - await new WorkspaceService(testContext).checkout(PACKAGE_KEY); - - expect(fs.readFileSync(path.join(process.cwd(), PACKAGE_KEY, "Guides", "Guide.md"), "utf-8")).toBe("original"); + const rename = jest.spyOn(fs, "renameSync"); + + try { + await new WorkspaceService(testContext).checkout(PACKAGE_KEY); + + expect(fs.readFileSync(path.join(process.cwd(), PACKAGE_KEY, "Guides", "Guide.md"), "utf-8")).toBe( + "original" + ); + const checkoutRename = rename.mock.calls.find(call => call[1] === path.join(process.cwd(), PACKAGE_KEY)); + expect(checkoutRename).toBeDefined(); + expect(path.dirname(checkoutRename![0].toString())).toBe(path.dirname(checkoutRename![1].toString())); + } finally { + rename.mockRestore(); + } }); it("records a move and reports later content changes together", () => { @@ -110,13 +120,34 @@ describe("Workspace service", () => { it("pushes the workspace archive", async () => { writeWorkspace(); mockAxiosPost(PUSH_URL, {}); + const service = new WorkspaceService(testContext); + service.move("Guides/Guide.md", "Pages/Guide.md"); + fs.writeFileSync(path.join(process.cwd(), "Pages", "Guide.md"), "changed"); - await new WorkspaceService(testContext).push(undefined, true); + await service.push(undefined, true); expect(mockedAxiosInstance.post).toHaveBeenCalledWith( PUSH_URL, expect.anything(), expect.objectContaining({ params: { overwrite: true } }) ); + expect(service.status()).toEqual([]); + expect(JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "index.json"), "utf-8"))).toMatchObject({ + files: [ + { + basePath: "Pages/Guide.md", + currentPath: "Pages/Guide.md", + digest: digest("changed"), + }, + ], + }); + }); + + it("reserves the metadata directory case-insensitively", () => { + writeWorkspace(); + + expect(() => new WorkspaceService(testContext).move("Guides/Guide.md", ".PACMAN/Guide.md")).toThrow( + "Invalid workspace path" + ); }); }); From 63730f299c9e37a85fbf05f2480e27c61f1e1ef7 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Wed, 19 Aug 2026 16:14:13 +0200 Subject: [PATCH 04/46] Reject duplicate workspace move targets Includes-AI-Code: true --- src/commands/workspace/workspace.service.ts | 6 ++++++ .../workspace/workspace.service.spec.ts | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index 2431a596..5b2bcebc 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -123,6 +123,12 @@ export class WorkspaceService { if (!tracked) { throw new GracefulError(`Tracked file not found: ${sourcePath}`); } + const trackedTarget = index.files.find( + file => file.nodeKey !== tracked.nodeKey && file.currentPath.toLowerCase() === targetPath.toLowerCase() + ); + if (trackedTarget) { + throw new GracefulError(`Target path is already tracked: ${targetPath}`); + } const absoluteSource = this.resolveVisiblePath(root, sourcePath); const absoluteTarget = this.resolveVisiblePath(root, targetPath); if (recordOnly) { diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index 5b3dccd1..a7be86dd 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -83,6 +83,24 @@ describe("Workspace service", () => { expect(fs.existsSync(path.join(process.cwd(), "Pages", "Guide.md"))).toBe(true); }); + it("rejects a move onto another tracked path", () => { + writeWorkspace(); + const indexPath = path.join(process.cwd(), ".pacman", "index.json"); + const workspaceIndex = JSON.parse(fs.readFileSync(indexPath, "utf-8")); + workspaceIndex.files.push({ + nodeKey: "node-2", + basePath: "Pages/Guide.md", + currentPath: "Pages/Guide.md", + digest: digest("other"), + }); + fs.writeFileSync(indexPath, JSON.stringify(workspaceIndex)); + + 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("reports clean, modified, moved, and deleted files", () => { writeWorkspace(); const service = new WorkspaceService(testContext); From f0d5758d9445db74190cea57562e571d22edb597 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Wed, 19 Aug 2026 16:28:47 +0200 Subject: [PATCH 05/46] Support case-only workspace moves Includes-AI-Code: true --- src/commands/workspace/workspace.service.ts | 12 ++++++++- .../workspace/workspace.service.spec.ts | 25 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index 5b2bcebc..bb4c4c01 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -136,7 +136,7 @@ export class WorkspaceService { throw new GracefulError(`Moved file not found: ${targetPath}`); } } else { - if (fs.existsSync(absoluteTarget)) { + if (fs.existsSync(absoluteTarget) && !this.sameFile(absoluteSource, absoluteTarget)) { throw new GracefulError(`Target already exists: ${targetPath}`); } fs.mkdirSync(path.dirname(absoluteTarget), { recursive: true }); @@ -147,6 +147,16 @@ export class WorkspaceService { logger.info(`Recorded move: ${sourcePath} -> ${targetPath}`); } + 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.indexPath(current))) { diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index a7be86dd..de3787b3 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -101,6 +101,31 @@ describe("Workspace service", () => { expect(fs.existsSync(path.join(process.cwd(), "Guides", "Guide.md"))).toBe(true); }); + it("records a case-only move when the target resolves to the source file", () => { + 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 { + new WorkspaceService(testContext).move("Guides/Guide.md", "Guides/guide.md"); + + expect(JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "index.json"), "utf-8"))).toMatchObject({ + files: [{ nodeKey: "node-1", currentPath: "Guides/guide.md" }], + }); + } finally { + lstat.mockRestore(); + exists.mockRestore(); + } + }); + it("reports clean, modified, moved, and deleted files", () => { writeWorkspace(); const service = new WorkspaceService(testContext); From 1c363c3171e4dcf05edc5c302c9720a476aa3855 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Wed, 19 Aug 2026 16:35:03 +0200 Subject: [PATCH 06/46] Use boolean workspace target lookup Includes-AI-Code: true --- src/commands/workspace/workspace.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index bb4c4c01..84cd325b 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -123,10 +123,10 @@ export class WorkspaceService { if (!tracked) { throw new GracefulError(`Tracked file not found: ${sourcePath}`); } - const trackedTarget = index.files.find( + const targetIsTracked = index.files.some( file => file.nodeKey !== tracked.nodeKey && file.currentPath.toLowerCase() === targetPath.toLowerCase() ); - if (trackedTarget) { + if (targetIsTracked) { throw new GracefulError(`Target path is already tracked: ${targetPath}`); } const absoluteSource = this.resolveVisiblePath(root, sourcePath); From d52503a835288fdc934fd51f5a16661fde3a2123 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Wed, 19 Aug 2026 17:50:28 +0200 Subject: [PATCH 07/46] Infer package workspace moves Includes-AI-Code: true --- src/commands/workspace/workspace-api.ts | 8 +- src/commands/workspace/workspace.service.ts | 459 ++++++++++++++---- .../workspace/workspace.service.spec.ts | 238 ++++++--- 3 files changed, 551 insertions(+), 154 deletions(-) diff --git a/src/commands/workspace/workspace-api.ts b/src/commands/workspace/workspace-api.ts index 981a821f..e7d89994 100644 --- a/src/commands/workspace/workspace-api.ts +++ b/src/commands/workspace/workspace-api.ts @@ -10,7 +10,11 @@ export class WorkspaceApi { ); } - public push(data: FormData, overwrite: boolean): Promise { - return this.context.httpClient.postFile("/pacman/api/core/staging/packages/file-archive", data, { overwrite }); + public push(packageKey: string, data: FormData, overwrite: boolean): Promise { + return this.context.httpClient.postFile( + `/pacman/api/core/staging/packages/${encodeURIComponent(packageKey)}/file-archive`, + data, + { overwrite } + ); } } diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index 84cd325b..a5e1d4a8 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -8,22 +8,44 @@ import { fileService } from "../../core/utils/file-service"; import { GracefulError, logger } from "../../core/utils/logger"; import { WorkspaceApi } from "./workspace-api"; -interface WorkspaceFile { +interface WorkspaceState { + schemaVersion: number; + packageKey: string; + serverRevision: string; + baselineDigests: Record; + moveHints: Record; +} + +interface WorkspaceNodeMetadata { + key: string; + name: string; + type: string; + parentNodeKey?: string | null; + filesystemName?: string; + metadata?: Record; + additionalFields?: Record; +} + +interface ExpectedFile { nodeKey: string; - basePath: string; - currentPath: string; + path: string; digest: string; } -interface WorkspaceIndex { - version: number; - packageKey: string; - files: WorkspaceFile[]; +interface ClassifiedChange extends WorkspaceChange { + nodeKey?: string; +} + +interface WorkspaceSnapshot { + state: WorkspaceState; + expectedFiles: ExpectedFile[]; + visibleFiles: Map; + changes: ClassifiedChange[]; } export interface WorkspaceChange { path: string; - status: "deleted" | "modified" | "moved" | "moved, modified"; + status: "added" | "deleted" | "modified" | "moved" | "moved, modified" | "unresolved"; } export class WorkspaceService { @@ -40,13 +62,20 @@ export class WorkspaceService { } const archive = await this.api.checkout(packageKey); const zip = new AdmZip(archive); - if (!zip.getEntry(".pacman/index.json")) { - throw new GracefulError("Archive does not contain .pacman/index.json"); + if (!zip.getEntry(".pacman/state.json")) { + throw new GracefulError("Archive does not contain .pacman/state.json"); } const temporary = fileService.extractZipBufferToTempDirectory(archive); const parent = path.dirname(target); let staging: string | undefined; try { + const snapshot = this.snapshot(temporary); + if (snapshot.state.packageKey !== packageKey) { + throw new GracefulError("Archive package key does not match the requested package."); + } + if (snapshot.changes.length !== 0) { + throw new GracefulError("Archive content does not match its workspace baseline."); + } fs.mkdirSync(parent, { recursive: true }); staging = fs.mkdtempSync(path.join(parent, ".pacman-checkout-")); fs.rmSync(staging, { recursive: true }); @@ -67,24 +96,10 @@ export class WorkspaceService { } public status(directory?: string): WorkspaceChange[] { - const root = this.root(directory); - const index = this.index(root); - const changes = index.files.flatMap(file => { - const absolutePath = this.resolveVisiblePath(root, file.currentPath); - if (!fs.existsSync(absolutePath)) { - return [{ path: file.currentPath, status: "deleted" as const }]; - } - const moved = file.basePath !== file.currentPath; - const modified = this.digest(absolutePath) !== file.digest; - if (!moved && !modified) { - return []; - } - let status: WorkspaceChange["status"] = "modified"; - if (moved) { - status = modified ? "moved, modified" : "moved"; - } - return [{ path: file.currentPath, status } as 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 { @@ -95,38 +110,36 @@ export class WorkspaceService { public async push(directory?: string, overwrite: boolean = false): Promise { const root = this.root(directory); - const index = this.index(root); - index.files.forEach(file => { - if (!fs.existsSync(this.resolveVisiblePath(root, file.currentPath))) { - throw new GracefulError(`Tracked file is missing: ${file.currentPath}`); - } - }); + const snapshot = this.snapshot(root); + if (snapshot.changes.some(change => change.status === "unresolved")) { + throw new GracefulError("Workspace has unresolved file identities. Record the intended moves before push."); + } const zipPath = fileService.zipDirectoryAsSinglePackage(root); try { - const pushedIndex = this.pushedIndex(index, zipPath); const form = new FormData(); form.append("packageFile", fs.createReadStream(zipPath), { filename: "workspace.zip" }); - await this.api.push(form, overwrite); - this.writeIndex(root, pushedIndex); + await this.api.push(snapshot.state.packageKey, form, overwrite); + const refreshedArchive = await this.api.checkout(snapshot.state.packageKey); + this.refreshMetadata(root, refreshedArchive, snapshot.state.packageKey); } finally { fs.rmSync(zipPath, { force: true }); } - logger.info(`Pushed ${index.packageKey}.`); + logger.info(`Pushed ${snapshot.state.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 index = this.index(root); - const tracked = index.files.find(file => file.currentPath === sourcePath); + const snapshot = this.snapshot(root); + const tracked = this.trackedFile(snapshot, sourcePath); if (!tracked) { throw new GracefulError(`Tracked file not found: ${sourcePath}`); } - const targetIsTracked = index.files.some( - file => file.nodeKey !== tracked.nodeKey && file.currentPath.toLowerCase() === targetPath.toLowerCase() + const targetOwner = snapshot.expectedFiles.find( + file => file.nodeKey !== tracked.nodeKey && file.path.toLowerCase() === targetPath.toLowerCase() ); - if (targetIsTracked) { + if (targetOwner) { throw new GracefulError(`Target path is already tracked: ${targetPath}`); } const absoluteSource = this.resolveVisiblePath(root, sourcePath); @@ -135,16 +148,284 @@ export class WorkspaceService { if (!fs.existsSync(absoluteTarget)) { throw new GracefulError(`Moved file not found: ${targetPath}`); } - } else { - if (fs.existsSync(absoluteTarget) && !this.sameFile(absoluteSource, absoluteTarget)) { - throw new GracefulError(`Target already exists: ${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); + logger.info(`Moved: ${sourcePath} -> ${targetPath}`); + } + + private snapshot(root: string): WorkspaceSnapshot { + const state = this.state(root); + const expectedFiles = this.expectedFiles(root, state); + const visibleFiles = this.visibleFiles(root); + return { + state, + expectedFiles, + visibleFiles, + changes: this.classify(expectedFiles, visibleFiles, state.moveHints), + }; + } + + private classify( + expectedFiles: ExpectedFile[], + visibleFiles: Map, + moveHints: Record + ): ClassifiedChange[] { + const changes: ClassifiedChange[] = []; + const consumedPaths = new Set(); + const missing: ExpectedFile[] = []; + + expectedFiles.forEach(file => { + const hint = moveHints[file.nodeKey]; + if (hint && hint !== file.path) { + if (visibleFiles.has(file.path) || !visibleFiles.has(hint) || consumedPaths.has(hint)) { + changes.push({ nodeKey: file.nodeKey, path: hint, status: "unresolved" }); + if (visibleFiles.has(hint)) { + consumedPaths.add(hint); + } + if (visibleFiles.has(file.path)) { + consumedPaths.add(file.path); + } + return; + } + consumedPaths.add(hint); + changes.push({ + nodeKey: file.nodeKey, + path: hint, + status: visibleFiles.get(hint) === file.digest ? "moved" : "moved, modified", + }); + return; + } + if (visibleFiles.has(file.path)) { + consumedPaths.add(file.path); + if (visibleFiles.get(file.path) !== file.digest) { + changes.push({ nodeKey: file.nodeKey, path: file.path, status: "modified" }); + } + return; + } + missing.push(file); + }); + + const missingByDigest = this.groupBy(missing, file => file.digest); + missingByDigest.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: "moved" }); + 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" }); + }); + } + }); + + const newPaths = [...visibleFiles.keys()].filter(filePath => !consumedPaths.has(filePath)); + const movedAndEdited = this.possibleMovedAndEdited(missing, newPaths); + movedAndEdited.forEach(([file, filePath]) => { + changes.push({ nodeKey: file.nodeKey, path: filePath, status: "unresolved" }); + missing.splice(missing.indexOf(file), 1); + newPaths.splice(newPaths.indexOf(filePath), 1); + }); + missing.forEach(file => changes.push({ nodeKey: file.nodeKey, path: file.path, status: "deleted" })); + newPaths.forEach(filePath => changes.push({ path: filePath, status: "added" })); + + return changes.sort((left, right) => left.path.localeCompare(right.path) || left.status.localeCompare(right.status)); + } + + private possibleMovedAndEdited(missing: ExpectedFile[], newPaths: string[]): Array<[ExpectedFile, string]> { + const pairs: Array<[ExpectedFile, string]> = []; + const remainingPaths = new Set(newPaths); + missing.forEach(file => { + const matchingBasenames = [...remainingPaths].filter( + filePath => path.posix.basename(filePath).toLowerCase() === path.posix.basename(file.path).toLowerCase() + ); + if (matchingBasenames.length === 1) { + pairs.push([file, matchingBasenames[0]]); + remainingPaths.delete(matchingBasenames[0]); + } + }); + const pairedKeys = new Set(pairs.map(([file]) => file.nodeKey)); + const remainingMissing = missing.filter(file => !pairedKeys.has(file.nodeKey)); + if (remainingMissing.length === 1 && remainingPaths.size === 1) { + const candidate = [...remainingPaths][0]; + if (path.posix.extname(remainingMissing[0].path).toLowerCase() === path.posix.extname(candidate).toLowerCase()) { + pairs.push([remainingMissing[0], candidate]); + } + } + return pairs; + } + + private expectedFiles(root: string, state: WorkspaceState): ExpectedFile[] { + const nodes = this.nodes(root); + const byKey = new Map(nodes.map(node => [node.key, node])); + const pathByKey = new Map(); + const resolving = new Set(); + const resolvePath = (node: WorkspaceNodeMetadata): string => { + const cached = pathByKey.get(node.key); + if (cached) { + return cached; + } + if (!resolving.add(node.key)) { + throw new GracefulError(`Circular node hierarchy at ${node.key}.`); + } + const segment = this.filesystemName(node); + let filePath = segment; + if (node.parentNodeKey && node.parentNodeKey !== state.packageKey) { + const parent = byKey.get(node.parentNodeKey); + if (!parent || !this.isFolder(parent)) { + throw new GracefulError(`Invalid parent metadata for node ${node.key}.`); + } + filePath = `${resolvePath(parent)}/${segment}`; } - fs.mkdirSync(path.dirname(absoluteTarget), { recursive: true }); - fs.renameSync(absoluteSource, absoluteTarget); + resolving.delete(node.key); + pathByKey.set(node.key, filePath); + return filePath; + }; + const expected = nodes + .filter(node => !this.isFolder(node)) + .map(node => { + const baseline = state.baselineDigests[node.key]; + if (!baseline || !/^sha256:[0-9a-f]{64}$/.test(baseline)) { + throw new GracefulError(`Missing or invalid baseline digest for node ${node.key}.`); + } + return { nodeKey: node.key, path: resolvePath(node), digest: baseline }; + }); + const foldedPaths = new Set(); + expected.forEach(file => { + if (!foldedPaths.add(file.path.toLowerCase())) { + throw new GracefulError(`Duplicate workspace path in node metadata: ${file.path}`); + } + }); + Object.entries(state.moveHints).forEach(([nodeKey, targetPath]) => { + if (!byKey.has(nodeKey)) { + throw new GracefulError(`Move hint references unknown node ${nodeKey}.`); + } + state.moveHints[nodeKey] = this.validateRelative(targetPath); + }); + return expected.sort((left, right) => left.path.localeCompare(right.path)); + } + + private nodes(root: string): WorkspaceNodeMetadata[] { + const directory = path.join(root, ".pacman", "nodes"); + if (!fs.existsSync(directory)) { + throw new GracefulError("Workspace does not contain .pacman/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 node = JSON.parse(fs.readFileSync(path.join(directory, entry.name), "utf-8")) as WorkspaceNodeMetadata; + if (!node.key || !node.name || !node.type || `${node.key}.json` !== entry.name) { + throw new GracefulError(`Invalid node metadata file: ${entry.name}`); + } + return node; + }); + } + + private filesystemName(node: WorkspaceNodeMetadata): string { + const value = node.filesystemName || node.metadata?.filesystemName || node.additionalFields?.filesystemName; + if (typeof value !== "string" || !value || value.includes("/") || value.includes("\\")) { + throw new GracefulError(`Invalid filesystem name for node ${node.key}.`); + } + return this.validateRelative(value); + } + + private visibleFiles(root: string): Map { + const files = new Map(); + 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() === ".pacman") { + 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()) { + visit(absolute, relative); + return; + } + if (!entry.isFile()) { + throw new GracefulError(`Workspace contains an unsupported entry: ${relative}`); + } + const validated = this.validateRelative(relative); + if (!foldedPaths.add(validated.toLowerCase())) { + throw new GracefulError(`Workspace contains duplicate case-insensitive paths: ${validated}`); + } + files.set(validated, this.digest(absolute)); + }); + }; + visit(root, ""); + return files; + } + + private trackedFile(snapshot: WorkspaceSnapshot, sourcePath: string): ExpectedFile | undefined { + const expected = snapshot.expectedFiles.find(file => file.path === sourcePath); + if (expected) { + return expected; + } + const hinted = snapshot.expectedFiles.find(file => snapshot.state.moveHints[file.nodeKey] === sourcePath); + if (hinted) { + return hinted; + } + const classified = snapshot.changes.find( + change => change.path === sourcePath && change.nodeKey && (change.status === "moved" || change.status === "moved, modified") + ); + return classified ? snapshot.expectedFiles.find(file => file.nodeKey === classified.nodeKey) : undefined; + } + + private refreshMetadata(root: string, archive: Buffer, packageKey: string): void { + const zip = new AdmZip(archive); + if (!zip.getEntry(".pacman/state.json")) { + throw new GracefulError("Refreshed archive does not contain .pacman/state.json"); + } + const extracted = fileService.extractZipBufferToTempDirectory(archive); + const refreshRoot = fs.mkdtempSync(path.join(root, ".pacman-refresh-")); + const stagedMetadata = path.join(refreshRoot, "metadata"); + const previousMetadata = path.join(refreshRoot, "previous"); + const metadata = path.join(root, ".pacman"); + try { + const refreshed = this.snapshot(extracted); + if (refreshed.state.packageKey !== packageKey || refreshed.changes.length !== 0) { + throw new GracefulError("Refreshed archive metadata does not match its content."); + } + fs.cpSync(path.join(extracted, ".pacman"), stagedMetadata, { recursive: true }); + fs.renameSync(metadata, previousMetadata); + try { + fs.renameSync(stagedMetadata, metadata); + } catch (error) { + fs.renameSync(previousMetadata, metadata); + throw error; + } + } finally { + fs.rmSync(extracted, { recursive: true, force: true }); + fs.rmSync(refreshRoot, { recursive: true, force: true }); } - tracked.currentPath = targetPath; - this.writeIndex(root, index); - logger.info(`Recorded move: ${sourcePath} -> ${targetPath}`); } private sameFile(source: string, target: string): boolean { @@ -159,7 +440,7 @@ export class WorkspaceService { private root(directory?: string): string { let current = path.resolve(process.cwd(), directory || "."); - while (!fs.existsSync(this.indexPath(current))) { + while (!fs.existsSync(this.statePath(current))) { const parent = path.dirname(current); if (parent === current) { throw new GracefulError("No Pacman workspace found."); @@ -169,12 +450,32 @@ export class WorkspaceService { return current; } - private index(root: string): WorkspaceIndex { - const parsed = JSON.parse(fs.readFileSync(this.indexPath(root), "utf-8")) as WorkspaceIndex; - if (parsed.version !== 1 || !parsed.packageKey || !Array.isArray(parsed.files)) { - throw new GracefulError("Unsupported Pacman workspace index."); + private state(root: string): WorkspaceState { + const parsed = JSON.parse(fs.readFileSync(this.statePath(root), "utf-8")) as Partial; + if ( + parsed.schemaVersion !== 1 || + !parsed.packageKey || + !parsed.serverRevision || + !/^sha256:[0-9a-f]{64}$/.test(parsed.serverRevision) || + !parsed.baselineDigests || + typeof parsed.baselineDigests !== "object" || + Array.isArray(parsed.baselineDigests) || + !Object.values(parsed.baselineDigests).every(value => typeof value === "string") || + (parsed.moveHints !== undefined && + (typeof parsed.moveHints !== "object" || + parsed.moveHints === null || + Array.isArray(parsed.moveHints) || + !Object.values(parsed.moveHints).every(value => typeof value === "string"))) + ) { + throw new GracefulError("Unsupported Pacman workspace state."); } - return parsed; + return { + schemaVersion: parsed.schemaVersion, + packageKey: parsed.packageKey, + serverRevision: parsed.serverRevision, + baselineDigests: parsed.baselineDigests, + moveHints: parsed.moveHints || {}, + }; } private relativeVisiblePath(root: string, value: string): string { @@ -193,47 +494,41 @@ export class WorkspaceService { if ( !normalized || path.isAbsolute(value) || + normalized === "." || normalized === ".." || folded === ".pacman" || folded.startsWith(".pacman/") || - normalized.startsWith("../") + normalized.startsWith("../") || + normalized.includes("/../") || + normalized.includes("/./") ) { throw new GracefulError(`Invalid workspace path: ${value}`); } return normalized; } - private indexPath(root: string): string { - return path.join(root, ".pacman", "index.json"); + private statePath(root: string): string { + return path.join(root, ".pacman", "state.json"); } - private pushedIndex(index: WorkspaceIndex, zipPath: string): WorkspaceIndex { - const archive = new AdmZip(zipPath); - return { - ...index, - files: index.files.map(file => { - const entry = archive.getEntry(file.currentPath); - if (!entry || entry.isDirectory) { - throw new GracefulError(`Tracked file is missing from archive: ${file.currentPath}`); - } - return { - ...file, - basePath: file.currentPath, - digest: this.digestContent(entry.getData()), - }; - }), - }; + private writeState(root: string, state: WorkspaceState): void { + fs.writeFileSync(this.statePath(root), JSON.stringify(state, null, 2) + "\n", { mode: 0o600 }); } - private writeIndex(root: string, index: WorkspaceIndex): void { - fs.writeFileSync(this.indexPath(root), JSON.stringify(index, null, 2) + "\n", { mode: 0o600 }); + private digest(file: string): string { + return `sha256:${createHash("sha256").update(fs.readFileSync(file)).digest("hex")}`; } - private digest(file: string): string { - return this.digestContent(fs.readFileSync(file)); + private isFolder(node: WorkspaceNodeMetadata): boolean { + return node.type.toUpperCase() === "FOLDER"; } - private digestContent(content: Buffer): string { - return `sha256:${createHash("sha256").update(content).digest("hex")}`; + private 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/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index de3787b3..a9c323c1 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -8,29 +8,89 @@ import { mockAxiosGet, mockAxiosPost, mockedAxiosInstance } from "../../utls/htt const PACKAGE_KEY = "pkg-1"; const CHECKOUT_URL = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${PACKAGE_KEY}/file-archive`; -const PUSH_URL = "https://myTeam.celonis.cloud/pacman/api/core/staging/packages/file-archive"; +const PUSH_URL = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${PACKAGE_KEY}/file-archive`; + +interface TestFile { + nodeKey: string; + path: string; + content: string; +} function digest(value: string): string { return `sha256:${createHash("sha256").update(value).digest("hex")}`; } -function index(basePath: string = "Guides/Guide.md", currentPath: string = basePath): object { +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 = `folder-${folders.size + 1}`; + folders.set(folderPath, folderKey); + nodes[folderKey] = { + key: folderKey, + name: segments[index], + type: "FOLDER", + parentNodeKey, + filesystemName: segments[index], + }; + } + parentNodeKey = folderKey; + } + nodes[file.nodeKey] = { + key: file.nodeKey, + name: path.posix.basename(file.path, path.posix.extname(file.path)), + type: "MARKDOWN_FILE", + parentNodeKey, + filesystemName: segments[segments.length - 1], + }; + }); + return nodes; +} + +function state( + files: TestFile[], + serverRevision: string = "revision-1", + moveHints: Record = {} +): object { return { - version: 1, + schemaVersion: 1, packageKey: PACKAGE_KEY, - files: [{ nodeKey: "node-1", basePath, currentPath, digest: digest("original") }], + serverRevision: digest(serverRevision), + baselineDigests: Object.fromEntries(files.map(file => [file.nodeKey, digest(file.content)])), + moveHints, }; } -function writeWorkspace(): void { - fs.mkdirSync(path.join(process.cwd(), ".pacman"), { recursive: true }); - fs.mkdirSync(path.join(process.cwd(), "Guides"), { recursive: true }); - fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "original"); - fs.writeFileSync(path.join(process.cwd(), ".pacman", "index.json"), JSON.stringify(index())); +function archive(files: TestFile[], serverRevision?: string, moveHints?: Record): Buffer { + const zip = new AdmZip(); + zip.addFile(".pacman/state.json", Buffer.from(JSON.stringify(state(files, serverRevision, moveHints)))); + Object.entries(metadata(files)).forEach(([nodeKey, node]) => { + zip.addFile(`.pacman/nodes/${nodeKey}.json`, Buffer.from(JSON.stringify(node))); + }); + files.forEach(file => zip.addFile(file.path, Buffer.from(file.content))); + return zip.toBuffer(); +} + +function writeWorkspace(files: TestFile[] = [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]): void { + fs.mkdirSync(path.join(process.cwd(), ".pacman", "nodes"), { recursive: true }); + fs.writeFileSync(path.join(process.cwd(), ".pacman", "state.json"), JSON.stringify(state(files))); + Object.entries(metadata(files)).forEach(([nodeKey, node]) => { + fs.writeFileSync(path.join(process.cwd(), ".pacman", "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 { - [".pacman", "Guides", "Pages", PACKAGE_KEY].forEach(entry => + [".pacman", "Guides", "Pages", "Other", "New", PACKAGE_KEY].forEach(entry => fs.rmSync(path.join(process.cwd(), entry), { recursive: true, force: true }) ); } @@ -39,11 +99,11 @@ describe("Workspace service", () => { beforeEach(removeWorkspace); afterEach(removeWorkspace); - it("checks out a filesystem archive", async () => { - const zip = new AdmZip(); - zip.addFile(".pacman/index.json", Buffer.from(JSON.stringify(index()))); - zip.addFile("Guides/Guide.md", Buffer.from("original")); - mockAxiosGet(CHECKOUT_URL, zip.toBuffer()); + it("checks out and validates a filesystem archive", async () => { + mockAxiosGet( + CHECKOUT_URL, + archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]) + ); const rename = jest.spyOn(fs, "renameSync"); try { @@ -60,40 +120,83 @@ describe("Workspace service", () => { } }); - it("records a move and reports later content changes together", () => { + it("infers an unchanged move performed by another tool without writing path state", () => { writeWorkspace(); - const service = new WorkspaceService(testContext); + fs.mkdirSync(path.join(process.cwd(), "Pages")); + fs.renameSync(path.join(process.cwd(), "Guides", "Guide.md"), path.join(process.cwd(), "Pages", "Guide.md")); - service.move("Guides/Guide.md", "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(), ".pacman", "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(), ".pacman", "index.json"), "utf-8"))).toMatchObject({ - files: [{ nodeKey: "node-1", currentPath: "Pages/Guide.md" }], + expect(JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "state.json"), "utf-8"))).toMatchObject({ + moveHints: { "node-1": "Pages/Guide.md" }, }); }); - it("records a move already performed by another tool", () => { + 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(); - 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); + expect(service.status()).toEqual([]); - new WorkspaceService(testContext).move("Guides/Guide.md", "Pages/Guide.md", true); + fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "changed"); + expect(service.status()).toEqual([{ path: "Guides/Guide.md", status: "modified" }]); - expect(fs.existsSync(path.join(process.cwd(), "Pages", "Guide.md"))).toBe(true); + 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("rejects a move onto another tracked path", () => { + it("moves a tracked file without maintaining a path index", () => { writeWorkspace(); - const indexPath = path.join(process.cwd(), ".pacman", "index.json"); - const workspaceIndex = JSON.parse(fs.readFileSync(indexPath, "utf-8")); - workspaceIndex.files.push({ - nodeKey: "node-2", - basePath: "Pages/Guide.md", - currentPath: "Pages/Guide.md", - digest: digest("other"), - }); - fs.writeFileSync(indexPath, JSON.stringify(workspaceIndex)); + 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 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" @@ -101,7 +204,7 @@ describe("Workspace service", () => { expect(fs.existsSync(path.join(process.cwd(), "Guides", "Guide.md"))).toBe(true); }); - it("records a case-only move when the target resolves to the source file", () => { + it("supports a case-only move when the target resolves to the source file", () => { writeWorkspace(); const source = path.join(process.cwd(), "Guides", "Guide.md"); const target = path.join(process.cwd(), "Guides", "guide.md"); @@ -116,32 +219,13 @@ describe("Workspace service", () => { try { new WorkspaceService(testContext).move("Guides/Guide.md", "Guides/guide.md"); - - expect(JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "index.json"), "utf-8"))).toMatchObject({ - files: [{ nodeKey: "node-1", currentPath: "Guides/guide.md" }], - }); + expect(fs.renameSync).toBeDefined(); } finally { lstat.mockRestore(); exists.mockRestore(); } }); - it("reports clean, modified, moved, 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"); - service.move("Guides/Guide.md", "Pages/Guide.md"); - expect(service.status()).toEqual([{ path: "Pages/Guide.md", status: "moved" }]); - - fs.rmSync(path.join(process.cwd(), "Pages", "Guide.md")); - expect(service.status()).toEqual([{ path: "Pages/Guide.md", status: "deleted" }]); - }); - it("rejects checkout over an existing destination", async () => { fs.mkdirSync(path.join(process.cwd(), PACKAGE_KEY)); @@ -150,22 +234,28 @@ describe("Workspace service", () => { ); }); - it("rejects an archive without a workspace index", async () => { + it("rejects an archive without workspace state", async () => { const zip = new AdmZip(); zip.addFile("Guides/Guide.md", Buffer.from("original")); mockAxiosGet(CHECKOUT_URL, zip.toBuffer()); await expect(new WorkspaceService(testContext).checkout(PACKAGE_KEY)).rejects.toThrow( - "Archive does not contain .pacman/index.json" + "Archive does not contain .pacman/state.json" ); }); - it("pushes the workspace archive", async () => { + it("pushes resolved changes and refreshes disposable metadata", async () => { writeWorkspace(); - mockAxiosPost(PUSH_URL, {}); - const service = new WorkspaceService(testContext); - service.move("Guides/Guide.md", "Pages/Guide.md"); + 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); + mockAxiosPost(PUSH_URL, {}); + mockAxiosGet( + CHECKOUT_URL, + archive([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "changed" }], "revision-2") + ); await service.push(undefined, true); @@ -175,17 +265,25 @@ describe("Workspace service", () => { expect.objectContaining({ params: { overwrite: true } }) ); expect(service.status()).toEqual([]); - expect(JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "index.json"), "utf-8"))).toMatchObject({ - files: [ - { - basePath: "Pages/Guide.md", - currentPath: "Pages/Guide.md", - digest: digest("changed"), - }, - ], + expect(JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "state.json"), "utf-8"))).toEqual({ + schemaVersion: 1, + packageKey: PACKAGE_KEY, + serverRevision: digest("revision-2"), + baselineDigests: { "node-1": digest("changed") }, + 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 has unresolved file identities"); + expect(mockedAxiosInstance.post).not.toHaveBeenCalled(); + }); + it("reserves the metadata directory case-insensitively", () => { writeWorkspace(); From f04719a36b72e6f96c2ace7253e2d57daf372323 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Wed, 19 Aug 2026 17:58:06 +0200 Subject: [PATCH 08/46] Validate package workspace path identity Includes-AI-Code: true --- src/commands/workspace/workspace.service.ts | 15 ++++-- .../workspace/workspace.service.spec.ts | 49 +++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index a5e1d4a8..1181ef53 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -136,10 +136,10 @@ export class WorkspaceService { if (!tracked) { throw new GracefulError(`Tracked file not found: ${sourcePath}`); } - const targetOwner = snapshot.expectedFiles.find( + const targetOwned = snapshot.expectedFiles.some( file => file.nodeKey !== tracked.nodeKey && file.path.toLowerCase() === targetPath.toLowerCase() ); - if (targetOwner) { + if (targetOwned) { throw new GracefulError(`Target path is already tracked: ${targetPath}`); } const absoluteSource = this.resolveVisiblePath(root, sourcePath); @@ -285,9 +285,10 @@ export class WorkspaceService { if (cached) { return cached; } - if (!resolving.add(node.key)) { + if (resolving.has(node.key)) { throw new GracefulError(`Circular node hierarchy at ${node.key}.`); } + resolving.add(node.key); const segment = this.filesystemName(node); let filePath = segment; if (node.parentNodeKey && node.parentNodeKey !== state.packageKey) { @@ -312,9 +313,11 @@ export class WorkspaceService { }); const foldedPaths = new Set(); expected.forEach(file => { - if (!foldedPaths.add(file.path.toLowerCase())) { + 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)) { @@ -374,9 +377,11 @@ export class WorkspaceService { throw new GracefulError(`Workspace contains an unsupported entry: ${relative}`); } const validated = this.validateRelative(relative); - if (!foldedPaths.add(validated.toLowerCase())) { + 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)); }); }; diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index a9c323c1..f2f8ef9c 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -204,6 +204,55 @@ describe("Workspace service", () => { expect(fs.existsSync(path.join(process.cwd(), "Guides", "Guide.md"))).toBe(true); }); + it("rejects circular node metadata", () => { + writeWorkspace(); + const folderPath = path.join(process.cwd(), ".pacman", "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 duplicate case-insensitive paths derived from node metadata", () => { + 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(), ".pacman", "nodes", "node-2.json"); + const second = JSON.parse(fs.readFileSync(secondPath, "utf-8")); + second.filesystemName = "one.md"; + fs.writeFileSync(secondPath, JSON.stringify(second)); + + expect(() => new WorkspaceService(testContext).status()).toThrow("Duplicate workspace path"); + }); + + 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("supports a case-only move when the target resolves to the source file", () => { writeWorkspace(); const source = path.join(process.cwd(), "Guides", "Guide.md"); From fe9bd57c5509d4669261b2ddbc3be74b6bfba0b7 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Wed, 19 Aug 2026 18:23:36 +0200 Subject: [PATCH 09/46] Add workspace clone and pull commands Includes-AI-Code: true --- src/commands/workspace/module.ts | 16 +-- src/commands/workspace/workspace-api.ts | 2 +- src/commands/workspace/workspace.service.ts | 109 +++++++++++++++--- tests/commands/workspace/module.spec.ts | 7 +- .../workspace/workspace.service.spec.ts | 80 ++++++++++--- 5 files changed, 171 insertions(+), 43 deletions(-) diff --git a/src/commands/workspace/module.ts b/src/commands/workspace/module.ts index 88db102f..01ff2132 100644 --- a/src/commands/workspace/module.ts +++ b/src/commands/workspace/module.ts @@ -7,11 +7,9 @@ class Module extends IModule { public register(context: Context, configurator: Configurator): void { const workspace = configurator.command("workspace").beta().description("Manage a package workspace."); - workspace - .command("checkout [directory]") - .beta() - .description("Check out a package.") - .action(this.checkout); + workspace.command("clone [directory]").beta().description("Clone a package.").action(this.clone); + + workspace.command("pull [directory]").beta().description("Pull remote changes.").action(this.pull); workspace.command("status [directory]").beta().description("Show local changes.").action(this.status); @@ -30,8 +28,12 @@ class Module extends IModule { .action(this.move); } - private async checkout(context: Context, command: Command): Promise { - await new WorkspaceService(context).checkout(command.args[0], command.args[1]); + private async clone(context: Context, command: Command): Promise { + await new WorkspaceService(context).clone(command.args[0], command.args[1]); + } + + private async pull(context: Context, command: Command): Promise { + await new WorkspaceService(context).pull(command.args[0]); } private async status(context: Context, command: Command): Promise { diff --git a/src/commands/workspace/workspace-api.ts b/src/commands/workspace/workspace-api.ts index e7d89994..72ed2cdc 100644 --- a/src/commands/workspace/workspace-api.ts +++ b/src/commands/workspace/workspace-api.ts @@ -4,7 +4,7 @@ import { Context } from "../../core/command/cli-context"; export class WorkspaceApi { constructor(private readonly context: Context) {} - public checkout(packageKey: string): Promise { + public download(packageKey: string): Promise { return this.context.httpClient.getFile( `/pacman/api/core/staging/packages/${encodeURIComponent(packageKey)}/file-archive` ); diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index 1181ef53..069dd374 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -55,29 +55,17 @@ export class WorkspaceService { this.api = new WorkspaceApi(context); } - public async checkout(packageKey: string, directory?: string): Promise { + public async clone(packageKey: string, directory?: string): Promise { const target = path.resolve(process.cwd(), directory || packageKey); if (fs.existsSync(target)) { throw new GracefulError(`Destination already exists: ${target}`); } - const archive = await this.api.checkout(packageKey); - const zip = new AdmZip(archive); - if (!zip.getEntry(".pacman/state.json")) { - throw new GracefulError("Archive does not contain .pacman/state.json"); - } - const temporary = fileService.extractZipBufferToTempDirectory(archive); + const temporary = this.validatedArchive(await this.api.download(packageKey), packageKey); const parent = path.dirname(target); let staging: string | undefined; try { - const snapshot = this.snapshot(temporary); - if (snapshot.state.packageKey !== packageKey) { - throw new GracefulError("Archive package key does not match the requested package."); - } - if (snapshot.changes.length !== 0) { - throw new GracefulError("Archive content does not match its workspace baseline."); - } fs.mkdirSync(parent, { recursive: true }); - staging = fs.mkdtempSync(path.join(parent, ".pacman-checkout-")); + staging = fs.mkdtempSync(path.join(parent, ".pacman-clone-")); fs.rmSync(staging, { recursive: true }); fs.cpSync(temporary, staging, { recursive: true, force: false, errorOnExist: true }); if (fs.existsSync(target)) { @@ -92,7 +80,23 @@ export class WorkspaceService { } finally { fs.rmSync(temporary, { recursive: true, force: true }); } - logger.info(`Checked out ${packageKey} to ${target}`); + logger.info(`Cloned ${packageKey} to ${target}`); + } + + public async pull(directory?: string): Promise { + const root = this.root(directory); + const current = this.snapshot(root); + if (current.changes.length !== 0) { + throw new GracefulError("Workspace has local changes. Push or discard them before pull."); + } + const packageKey = current.state.packageKey; + const temporary = this.validatedArchive(await this.api.download(packageKey), packageKey); + try { + this.replaceWorkspaceContents(root, temporary); + } finally { + fs.rmSync(temporary, { recursive: true, force: true }); + } + logger.info(`Pulled ${packageKey}.`); } public status(directory?: string): WorkspaceChange[] { @@ -119,7 +123,7 @@ export class WorkspaceService { const form = new FormData(); form.append("packageFile", fs.createReadStream(zipPath), { filename: "workspace.zip" }); await this.api.push(snapshot.state.packageKey, form, overwrite); - const refreshedArchive = await this.api.checkout(snapshot.state.packageKey); + const refreshedArchive = await this.api.download(snapshot.state.packageKey); this.refreshMetadata(root, refreshedArchive, snapshot.state.packageKey); } finally { fs.rmSync(zipPath, { force: true }); @@ -433,6 +437,77 @@ export class WorkspaceService { } } + private validatedArchive(archive: Buffer, packageKey: string): string { + const zip = new AdmZip(archive); + if (!zip.getEntry(".pacman/state.json")) { + throw new GracefulError("Archive does not contain .pacman/state.json"); + } + const temporary = fileService.extractZipBufferToTempDirectory(archive); + try { + const snapshot = this.snapshot(temporary); + if (snapshot.state.packageKey !== packageKey) { + throw new GracefulError("Archive package key does not match the requested package."); + } + if (snapshot.changes.length !== 0) { + throw new GracefulError("Archive content does not match its workspace baseline."); + } + return temporary; + } catch (error) { + fs.rmSync(temporary, { recursive: true, force: true }); + throw error; + } + } + + 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, ".pacman-pull-backup-")); + const staging = fs.mkdtempSync(path.join(parent, ".pacman-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); + } catch (error) { + try { + this.moveEntries(backup, root); + } catch (restoreError) { + preserveBackup = true; + throw new GracefulError(`Pull failed; workspace backup remains at ${backup}.`); + } + throw error; + } + try { + this.moveEntries(staging, root); + } catch (error) { + try { + fs.readdirSync(root).forEach(entry => + fs.rmSync(path.join(root, entry), { recursive: true, force: true }) + ); + this.moveEntries(backup, root); + } catch (restoreError) { + preserveBackup = true; + throw new GracefulError(`Pull failed; workspace backup remains at ${backup}.`); + } + 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): void { + fs.readdirSync(source) + .sort() + .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); diff --git a/tests/commands/workspace/module.spec.ts b/tests/commands/workspace/module.spec.ts index 15780fed..9f41ee7a 100644 --- a/tests/commands/workspace/module.spec.ts +++ b/tests/commands/workspace/module.spec.ts @@ -9,11 +9,12 @@ describe("Workspace module", () => { new Module().register(testContext, configurator); expect(configurator.command).toHaveBeenCalledWith("workspace"); - expect(configurator.command).toHaveBeenCalledWith("checkout [directory]"); + expect(configurator.command).toHaveBeenCalledWith("clone [directory]"); + expect(configurator.command).toHaveBeenCalledWith("pull [directory]"); expect(configurator.command).toHaveBeenCalledWith("status [directory]"); expect(configurator.command).toHaveBeenCalledWith("push [directory]"); expect(configurator.command).toHaveBeenCalledWith("move "); - expect(configurator.beta).toHaveBeenCalledTimes(5); - expect(configurator.action).toHaveBeenCalledTimes(4); + expect(configurator.beta).toHaveBeenCalledTimes(6); + expect(configurator.action).toHaveBeenCalledTimes(5); }); }); diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index f2f8ef9c..7d7cbf3f 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -7,7 +7,7 @@ import { testContext } from "../../utls/test-context"; import { mockAxiosGet, mockAxiosPost, mockedAxiosInstance } from "../../utls/http-requests-mock"; const PACKAGE_KEY = "pkg-1"; -const CHECKOUT_URL = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${PACKAGE_KEY}/file-archive`; +const ARCHIVE_URL = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${PACKAGE_KEY}/file-archive`; const PUSH_URL = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${PACKAGE_KEY}/file-archive`; interface TestFile { @@ -99,25 +99,75 @@ describe("Workspace service", () => { beforeEach(removeWorkspace); afterEach(removeWorkspace); - it("checks out and validates a filesystem archive", async () => { - mockAxiosGet( - CHECKOUT_URL, - archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]) - ); + it("clones and validates a filesystem archive", async () => { + mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }])); const rename = jest.spyOn(fs, "renameSync"); try { - await new WorkspaceService(testContext).checkout(PACKAGE_KEY); + await new WorkspaceService(testContext).clone(PACKAGE_KEY); expect(fs.readFileSync(path.join(process.cwd(), PACKAGE_KEY, "Guides", "Guide.md"), "utf-8")).toBe( "original" ); - const checkoutRename = rename.mock.calls.find(call => call[1] === path.join(process.cwd(), PACKAGE_KEY)); - expect(checkoutRename).toBeDefined(); - expect(path.dirname(checkoutRename![0].toString())).toBe(path.dirname(checkoutRename![1].toString())); + 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("pulls the latest archive into a clean existing workspace", async () => { + writeWorkspace(); + const workspaceInode = fs.statSync(process.cwd()).ino; + mockAxiosGet( + ARCHIVE_URL, + archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }], "revision-2") + ); + + await new WorkspaceService(testContext).pull(); + + expect(fs.statSync(process.cwd()).ino).toBe(workspaceInode); + 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(), ".pacman", "state.json"), "utf-8"))).toMatchObject({ + serverRevision: digest("revision-2"), + baselineDigests: { "node-1": digest("remote") }, + moveHints: {}, + }); + }); + + it("refuses to pull over local changes", async () => { + writeWorkspace(); + fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "local"); + + await expect(new WorkspaceService(testContext).pull()).rejects.toThrow("Workspace has local changes"); + expect(mockedAxiosInstance.get).not.toHaveBeenCalled(); + expect(fs.readFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "utf-8")).toBe("local"); + }); + + it("restores the existing workspace when applying a pull fails", async () => { + writeWorkspace(); + mockAxiosGet( + ARCHIVE_URL, + archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }], "revision-2") + ); + const originalRename = fs.renameSync; + const rename = jest.spyOn(fs, "renameSync").mockImplementation((source, target) => { + const sourceParent = path.basename(path.dirname(source.toString())); + if (sourceParent.startsWith(".pacman-pull-") && !sourceParent.startsWith(".pacman-pull-backup-")) { + throw new Error("apply failed"); + } + originalRename(source, target); + }); + + try { + await expect(new WorkspaceService(testContext).pull()).rejects.toThrow("apply failed"); } finally { rename.mockRestore(); } + expect(fs.readFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "utf-8")).toBe("original"); + expect(new WorkspaceService(testContext).status()).toEqual([]); }); it("infers an unchanged move performed by another tool without writing path state", () => { @@ -275,10 +325,10 @@ describe("Workspace service", () => { } }); - it("rejects checkout over an existing destination", async () => { + it("rejects clone over an existing destination", async () => { fs.mkdirSync(path.join(process.cwd(), PACKAGE_KEY)); - await expect(new WorkspaceService(testContext).checkout(PACKAGE_KEY)).rejects.toThrow( + await expect(new WorkspaceService(testContext).clone(PACKAGE_KEY)).rejects.toThrow( "Destination already exists" ); }); @@ -286,9 +336,9 @@ describe("Workspace service", () => { it("rejects an archive without workspace state", async () => { const zip = new AdmZip(); zip.addFile("Guides/Guide.md", Buffer.from("original")); - mockAxiosGet(CHECKOUT_URL, zip.toBuffer()); + mockAxiosGet(ARCHIVE_URL, zip.toBuffer()); - await expect(new WorkspaceService(testContext).checkout(PACKAGE_KEY)).rejects.toThrow( + await expect(new WorkspaceService(testContext).clone(PACKAGE_KEY)).rejects.toThrow( "Archive does not contain .pacman/state.json" ); }); @@ -302,7 +352,7 @@ describe("Workspace service", () => { service.move("Guides/Guide.md", "Pages/Guide.md", true); mockAxiosPost(PUSH_URL, {}); mockAxiosGet( - CHECKOUT_URL, + ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "changed" }], "revision-2") ); From 5bd31c166f475ae7ece2a157135d04597541dc26 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Wed, 19 Aug 2026 18:58:41 +0200 Subject: [PATCH 10/46] Keep workspace synchronization state local Includes-AI-Code: true --- src/commands/workspace/workspace-api.ts | 15 +- .../workspace/workspace-change-classifier.ts | 121 ++++++ src/commands/workspace/workspace.models.ts | 46 ++ src/commands/workspace/workspace.service.ts | 404 +++++++++--------- src/core/http/http-client.ts | 34 +- src/core/utils/file-service.ts | 9 +- .../workspace-change-classifier.spec.ts | 33 ++ .../workspace/workspace.service.spec.ts | 130 +++++- tests/core/utils/file.service.spec.ts | 16 + tests/utls/http-requests-mock.ts | 6 +- 10 files changed, 582 insertions(+), 232 deletions(-) create mode 100644 src/commands/workspace/workspace-change-classifier.ts create mode 100644 src/commands/workspace/workspace.models.ts create mode 100644 tests/commands/workspace/workspace-change-classifier.spec.ts diff --git a/src/commands/workspace/workspace-api.ts b/src/commands/workspace/workspace-api.ts index 72ed2cdc..2bbecb75 100644 --- a/src/commands/workspace/workspace-api.ts +++ b/src/commands/workspace/workspace-api.ts @@ -1,20 +1,27 @@ import * as FormData from "form-data"; import { Context } from "../../core/command/cli-context"; +import { GracefulError } from "../../core/utils/logger"; export class WorkspaceApi { constructor(private readonly context: Context) {} - public download(packageKey: string): Promise { - return this.context.httpClient.getFile( + public async download(packageKey: string): Promise<{ archive: Buffer; eTag: string }> { + const response = await this.context.httpClient.getFileWithHeaders( `/pacman/api/core/staging/packages/${encodeURIComponent(packageKey)}/file-archive` ); + const eTag = response.headers.etag; + if (typeof eTag !== "string") { + throw new GracefulError("Filesystem archive response does not contain an ETag."); + } + return { archive: response.data, eTag }; } - public push(packageKey: string, data: FormData, overwrite: boolean): Promise { + public push(packageKey: string, data: FormData, overwrite: boolean, eTag: string): Promise { return this.context.httpClient.postFile( `/pacman/api/core/staging/packages/${encodeURIComponent(packageKey)}/file-archive`, data, - { overwrite } + { overwrite }, + { "If-Match": eTag } ); } } diff --git a/src/commands/workspace/workspace-change-classifier.ts b/src/commands/workspace/workspace-change-classifier.ts new file mode 100644 index 00000000..484154d9 --- /dev/null +++ b/src/commands/workspace/workspace-change-classifier.ts @@ -0,0 +1,121 @@ +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[] = []; + + expectedFiles.forEach(file => { + const hint = moveHints[file.nodeKey]; + if (!file.digest) { + if (hint || !visibleFiles.has(file.path)) { + changes.push({ nodeKey: file.nodeKey, path: hint || file.path, status: "unresolved" }); + return; + } + consumedPaths.add(file.path); + changes.push({ nodeKey: file.nodeKey, path: file.path, status: "added" }); + return; + } + if (hint && hint !== file.path) { + if (visibleFiles.has(file.path) || !visibleFiles.has(hint) || consumedPaths.has(hint)) { + changes.push({ nodeKey: file.nodeKey, path: hint, status: "unresolved" }); + if (visibleFiles.has(hint)) { + consumedPaths.add(hint); + } + if (visibleFiles.has(file.path)) { + consumedPaths.add(file.path); + } + return; + } + consumedPaths.add(hint); + changes.push({ + nodeKey: file.nodeKey, + path: hint, + status: visibleFiles.get(hint) === file.digest ? "moved" : "moved, modified", + }); + return; + } + if (visibleFiles.has(file.path)) { + consumedPaths.add(file.path); + if (visibleFiles.get(file.path) !== file.digest) { + changes.push({ nodeKey: file.nodeKey, path: file.path, status: "modified" }); + } + return; + } + missing.push(file); + }); + + const missingByDigest = groupBy(missing, file => file.digest!); + missingByDigest.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: "moved" }); + 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" }); + }); + } + }); + + const newPaths = [...visibleFiles.keys()].filter(filePath => !consumedPaths.has(filePath)); + possibleMovedAndEdited(missing, newPaths).forEach(([file, filePath]) => { + changes.push({ nodeKey: file.nodeKey, path: filePath, status: "unresolved" }); + missing.splice(missing.indexOf(file), 1); + newPaths.splice(newPaths.indexOf(filePath), 1); + }); + missing.forEach(file => changes.push({ nodeKey: file.nodeKey, path: file.path, status: "deleted" })); + 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 possibleMovedAndEdited( + missing: ExpectedWorkspaceFile[], + newPaths: string[] +): Array<[ExpectedWorkspaceFile, string]> { + const pairs: Array<[ExpectedWorkspaceFile, string]> = []; + const remainingPaths = new Set(newPaths); + missing.forEach(file => { + const matchingBasenames = [...remainingPaths].filter( + filePath => path.posix.basename(filePath).toLowerCase() === path.posix.basename(file.path).toLowerCase() + ); + if (matchingBasenames.length === 1) { + pairs.push([file, matchingBasenames[0]]); + remainingPaths.delete(matchingBasenames[0]); + } + }); + const pairedKeys = new Set(pairs.map(([file]) => file.nodeKey)); + const remainingMissing = missing.filter(file => !pairedKeys.has(file.nodeKey)); + if (remainingMissing.length === 1 && remainingPaths.size === 1) { + const candidate = [...remainingPaths][0]; + if (path.posix.extname(remainingMissing[0].path).toLowerCase() === path.posix.extname(candidate).toLowerCase()) { + pairs.push([remainingMissing[0], candidate]); + } + } + return pairs; +} + +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.models.ts b/src/commands/workspace/workspace.models.ts new file mode 100644 index 00000000..deb12b62 --- /dev/null +++ b/src/commands/workspace/workspace.models.ts @@ -0,0 +1,46 @@ +export interface WorkspaceState { + schemaVersion: number; + serverRevision: string; + baselineDigests: Record; + moveHints: Record; +} + +export interface WorkspacePackageIdentity { + schemaVersion: number; + packageKey: string; +} + +export interface WorkspaceNodeMetadata { + key: string; + name: string; + type: string; + parentNodeKey?: string | null; + filesystemName?: string; + metadata?: Record; + additionalFields?: Record; +} + +export interface ExpectedWorkspaceFile { + nodeKey: string; + path: string; + digest?: 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; +} + +export interface WorkspaceSnapshot { + packageKey: string; + state: WorkspaceState; + expectedFiles: ExpectedWorkspaceFile[]; + visibleFiles: Map; + changes: ClassifiedWorkspaceChange[]; +} diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index 069dd374..25ad7cfc 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -7,46 +7,44 @@ import { Context } from "../../core/command/cli-context"; import { fileService } from "../../core/utils/file-service"; import { GracefulError, logger } from "../../core/utils/logger"; import { WorkspaceApi } from "./workspace-api"; - -interface WorkspaceState { - schemaVersion: number; - packageKey: string; - serverRevision: string; - baselineDigests: Record; - moveHints: Record; -} - -interface WorkspaceNodeMetadata { - key: string; - name: string; - type: string; - parentNodeKey?: string | null; - filesystemName?: string; - metadata?: Record; - additionalFields?: Record; -} - -interface ExpectedFile { - nodeKey: string; - path: string; - digest: string; -} - -interface ClassifiedChange extends WorkspaceChange { - nodeKey?: string; -} - -interface WorkspaceSnapshot { - state: WorkspaceState; - expectedFiles: ExpectedFile[]; - visibleFiles: Map; - changes: ClassifiedChange[]; -} - -export interface WorkspaceChange { - path: string; - status: "added" | "deleted" | "modified" | "moved" | "moved, modified" | "unresolved"; -} +import { classifyWorkspaceChanges } from "./workspace-change-classifier"; +import { + ExpectedWorkspaceFile, + WorkspaceChange, + WorkspaceNodeMetadata, + WorkspacePackageIdentity, + WorkspaceSnapshot, + WorkspaceState, +} from "./workspace.models"; + +const NON_SEMANTIC_NODE_FIELDS = [ + "configuration", + "invalidConfiguration", + "invalidContent", + "id", + "workingDraftId", + "activatedDraftId", + "stagingDraftId", + "prodDraftId", + "archivedDraftId", + "packageNodeId", + "creationDate", + "changeDate", + "deletedAt", + "archivedAt", + "createdBy", + "updatedBy", + "deletedBy", + "archivedBy", + "optimisticLockVersion", + "revision", + "serverRevision", + "lastModified", + "lastModifiedAt", + "lastModifiedBy", +]; + +export { WorkspaceChange } from "./workspace.models"; export class WorkspaceService { private readonly api: WorkspaceApi; @@ -85,14 +83,18 @@ export class WorkspaceService { public async pull(directory?: string): Promise { const root = this.root(directory); - const current = this.snapshot(root); - if (current.changes.length !== 0) { + const packageKey = this.packageIdentity(root).packageKey; + const hasLocalState = fs.existsSync(this.statePath(root)); + if (hasLocalState && this.snapshot(root).changes.length !== 0) { throw new GracefulError("Workspace has local changes. Push or discard them before pull."); } - const packageKey = current.state.packageKey; const temporary = this.validatedArchive(await this.api.download(packageKey), packageKey); try { - this.replaceWorkspaceContents(root, temporary); + if (hasLocalState) { + this.replaceWorkspaceContents(root, temporary); + } else { + this.reconcileLocalState(root, temporary); + } } finally { fs.rmSync(temporary, { recursive: true, force: true }); } @@ -118,17 +120,35 @@ export class WorkspaceService { if (snapshot.changes.some(change => change.status === "unresolved")) { throw new GracefulError("Workspace has unresolved file identities. Record the intended moves before push."); } - const zipPath = fileService.zipDirectoryAsSinglePackage(root); + const zipPath = fileService.zipDirectoryAsSinglePackage(root, filePath => { + const folded = filePath.toLowerCase(); + return ( + folded !== ".git" && + !folded.startsWith(".git/") && + folded !== ".pacman/local" && + !folded.startsWith(".pacman/local/") + ); + }); try { const form = new FormData(); form.append("packageFile", fs.createReadStream(zipPath), { filename: "workspace.zip" }); - await this.api.push(snapshot.state.packageKey, form, overwrite); - const refreshedArchive = await this.api.download(snapshot.state.packageKey); - this.refreshMetadata(root, refreshedArchive, snapshot.state.packageKey); + const moves = Object.fromEntries( + snapshot.changes + .filter(change => + Boolean(change.nodeKey) && (change.status === "moved" || change.status === "moved, modified") + ) + .map(change => [change.nodeKey!, change.path]) + ); + if (Object.keys(moves).length > 0) { + form.append("moveMappings", JSON.stringify({ moves }), { contentType: "application/json" }); + } + await this.api.push(snapshot.packageKey, form, overwrite, snapshot.state.serverRevision); + const refreshedArchive = await this.api.download(snapshot.packageKey); + this.refreshMetadata(root, refreshedArchive, snapshot.packageKey); } finally { fs.rmSync(zipPath, { force: true }); } - logger.info(`Pushed ${snapshot.state.packageKey}.`); + logger.info(`Pushed ${snapshot.packageKey}.`); } public move(source: string, target: string, recordOnly: boolean = false): void { @@ -169,117 +189,20 @@ export class WorkspaceService { } private snapshot(root: string): WorkspaceSnapshot { + const packageKey = this.packageIdentity(root).packageKey; const state = this.state(root); - const expectedFiles = this.expectedFiles(root, state); + const expectedFiles = this.expectedFiles(root, state, packageKey); const visibleFiles = this.visibleFiles(root); return { + packageKey, state, expectedFiles, visibleFiles, - changes: this.classify(expectedFiles, visibleFiles, state.moveHints), + changes: classifyWorkspaceChanges(expectedFiles, visibleFiles, state.moveHints), }; } - private classify( - expectedFiles: ExpectedFile[], - visibleFiles: Map, - moveHints: Record - ): ClassifiedChange[] { - const changes: ClassifiedChange[] = []; - const consumedPaths = new Set(); - const missing: ExpectedFile[] = []; - - expectedFiles.forEach(file => { - const hint = moveHints[file.nodeKey]; - if (hint && hint !== file.path) { - if (visibleFiles.has(file.path) || !visibleFiles.has(hint) || consumedPaths.has(hint)) { - changes.push({ nodeKey: file.nodeKey, path: hint, status: "unresolved" }); - if (visibleFiles.has(hint)) { - consumedPaths.add(hint); - } - if (visibleFiles.has(file.path)) { - consumedPaths.add(file.path); - } - return; - } - consumedPaths.add(hint); - changes.push({ - nodeKey: file.nodeKey, - path: hint, - status: visibleFiles.get(hint) === file.digest ? "moved" : "moved, modified", - }); - return; - } - if (visibleFiles.has(file.path)) { - consumedPaths.add(file.path); - if (visibleFiles.get(file.path) !== file.digest) { - changes.push({ nodeKey: file.nodeKey, path: file.path, status: "modified" }); - } - return; - } - missing.push(file); - }); - - const missingByDigest = this.groupBy(missing, file => file.digest); - missingByDigest.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: "moved" }); - 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" }); - }); - } - }); - - const newPaths = [...visibleFiles.keys()].filter(filePath => !consumedPaths.has(filePath)); - const movedAndEdited = this.possibleMovedAndEdited(missing, newPaths); - movedAndEdited.forEach(([file, filePath]) => { - changes.push({ nodeKey: file.nodeKey, path: filePath, status: "unresolved" }); - missing.splice(missing.indexOf(file), 1); - newPaths.splice(newPaths.indexOf(filePath), 1); - }); - missing.forEach(file => changes.push({ nodeKey: file.nodeKey, path: file.path, status: "deleted" })); - newPaths.forEach(filePath => changes.push({ path: filePath, status: "added" })); - - return changes.sort((left, right) => left.path.localeCompare(right.path) || left.status.localeCompare(right.status)); - } - - private possibleMovedAndEdited(missing: ExpectedFile[], newPaths: string[]): Array<[ExpectedFile, string]> { - const pairs: Array<[ExpectedFile, string]> = []; - const remainingPaths = new Set(newPaths); - missing.forEach(file => { - const matchingBasenames = [...remainingPaths].filter( - filePath => path.posix.basename(filePath).toLowerCase() === path.posix.basename(file.path).toLowerCase() - ); - if (matchingBasenames.length === 1) { - pairs.push([file, matchingBasenames[0]]); - remainingPaths.delete(matchingBasenames[0]); - } - }); - const pairedKeys = new Set(pairs.map(([file]) => file.nodeKey)); - const remainingMissing = missing.filter(file => !pairedKeys.has(file.nodeKey)); - if (remainingMissing.length === 1 && remainingPaths.size === 1) { - const candidate = [...remainingPaths][0]; - if (path.posix.extname(remainingMissing[0].path).toLowerCase() === path.posix.extname(candidate).toLowerCase()) { - pairs.push([remainingMissing[0], candidate]); - } - } - return pairs; - } - - private expectedFiles(root: string, state: WorkspaceState): ExpectedFile[] { + private expectedFiles(root: string, state: WorkspaceState, packageKey: string): ExpectedWorkspaceFile[] { const nodes = this.nodes(root); const byKey = new Map(nodes.map(node => [node.key, node])); const pathByKey = new Map(); @@ -295,7 +218,7 @@ export class WorkspaceService { resolving.add(node.key); const segment = this.filesystemName(node); let filePath = segment; - if (node.parentNodeKey && node.parentNodeKey !== state.packageKey) { + if (node.parentNodeKey && node.parentNodeKey !== packageKey) { const parent = byKey.get(node.parentNodeKey); if (!parent || !this.isFolder(parent)) { throw new GracefulError(`Invalid parent metadata for node ${node.key}.`); @@ -310,8 +233,8 @@ export class WorkspaceService { .filter(node => !this.isFolder(node)) .map(node => { const baseline = state.baselineDigests[node.key]; - if (!baseline || !/^sha256:[0-9a-f]{64}$/.test(baseline)) { - throw new GracefulError(`Missing or invalid baseline digest for node ${node.key}.`); + if (baseline && !/^sha256:[0-9a-f]{64}$/.test(baseline)) { + throw new GracefulError(`Invalid baseline digest for node ${node.key}.`); } return { nodeKey: node.key, path: resolvePath(node), digest: baseline }; }); @@ -343,7 +266,14 @@ export class WorkspaceService { .sort((left, right) => left.name.localeCompare(right.name)) .map(entry => { const node = JSON.parse(fs.readFileSync(path.join(directory, entry.name), "utf-8")) as WorkspaceNodeMetadata; - if (!node.key || !node.name || !node.type || `${node.key}.json` !== entry.name) { + const fields = node as unknown as Record; + if ( + !node.key || + !node.name || + !node.type || + `${node.key}.json` !== entry.name || + NON_SEMANTIC_NODE_FIELDS.some(field => field in fields) + ) { throw new GracefulError(`Invalid node metadata file: ${entry.name}`); } return node; @@ -365,7 +295,10 @@ export class WorkspaceService { fs.readdirSync(directory, { withFileTypes: true }) .sort((left, right) => left.name.localeCompare(right.name)) .forEach(entry => { - if (!relativeDirectory && entry.name.toLowerCase() === ".pacman") { + if ( + !relativeDirectory && + (entry.name.toLowerCase() === ".pacman" || entry.name.toLowerCase() === ".git") + ) { return; } const relative = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name; @@ -393,7 +326,7 @@ export class WorkspaceService { return files; } - private trackedFile(snapshot: WorkspaceSnapshot, sourcePath: string): ExpectedFile | undefined { + private trackedFile(snapshot: WorkspaceSnapshot, sourcePath: string): ExpectedWorkspaceFile | undefined { const expected = snapshot.expectedFiles.find(file => file.path === sourcePath); if (expected) { return expected; @@ -408,21 +341,17 @@ export class WorkspaceService { return classified ? snapshot.expectedFiles.find(file => file.nodeKey === classified.nodeKey) : undefined; } - private refreshMetadata(root: string, archive: Buffer, packageKey: string): void { - const zip = new AdmZip(archive); - if (!zip.getEntry(".pacman/state.json")) { - throw new GracefulError("Refreshed archive does not contain .pacman/state.json"); - } - const extracted = fileService.extractZipBufferToTempDirectory(archive); + private refreshMetadata( + root: string, + download: { archive: Buffer; eTag: string }, + packageKey: string + ): void { + const extracted = this.validatedArchive(download, packageKey); const refreshRoot = fs.mkdtempSync(path.join(root, ".pacman-refresh-")); const stagedMetadata = path.join(refreshRoot, "metadata"); const previousMetadata = path.join(refreshRoot, "previous"); const metadata = path.join(root, ".pacman"); try { - const refreshed = this.snapshot(extracted); - if (refreshed.state.packageKey !== packageKey || refreshed.changes.length !== 0) { - throw new GracefulError("Refreshed archive metadata does not match its content."); - } fs.cpSync(path.join(extracted, ".pacman"), stagedMetadata, { recursive: true }); fs.renameSync(metadata, previousMetadata); try { @@ -437,17 +366,27 @@ export class WorkspaceService { } } - private validatedArchive(archive: Buffer, packageKey: string): string { - const zip = new AdmZip(archive); - if (!zip.getEntry(".pacman/state.json")) { - throw new GracefulError("Archive does not contain .pacman/state.json"); + private validatedArchive(download: { archive: Buffer; eTag: string }, packageKey: string): string { + const zip = new AdmZip(download.archive); + if (!zip.getEntry(".pacman/package.json") || !zip.getEntry(".pacman/.gitignore")) { + throw new GracefulError("Archive does not contain Pacman package metadata."); + } + if ( + zip.getEntries().some(entry => { + const folded = entry.entryName.toLowerCase(); + return folded === ".pacman/local" || folded.startsWith(".pacman/local/"); + }) + ) { + throw new GracefulError("Archive contains local Pacman workspace state."); } - const temporary = fileService.extractZipBufferToTempDirectory(archive); + const temporary = fileService.extractZipBufferToTempDirectory(download.archive); try { - const snapshot = this.snapshot(temporary); - if (snapshot.state.packageKey !== packageKey) { + if (this.packageIdentity(temporary).packageKey !== packageKey) { throw new GracefulError("Archive package key does not match the requested package."); } + this.validateGitignore(temporary); + this.hydrateState(temporary, download.eTag); + const snapshot = this.snapshot(temporary); if (snapshot.changes.length !== 0) { throw new GracefulError("Archive content does not match its workspace baseline."); } @@ -458,6 +397,66 @@ export class WorkspaceService { } } + private reconcileLocalState(root: string, remoteRoot: string): void { + const packageKey = this.packageIdentity(root).packageKey; + if (this.packageIdentity(remoteRoot).packageKey !== packageKey) { + throw new GracefulError("Remote archive package key does not match this workspace."); + } + this.validateGitignore(root); + const remoteState = this.state(remoteRoot); + const emptyState: WorkspaceState = { + schemaVersion: 1, + serverRevision: remoteState.serverRevision, + baselineDigests: remoteState.baselineDigests, + moveHints: {}, + }; + const remoteFiles = new Map( + this.expectedFiles(remoteRoot, emptyState, packageKey).map(file => [file.nodeKey, file]) + ); + const localFiles = this.expectedFiles(root, emptyState, packageKey); + const remoteNodes = new Map(this.nodes(remoteRoot).map(node => [node.key, node])); + this.nodes(root).forEach(node => { + const remote = remoteNodes.get(node.key); + if (remote && this.isFolder(remote) !== this.isFolder(node)) { + throw new GracefulError(`Node metadata type conflicts with the server for ${node.key}.`); + } + }); + const moveHints = Object.fromEntries( + localFiles + .filter(file => remoteFiles.has(file.nodeKey) && remoteFiles.get(file.nodeKey)!.path !== file.path) + .map(file => [file.nodeKey, file.path]) + ); + const reconciledState = { ...remoteState, moveHints }; + const expectedFiles = this.expectedFiles(root, reconciledState, packageKey); + classifyWorkspaceChanges(expectedFiles, this.visibleFiles(root), moveHints); + this.writeState(root, reconciledState); + } + + private hydrateState(root: string, eTag: string): WorkspaceState { + if (!/^"sha256:[0-9a-f]{64}"$/.test(eTag)) { + throw new GracefulError("Filesystem archive response contains an invalid ETag."); + } + const packageKey = this.packageIdentity(root).packageKey; + const emptyState: WorkspaceState = { + schemaVersion: 1, + serverRevision: eTag, + baselineDigests: {}, + moveHints: {}, + }; + const baselineDigests = Object.fromEntries( + this.expectedFiles(root, emptyState, packageKey).map(file => { + const absolute = this.resolveVisiblePath(root, file.path); + if (!fs.existsSync(absolute) || !fs.lstatSync(absolute).isFile()) { + throw new GracefulError(`Archive is missing visible content for node ${file.nodeKey}.`); + } + return [file.nodeKey, this.digest(absolute)]; + }) + ); + const state = { ...emptyState, baselineDigests }; + this.writeState(root, state); + return state; + } + private replaceWorkspaceContents(root: string, source: string): void { if (root === path.parse(root).root) { throw new GracefulError("Cannot pull into the filesystem root."); @@ -470,7 +469,7 @@ export class WorkspaceService { try { fs.cpSync(source, staging, { recursive: true, force: false, errorOnExist: true }); try { - this.moveEntries(root, backup); + this.moveEntries(root, backup, new Set([".git"])); } catch (error) { try { this.moveEntries(backup, root); @@ -484,9 +483,9 @@ export class WorkspaceService { this.moveEntries(staging, root); } catch (error) { try { - fs.readdirSync(root).forEach(entry => - fs.rmSync(path.join(root, entry), { recursive: true, force: true }) - ); + 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; @@ -502,8 +501,9 @@ export class WorkspaceService { } } - private moveEntries(source: string, target: string): void { + private moveEntries(source: string, target: string, excluded: Set = new Set()): void { fs.readdirSync(source) + .filter(entry => !excluded.has(entry)) .sort() .forEach(entry => fs.renameSync(path.join(source, entry), path.join(target, entry))); } @@ -520,7 +520,7 @@ export class WorkspaceService { private root(directory?: string): string { let current = path.resolve(process.cwd(), directory || "."); - while (!fs.existsSync(this.statePath(current))) { + while (!fs.existsSync(this.packageIdentityPath(current))) { const parent = path.dirname(current); if (parent === current) { throw new GracefulError("No Pacman workspace found."); @@ -531,16 +531,20 @@ export class WorkspaceService { } 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.packageKey || !parsed.serverRevision || - !/^sha256:[0-9a-f]{64}$/.test(parsed.serverRevision) || + !/^"sha256:[0-9a-f]{64}"$/.test(parsed.serverRevision) || !parsed.baselineDigests || typeof parsed.baselineDigests !== "object" || Array.isArray(parsed.baselineDigests) || - !Object.values(parsed.baselineDigests).every(value => typeof value === "string") || + !Object.values(parsed.baselineDigests).every( + value => typeof value === "string" && /^sha256:[0-9a-f]{64}$/.test(value) + ) || (parsed.moveHints !== undefined && (typeof parsed.moveHints !== "object" || parsed.moveHints === null || @@ -551,7 +555,6 @@ export class WorkspaceService { } return { schemaVersion: parsed.schemaVersion, - packageKey: parsed.packageKey, serverRevision: parsed.serverRevision, baselineDigests: parsed.baselineDigests, moveHints: parsed.moveHints || {}, @@ -578,6 +581,8 @@ export class WorkspaceService { normalized === ".." || folded === ".pacman" || folded.startsWith(".pacman/") || + folded === ".git" || + folded.startsWith(".git/") || normalized.startsWith("../") || normalized.includes("/../") || normalized.includes("/./") @@ -588,13 +593,33 @@ export class WorkspaceService { } private statePath(root: string): string { - return path.join(root, ".pacman", "state.json"); + return path.join(root, ".pacman", "local", "state.json"); } 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.packageKey || Object.keys(parsed).length !== 2) { + throw new GracefulError("Unsupported Pacman package metadata."); + } + return { schemaVersion: parsed.schemaVersion, packageKey: parsed.packageKey }; + } + + private packageIdentityPath(root: string): string { + return path.join(root, ".pacman", "package.json"); + } + + private validateGitignore(root: string): void { + const gitignore = path.join(root, ".pacman", ".gitignore"); + if (!fs.existsSync(gitignore) || fs.readFileSync(gitignore, "utf-8") !== "local/\n") { + throw new GracefulError("Workspace .pacman/.gitignore must contain local/."); + } + } + private digest(file: string): string { return `sha256:${createHash("sha256").update(fs.readFileSync(file)).digest("hex")}`; } @@ -602,13 +627,4 @@ export class WorkspaceService { private isFolder(node: WorkspaceNodeMetadata): boolean { return node.type.toUpperCase() === "FOLDER"; } - - private 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/core/http/http-client.ts b/src/core/http/http-client.ts index a66b62a2..ed4027fc 100644 --- a/src/core/http/http-client.ts +++ b/src/core/http/http-client.ts @@ -53,6 +53,10 @@ export class HttpClient { } public async getFile(url: string): Promise { + return (await this.getFileWithHeaders(url)).data; + } + + public async getFileWithHeaders(url: string): Promise<{ data: Buffer; headers: Record }> { return new Promise((resolve, reject) => { this.axios.get(this.resolveUrl(url), { headers: this.buildHeaders(), @@ -69,7 +73,13 @@ export class HttpClient { return; } - this.handleResponseStreamData(Buffer.concat(data as any), resolve, reject); + const content = Buffer.concat(data as any); + if (content) { + resolve({ data: content, headers: response.headers || {} }); + return; + } + logger.error("Could not get file stream from response"); + reject(); }); }).catch(err => { this.handleError(err, resolve, reject); @@ -77,7 +87,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 +100,8 @@ export class HttpClient { { headers: { ...this.buildHeaders("multipart/form-data"), - ...formData.getHeaders() + ...formData.getHeaders(), + ...additionalHeaders }, params: parameters } @@ -164,15 +180,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 +200,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/workspace-change-classifier.spec.ts b/tests/commands/workspace/workspace-change-classifier.spec.ts new file mode 100644 index 00000000..9a678f75 --- /dev/null +++ b/tests/commands/workspace/workspace-change-classifier.spec.ts @@ -0,0 +1,33 @@ +import { classifyWorkspaceChanges } from "../../../src/commands/workspace/workspace-change-classifier"; + +describe("Workspace change classifier", () => { + it("keeps unchanged files clean", () => { + expect( + classifyWorkspaceChanges( + [{ nodeKey: "node-1", path: "Guides/Guide.md", digest: "sha256:one" }], + new Map([["Guides/Guide.md", "sha256:one"]]), + {} + ) + ).toEqual([]); + }); + + it("infers a uniquely matching unchanged move", () => { + expect( + classifyWorkspaceChanges( + [{ nodeKey: "node-1", path: "Guides/Guide.md", digest: "sha256:one" }], + new Map([["Pages/Guide.md", "sha256:one"]]), + {} + ) + ).toEqual([{ nodeKey: "node-1", path: "Pages/Guide.md", status: "moved" }]); + }); + + it("uses a recorded hint for a move followed by an edit", () => { + expect( + classifyWorkspaceChanges( + [{ nodeKey: "node-1", path: "Guides/Guide.md", digest: "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" }]); + }); +}); diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index 7d7cbf3f..3bfa685b 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import AdmZip = require("adm-zip"); import { WorkspaceService } from "../../../src/commands/workspace/workspace.service"; +import { fileService } from "../../../src/core/utils/file-service"; import { testContext } from "../../utls/test-context"; import { mockAxiosGet, mockAxiosPost, mockedAxiosInstance } from "../../utls/http-requests-mock"; @@ -20,6 +21,10 @@ function digest(value: string): string { return `sha256:${createHash("sha256").update(value).digest("hex")}`; } +function eTag(value: string): string { + return `"${digest(value)}"`; +} + function metadata(files: TestFile[]): Record { const nodes: Record = {}; const folders = new Map(); @@ -55,21 +60,21 @@ function metadata(files: TestFile[]): Record { function state( files: TestFile[], - serverRevision: string = "revision-1", + serverRevision: string = eTag("revision-1"), moveHints: Record = {} ): object { return { schemaVersion: 1, - packageKey: PACKAGE_KEY, - serverRevision: digest(serverRevision), + serverRevision, baselineDigests: Object.fromEntries(files.map(file => [file.nodeKey, digest(file.content)])), moveHints, }; } -function archive(files: TestFile[], serverRevision?: string, moveHints?: Record): Buffer { +function archive(files: TestFile[]): Buffer { const zip = new AdmZip(); - zip.addFile(".pacman/state.json", Buffer.from(JSON.stringify(state(files, serverRevision, moveHints)))); + zip.addFile(".pacman/.gitignore", Buffer.from("local/\n")); + zip.addFile(".pacman/package.json", Buffer.from(JSON.stringify({ schemaVersion: 1, packageKey: PACKAGE_KEY }))); Object.entries(metadata(files)).forEach(([nodeKey, node]) => { zip.addFile(`.pacman/nodes/${nodeKey}.json`, Buffer.from(JSON.stringify(node))); }); @@ -79,7 +84,13 @@ function archive(files: TestFile[], serverRevision?: string, moveHints?: Record< function writeWorkspace(files: TestFile[] = [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]): void { fs.mkdirSync(path.join(process.cwd(), ".pacman", "nodes"), { recursive: true }); - fs.writeFileSync(path.join(process.cwd(), ".pacman", "state.json"), JSON.stringify(state(files))); + fs.mkdirSync(path.join(process.cwd(), ".pacman", "local"), { recursive: true }); + fs.writeFileSync(path.join(process.cwd(), ".pacman", ".gitignore"), "local/\n"); + fs.writeFileSync( + path.join(process.cwd(), ".pacman", "package.json"), + JSON.stringify({ schemaVersion: 1, packageKey: PACKAGE_KEY }) + ); + fs.writeFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), JSON.stringify(state(files))); Object.entries(metadata(files)).forEach(([nodeKey, node]) => { fs.writeFileSync(path.join(process.cwd(), ".pacman", "nodes", `${nodeKey}.json`), JSON.stringify(node)); }); @@ -90,7 +101,7 @@ function writeWorkspace(files: TestFile[] = [{ nodeKey: "node-1", path: "Guides/ } function removeWorkspace(): void { - [".pacman", "Guides", "Pages", "Other", "New", PACKAGE_KEY].forEach(entry => + [".git", ".pacman", "Guides", "Pages", "Other", "New", PACKAGE_KEY].forEach(entry => fs.rmSync(path.join(process.cwd(), entry), { recursive: true, force: true }) ); } @@ -100,7 +111,11 @@ describe("Workspace service", () => { afterEach(removeWorkspace); it("clones and validates a filesystem archive", async () => { - mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }])); + mockAxiosGet( + ARCHIVE_URL, + archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]), + { etag: eTag("revision-1") } + ); const rename = jest.spyOn(fs, "renameSync"); try { @@ -109,6 +124,19 @@ describe("Workspace service", () => { 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, ".pacman", ".gitignore"), "utf-8")).toBe( + "local/\n" + ); + expect( + JSON.parse( + fs.readFileSync(path.join(process.cwd(), PACKAGE_KEY, ".pacman", "local", "state.json"), "utf-8") + ) + ).toEqual({ + schemaVersion: 1, + serverRevision: eTag("revision-1"), + baselineDigests: { "node-1": digest("original") }, + moveHints: {}, + }); 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())); @@ -119,24 +147,50 @@ describe("Workspace service", () => { it("pulls the latest archive 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; mockAxiosGet( ARCHIVE_URL, - archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }], "revision-2") + archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }]), + { etag: eTag("revision-2") } ); 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(), ".pacman", "state.json"), "utf-8"))).toMatchObject({ - serverRevision: digest("revision-2"), + expect(JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8"))).toMatchObject({ + serverRevision: eTag("revision-2"), baselineDigests: { "node-1": digest("remote") }, moveHints: {}, }); }); + it("hydrates local state after an external Git restore without overwriting files", async () => { + writeWorkspace(); + fs.rmSync(path.join(process.cwd(), ".pacman", "local"), { recursive: true }); + fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "git change"); + mockAxiosGet( + ARCHIVE_URL, + archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }]), + { etag: eTag("revision-2") } + ); + + 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(), ".pacman", "local", "state.json"), "utf-8"))).toMatchObject({ + serverRevision: eTag("revision-2"), + baselineDigests: { "node-1": digest("remote") }, + }); + }); + it("refuses to pull over local changes", async () => { writeWorkspace(); fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "local"); @@ -150,7 +204,8 @@ describe("Workspace service", () => { writeWorkspace(); mockAxiosGet( ARCHIVE_URL, - archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }], "revision-2") + archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }]), + { etag: eTag("revision-2") } ); const originalRename = fs.renameSync; const rename = jest.spyOn(fs, "renameSync").mockImplementation((source, target) => { @@ -176,7 +231,9 @@ describe("Workspace service", () => { 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(), ".pacman", "state.json"), "utf-8")); + const workspaceState = JSON.parse( + fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8") + ); expect(workspaceState).not.toHaveProperty("files"); expect(workspaceState).not.toHaveProperty("basePath"); expect(workspaceState).not.toHaveProperty("currentPath"); @@ -195,7 +252,7 @@ describe("Workspace service", () => { 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(), ".pacman", "state.json"), "utf-8"))).toMatchObject({ + expect(JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8"))).toMatchObject({ moveHints: { "node-1": "Pages/Guide.md" }, }); }); @@ -264,6 +321,16 @@ describe("Workspace service", () => { expect(() => new WorkspaceService(testContext).status()).toThrow("Circular node hierarchy"); }); + it("rejects volatile fields in stable node metadata", () => { + writeWorkspace(); + const nodePath = path.join(process.cwd(), ".pacman", "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("rejects duplicate case-insensitive paths derived from node metadata", () => { writeWorkspace([ { nodeKey: "node-1", path: "Guides/One.md", content: "one" }, @@ -333,13 +400,23 @@ describe("Workspace service", () => { ); }); - it("rejects an archive without workspace state", async () => { + it("rejects an archive without stable package metadata", async () => { const zip = new AdmZip(); zip.addFile("Guides/Guide.md", Buffer.from("original")); - mockAxiosGet(ARCHIVE_URL, zip.toBuffer()); + mockAxiosGet(ARCHIVE_URL, zip.toBuffer(), { etag: eTag("revision-1") }); + + await expect(new WorkspaceService(testContext).clone(PACKAGE_KEY)).rejects.toThrow( + "Archive does not contain Pacman package metadata" + ); + }); + + it("rejects downloaded archives containing local workspace state", async () => { + const zip = new AdmZip(archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }])); + zip.addFile(".pacman/local/state.json", Buffer.from("{}")); + mockAxiosGet(ARCHIVE_URL, zip.toBuffer(), { etag: eTag("revision-1") }); await expect(new WorkspaceService(testContext).clone(PACKAGE_KEY)).rejects.toThrow( - "Archive does not contain .pacman/state.json" + "Archive contains local Pacman workspace state" ); }); @@ -350,10 +427,12 @@ describe("Workspace service", () => { fs.writeFileSync(path.join(process.cwd(), "Pages", "Guide.md"), "changed"); const service = new WorkspaceService(testContext); service.move("Guides/Guide.md", "Pages/Guide.md", true); + const zip = jest.spyOn(fileService, "zipDirectoryAsSinglePackage"); mockAxiosPost(PUSH_URL, {}); mockAxiosGet( ARCHIVE_URL, - archive([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "changed" }], "revision-2") + archive([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "changed" }]), + { etag: eTag("revision-2") } ); await service.push(undefined, true); @@ -361,13 +440,20 @@ describe("Workspace service", () => { expect(mockedAxiosInstance.post).toHaveBeenCalledWith( PUSH_URL, expect.anything(), - expect.objectContaining({ params: { overwrite: true } }) + expect.objectContaining({ + params: { overwrite: true }, + headers: expect.objectContaining({ "If-Match": eTag("revision-1") }), + }) ); + const include = zip.mock.calls[0][1]!; + expect(include(".pacman/local/state.json")).toBe(false); + expect(include(".pacman/nodes/node-1.json")).toBe(true); + const form = (mockedAxiosInstance.post as jest.Mock).mock.calls[0][1] as { _streams: unknown[] }; + expect(form._streams).toContain(JSON.stringify({ moves: { "node-1": "Pages/Guide.md" } })); expect(service.status()).toEqual([]); - expect(JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "state.json"), "utf-8"))).toEqual({ + expect(JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8"))).toEqual({ schemaVersion: 1, - packageKey: PACKAGE_KEY, - serverRevision: digest("revision-2"), + serverRevision: eTag("revision-2"), baselineDigests: { "node-1": digest("changed") }, moveHints: {}, }); diff --git a/tests/core/utils/file.service.spec.ts b/tests/core/utils/file.service.spec.ts index dcd71be6..d96b1bf8 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, ".pacman"); + 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(".pacman/local") + ); + const entries = new AdmZip(zipPath).getEntries().map(entry => entry.entryName); + + expect(entries).toContain(".pacman/package.json"); + expect(entries).not.toContain(".pacman/local/state.json"); + }); }); describe("writeBufferToFileWithGivenName", () => { diff --git a/tests/utls/http-requests-mock.ts b/tests/utls/http-requests-mock.ts index 50734e5b..7baa751e 100644 --- a/tests/utls/http-requests-mock.ts +++ b/tests/utls/http-requests-mock.ts @@ -8,6 +8,7 @@ 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(); @@ -39,6 +40,7 @@ const mockAxios = () : void => { return Promise.resolve({ status: 200, data: readableStream, + headers: mockedGetHeadersByUrl.get(requestUrl) || {}, }); } else { return Promise.resolve({ status, data }); @@ -81,8 +83,9 @@ const mockAxios = () : void => { }); } -const mockAxiosGet = (url: string, responseData: any) => { +const mockAxiosGet = (url: string, responseData: any, headers: Record = {}) => { mockedGetResponseByUrl.set(url, responseData); + mockedGetHeadersByUrl.set(url, headers); mockedGetStatusByUrl.delete(url); mockedGetErrorByUrl.delete(url); }; @@ -132,6 +135,7 @@ const mockAxiosDelete = (url: string) => { afterEach(() => { mockedGetResponseByUrl.clear(); mockedGetStatusByUrl.clear(); + mockedGetHeadersByUrl.clear(); mockedGetErrorByUrl.clear(); mockedPostResponseByUrl.clear(); mockedPostErrorByUrl.clear(); From 903ff4e6e06dedd697557aa0d6a46c31dbc715e1 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Wed, 19 Aug 2026 19:03:48 +0200 Subject: [PATCH 11/46] Preserve workspace metadata backups Includes-AI-Code: true --- src/commands/workspace/workspace.service.ts | 15 ++++++-- .../workspace/workspace.service.spec.ts | 34 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index 25ad7cfc..6955c7d3 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import * as fs from "node:fs"; +import * as os from "node:os"; import * as path from "node:path"; import * as FormData from "form-data"; import AdmZip = require("adm-zip"); @@ -347,22 +348,30 @@ export class WorkspaceService { packageKey: string ): void { const extracted = this.validatedArchive(download, packageKey); - const refreshRoot = fs.mkdtempSync(path.join(root, ".pacman-refresh-")); + const refreshRoot = fs.mkdtempSync(path.join(os.tmpdir(), "content-cli-pacman-refresh-")); const stagedMetadata = path.join(refreshRoot, "metadata"); const previousMetadata = path.join(refreshRoot, "previous"); const metadata = path.join(root, ".pacman"); + let preserveBackup = false; try { fs.cpSync(path.join(extracted, ".pacman"), stagedMetadata, { recursive: true }); fs.renameSync(metadata, previousMetadata); try { fs.renameSync(stagedMetadata, metadata); } catch (error) { - fs.renameSync(previousMetadata, metadata); + try { + fs.renameSync(previousMetadata, metadata); + } catch (restoreError) { + preserveBackup = true; + throw new GracefulError(`Metadata refresh failed; workspace metadata backup remains at ${previousMetadata}.`); + } throw error; } } finally { fs.rmSync(extracted, { recursive: true, force: true }); - fs.rmSync(refreshRoot, { recursive: true, force: true }); + if (!preserveBackup) { + fs.rmSync(refreshRoot, { recursive: true, force: true }); + } } } diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index 3bfa685b..185f61d5 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -459,6 +459,40 @@ describe("Workspace service", () => { }); }); + it("preserves the metadata backup when refresh and rollback both fail", async () => { + writeWorkspace(); + fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "changed"); + mockAxiosPost(PUSH_URL, {}); + mockAxiosGet( + ARCHIVE_URL, + archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "changed" }]), + { etag: eTag("revision-2") } + ); + const originalRename = fs.renameSync; + const rename = jest.spyOn(fs, "renameSync").mockImplementation((source, target) => { + if (target.toString() === path.join(process.cwd(), ".pacman")) { + throw new Error("rename failed"); + } + originalRename(source, target); + }); + let backup: string | undefined; + + try { + await new WorkspaceService(testContext).push(); + } catch (error) { + expect(error).toBeInstanceOf(Error); + const match = (error as Error).message.match(/backup remains at (.+)\.$/); + backup = match?.[1]; + } finally { + rename.mockRestore(); + } + + expect(backup).toBeDefined(); + expect(fs.existsSync(backup!)).toBe(true); + originalRename(backup!, path.join(process.cwd(), ".pacman")); + fs.rmSync(path.dirname(backup!), { recursive: true, force: true }); + }); + it("does not push unresolved file identities", async () => { writeWorkspace(); fs.mkdirSync(path.join(process.cwd(), "Pages")); From 42d201a468cc71e1165f4380d29b44b838511002 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Wed, 19 Aug 2026 19:05:33 +0200 Subject: [PATCH 12/46] Distinguish workspace deletion and addition Includes-AI-Code: true --- .../workspace/workspace-change-classifier.ts | 8 -------- .../workspace/workspace-change-classifier.spec.ts | 13 +++++++++++++ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/commands/workspace/workspace-change-classifier.ts b/src/commands/workspace/workspace-change-classifier.ts index 484154d9..9632f0e6 100644 --- a/src/commands/workspace/workspace-change-classifier.ts +++ b/src/commands/workspace/workspace-change-classifier.ts @@ -100,14 +100,6 @@ function possibleMovedAndEdited( remainingPaths.delete(matchingBasenames[0]); } }); - const pairedKeys = new Set(pairs.map(([file]) => file.nodeKey)); - const remainingMissing = missing.filter(file => !pairedKeys.has(file.nodeKey)); - if (remainingMissing.length === 1 && remainingPaths.size === 1) { - const candidate = [...remainingPaths][0]; - if (path.posix.extname(remainingMissing[0].path).toLowerCase() === path.posix.extname(candidate).toLowerCase()) { - pairs.push([remainingMissing[0], candidate]); - } - } return pairs; } diff --git a/tests/commands/workspace/workspace-change-classifier.spec.ts b/tests/commands/workspace/workspace-change-classifier.spec.ts index 9a678f75..fac75e98 100644 --- a/tests/commands/workspace/workspace-change-classifier.spec.ts +++ b/tests/commands/workspace/workspace-change-classifier.spec.ts @@ -30,4 +30,17 @@ describe("Workspace change classifier", () => { ) ).toEqual([{ nodeKey: "node-1", path: "Pages/Guide.md", status: "moved, modified" }]); }); + + it("keeps a distinct deletion and addition separate", () => { + expect( + classifyWorkspaceChanges( + [{ nodeKey: "node-1", path: "Guides/Old.md", digest: "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" }, + ]); + }); }); From e98a6828665bd3db00c26ca5ec204016d1864674 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Wed, 19 Aug 2026 19:24:02 +0200 Subject: [PATCH 13/46] Address workspace quality checks Includes-AI-Code: true --- .../workspace/workspace-change-classifier.ts | 116 +++++++++++------- src/commands/workspace/workspace.service.ts | 16 ++- src/core/http/http-client.ts | 4 +- .../workspace-change-classifier.spec.ts | 30 +++++ .../workspace/workspace.service.spec.ts | 36 ++++++ 5 files changed, 152 insertions(+), 50 deletions(-) diff --git a/src/commands/workspace/workspace-change-classifier.ts b/src/commands/workspace/workspace-change-classifier.ts index 9632f0e6..1d1b6e51 100644 --- a/src/commands/workspace/workspace-change-classifier.ts +++ b/src/commands/workspace/workspace-change-classifier.ts @@ -10,48 +10,87 @@ export function classifyWorkspaceChanges( const consumedPaths = new Set(); const missing: ExpectedWorkspaceFile[] = []; - expectedFiles.forEach(file => { - const hint = moveHints[file.nodeKey]; - if (!file.digest) { - if (hint || !visibleFiles.has(file.path)) { - changes.push({ nodeKey: file.nodeKey, path: hint || file.path, status: "unresolved" }); - return; - } - consumedPaths.add(file.path); - changes.push({ nodeKey: file.nodeKey, path: file.path, status: "added" }); + expectedFiles.forEach(file => + classifyExpectedFile(file, visibleFiles, moveHints[file.nodeKey], changes, consumedPaths, missing) + ); + + resolveDigestMoves(missing, visibleFiles, consumedPaths, changes); + + const newPaths = [...visibleFiles.keys()].filter(filePath => !consumedPaths.has(filePath)); + possibleMovedAndEdited(missing, newPaths).forEach(([file, filePath]) => { + changes.push({ nodeKey: file.nodeKey, path: filePath, status: "unresolved" }); + missing.splice(missing.indexOf(file), 1); + newPaths.splice(newPaths.indexOf(filePath), 1); + }); + missing.forEach(file => changes.push({ nodeKey: file.nodeKey, path: file.path, status: "deleted" })); + 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, + hint: string | undefined, + changes: ClassifiedWorkspaceChange[], + consumedPaths: Set, + missing: ExpectedWorkspaceFile[] +): void { + if (!file.digest) { + if (hint || !visibleFiles.has(file.path)) { + changes.push({ nodeKey: file.nodeKey, path: hint || file.path, status: "unresolved" }); return; } - if (hint && hint !== file.path) { - if (visibleFiles.has(file.path) || !visibleFiles.has(hint) || consumedPaths.has(hint)) { - changes.push({ nodeKey: file.nodeKey, path: hint, status: "unresolved" }); - if (visibleFiles.has(hint)) { - consumedPaths.add(hint); - } - if (visibleFiles.has(file.path)) { - consumedPaths.add(file.path); - } - return; - } + consumedPaths.add(file.path); + changes.push({ nodeKey: file.nodeKey, path: file.path, status: "added" }); + return; + } + if (hint && hint !== file.path) { + classifyHintedFile(file, hint, visibleFiles, changes, consumedPaths); + return; + } + if (visibleFiles.has(file.path)) { + consumedPaths.add(file.path); + if (visibleFiles.get(file.path) !== file.digest) { + changes.push({ nodeKey: file.nodeKey, path: file.path, status: "modified" }); + } + return; + } + missing.push(file); +} + +function classifyHintedFile( + file: ExpectedWorkspaceFile, + hint: string, + visibleFiles: Map, + changes: ClassifiedWorkspaceChange[], + consumedPaths: Set +): void { + if (visibleFiles.has(file.path) || !visibleFiles.has(hint) || consumedPaths.has(hint)) { + changes.push({ nodeKey: file.nodeKey, path: hint, status: "unresolved" }); + if (visibleFiles.has(hint)) { consumedPaths.add(hint); - changes.push({ - nodeKey: file.nodeKey, - path: hint, - status: visibleFiles.get(hint) === file.digest ? "moved" : "moved, modified", - }); - return; } if (visibleFiles.has(file.path)) { consumedPaths.add(file.path); - if (visibleFiles.get(file.path) !== file.digest) { - changes.push({ nodeKey: file.nodeKey, path: file.path, status: "modified" }); - } - return; } - missing.push(file); + return; + } + consumedPaths.add(hint); + changes.push({ + nodeKey: file.nodeKey, + path: hint, + status: visibleFiles.get(hint) === file.digest ? "moved" : "moved, modified", }); +} - const missingByDigest = groupBy(missing, file => file.digest!); - missingByDigest.forEach((files, digest) => { +function resolveDigestMoves( + missing: ExpectedWorkspaceFile[], + visibleFiles: Map, + consumedPaths: Set, + changes: ClassifiedWorkspaceChange[] +): void { + groupBy(missing, file => file.digest!).forEach((files, digest) => { const candidates = [...visibleFiles.entries()] .filter(([filePath, visibleDigest]) => !consumedPaths.has(filePath) && visibleDigest === digest) .map(([filePath]) => filePath); @@ -72,17 +111,6 @@ export function classifyWorkspaceChanges( }); } }); - - const newPaths = [...visibleFiles.keys()].filter(filePath => !consumedPaths.has(filePath)); - possibleMovedAndEdited(missing, newPaths).forEach(([file, filePath]) => { - changes.push({ nodeKey: file.nodeKey, path: filePath, status: "unresolved" }); - missing.splice(missing.indexOf(file), 1); - newPaths.splice(newPaths.indexOf(filePath), 1); - }); - missing.forEach(file => changes.push({ nodeKey: file.nodeKey, path: file.path, status: "deleted" })); - 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 possibleMovedAndEdited( diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index 6955c7d3..f10ea06d 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -363,7 +363,11 @@ export class WorkspaceService { fs.renameSync(previousMetadata, metadata); } catch (restoreError) { preserveBackup = true; - throw new GracefulError(`Metadata refresh failed; workspace metadata backup remains at ${previousMetadata}.`); + const failure = new GracefulError( + `Metadata refresh failed; workspace metadata backup remains at ${previousMetadata}.` + ); + failure.cause = restoreError; + throw failure; } throw error; } @@ -484,7 +488,9 @@ export class WorkspaceService { this.moveEntries(backup, root); } catch (restoreError) { preserveBackup = true; - throw new GracefulError(`Pull failed; workspace backup remains at ${backup}.`); + const failure = new GracefulError(`Pull failed; workspace backup remains at ${backup}.`); + failure.cause = restoreError; + throw failure; } throw error; } @@ -498,7 +504,9 @@ export class WorkspaceService { this.moveEntries(backup, root); } catch (restoreError) { preserveBackup = true; - throw new GracefulError(`Pull failed; workspace backup remains at ${backup}.`); + const failure = new GracefulError(`Pull failed; workspace backup remains at ${backup}.`); + failure.cause = restoreError; + throw failure; } throw error; } @@ -513,7 +521,7 @@ export class WorkspaceService { private moveEntries(source: string, target: string, excluded: Set = new Set()): void { fs.readdirSync(source) .filter(entry => !excluded.has(entry)) - .sort() + .sort((left, right) => left.localeCompare(right)) .forEach(entry => fs.renameSync(path.join(source, entry), path.join(target, entry))); } diff --git a/src/core/http/http-client.ts b/src/core/http/http-client.ts index ed4027fc..e24e53a0 100644 --- a/src/core/http/http-client.ts +++ b/src/core/http/http-client.ts @@ -69,7 +69,7 @@ export class HttpClient { }); response.data.on("end", () => { if (response.status !== 200) { - reject(Buffer.concat(data as any).toString()); + reject(new Error(Buffer.concat(data as any).toString())); return; } @@ -79,7 +79,7 @@ export class HttpClient { return; } logger.error("Could not get file stream from response"); - reject(); + reject(new Error("Could not get file stream from response")); }); }).catch(err => { this.handleError(err, resolve, reject); diff --git a/tests/commands/workspace/workspace-change-classifier.spec.ts b/tests/commands/workspace/workspace-change-classifier.spec.ts index fac75e98..6b3e70b7 100644 --- a/tests/commands/workspace/workspace-change-classifier.spec.ts +++ b/tests/commands/workspace/workspace-change-classifier.spec.ts @@ -43,4 +43,34 @@ describe("Workspace change classifier", () => { { path: "Pages/New.md", status: "added" }, ]); }); + + it("classifies a metadata-backed file without a baseline as added", () => { + expect( + classifyWorkspaceChanges( + [{ nodeKey: "node-1", path: "Guides/New.md" }], + new Map([["Guides/New.md", "sha256:new"]]), + {} + ) + ).toEqual([{ nodeKey: "node-1", path: "Guides/New.md", status: "added" }]); + }); + + it("keeps an invalid recorded move unresolved", () => { + expect( + classifyWorkspaceChanges( + [{ nodeKey: "node-1", path: "Guides/Guide.md", digest: "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( + [{ nodeKey: "node-1", path: "Guides/Guide.md", digest: "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.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index 185f61d5..a88c0c4c 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -225,6 +225,42 @@ describe("Workspace service", () => { expect(new WorkspaceService(testContext).status()).toEqual([]); }); + it("preserves the workspace backup when pull and rollback both fail", async () => { + writeWorkspace(); + mockAxiosGet( + ARCHIVE_URL, + archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }]), + { etag: eTag("revision-2") } + ); + const originalRename = fs.renameSync; + const rename = jest.spyOn(fs, "renameSync").mockImplementation((source, target) => { + const sourceParent = path.basename(path.dirname(source.toString())); + if (sourceParent.startsWith(".pacman-pull-")) { + throw new Error("rename failed"); + } + originalRename(source, target); + }); + let backup: string | undefined; + + try { + await new WorkspaceService(testContext).pull(); + } catch (error) { + expect(error).toBeInstanceOf(Error); + const match = (error as Error).message.match(/backup remains at (.+)\.$/); + backup = match?.[1]; + } finally { + rename.mockRestore(); + } + + expect(backup).toBeDefined(); + expect(fs.existsSync(backup!)).toBe(true); + fs.readdirSync(backup!).forEach(entry => { + originalRename(path.join(backup!, entry), path.join(process.cwd(), entry)); + }); + fs.rmSync(backup!, { recursive: true, force: true }); + expect(new WorkspaceService(testContext).status()).toEqual([]); + }); + it("infers an unchanged move performed by another tool without writing path state", () => { writeWorkspace(); fs.mkdirSync(path.join(process.cwd(), "Pages")); From bd631d05b97fdf72aff788e66dafe6816b54d806 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Wed, 19 Aug 2026 19:31:22 +0200 Subject: [PATCH 14/46] Cover workspace command dispatch Includes-AI-Code: true --- tests/commands/workspace/module.spec.ts | 29 ++++++++++++ .../workspace/workspace.service.spec.ts | 45 +++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/tests/commands/workspace/module.spec.ts b/tests/commands/workspace/module.spec.ts index 9f41ee7a..e4cd3da8 100644 --- a/tests/commands/workspace/module.spec.ts +++ b/tests/commands/workspace/module.spec.ts @@ -1,4 +1,7 @@ +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 { testContext } from "../../utls/test-context"; import { createMockConfigurator } from "../../utls/configurator-mock"; @@ -17,4 +20,30 @@ describe("Workspace module", () => { expect(configurator.beta).toHaveBeenCalledTimes(6); expect(configurator.action).toHaveBeenCalledTimes(5); }); + + it("dispatches workspace command arguments and options", async () => { + const clone = jest.spyOn(WorkspaceService.prototype, "clone").mockResolvedValue(); + const pull = jest.spyOn(WorkspaceService.prototype, "pull").mockResolvedValue(); + const status = jest.spyOn(WorkspaceService.prototype, "status").mockReturnValue([]); + 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"); + await execute("workspace", "pull", "target"); + await execute("workspace", "status", "target"); + await execute("workspace", "push", "target", "--overwrite"); + await execute("workspace", "move", "old.md", "new.md", "--record"); + + expect(clone).toHaveBeenCalledWith("package-key", "target"); + expect(pull).toHaveBeenCalledWith("target"); + expect(status).toHaveBeenCalledWith("target"); + expect(push).toHaveBeenCalledWith("target", true); + expect(move).toHaveBeenCalledWith("old.md", "new.md", true); + }); }); diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index a88c0c4c..3df56c20 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -145,6 +145,17 @@ describe("Workspace service", () => { } }); + it("rejects a clone response without an ETag", async () => { + mockAxiosGet( + ARCHIVE_URL, + archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]) + ); + + await expect(new WorkspaceService(testContext).clone(PACKAGE_KEY)).rejects.toThrow( + "Filesystem archive response does not contain an ETag." + ); + }); + it("pulls the latest archive into a clean existing workspace", async () => { writeWorkspace(); fs.mkdirSync(path.join(process.cwd(), ".git")); @@ -347,6 +358,40 @@ describe("Workspace service", () => { 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(), ".pacman", "nodes", "folder-1.json"); From 774d3f484807b8de50ecad1793a23ead6669ab90 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Wed, 19 Aug 2026 19:38:58 +0200 Subject: [PATCH 15/46] Fix workspace refresh and reconciled moves Includes-AI-Code: true --- .../workspace/workspace-change-classifier.ts | 7 +-- src/commands/workspace/workspace.service.ts | 3 +- .../workspace-change-classifier.spec.ts | 10 +++++ .../workspace/workspace.service.spec.ts | 45 ++++++++++++++++++- 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/commands/workspace/workspace-change-classifier.ts b/src/commands/workspace/workspace-change-classifier.ts index 1d1b6e51..1c12beb8 100644 --- a/src/commands/workspace/workspace-change-classifier.ts +++ b/src/commands/workspace/workspace-change-classifier.ts @@ -45,7 +45,7 @@ function classifyExpectedFile( changes.push({ nodeKey: file.nodeKey, path: file.path, status: "added" }); return; } - if (hint && hint !== file.path) { + if (hint) { classifyHintedFile(file, hint, visibleFiles, changes, consumedPaths); return; } @@ -66,12 +66,13 @@ function classifyHintedFile( changes: ClassifiedWorkspaceChange[], consumedPaths: Set ): void { - if (visibleFiles.has(file.path) || !visibleFiles.has(hint) || consumedPaths.has(hint)) { + const sourceStillPresent = hint !== file.path && visibleFiles.has(file.path); + if (sourceStillPresent || !visibleFiles.has(hint) || consumedPaths.has(hint)) { changes.push({ nodeKey: file.nodeKey, path: hint, status: "unresolved" }); if (visibleFiles.has(hint)) { consumedPaths.add(hint); } - if (visibleFiles.has(file.path)) { + if (sourceStillPresent) { consumedPaths.add(file.path); } return; diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index f10ea06d..cac4ed2b 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -1,6 +1,5 @@ import { createHash } from "node:crypto"; import * as fs from "node:fs"; -import * as os from "node:os"; import * as path from "node:path"; import * as FormData from "form-data"; import AdmZip = require("adm-zip"); @@ -348,7 +347,7 @@ export class WorkspaceService { packageKey: string ): void { const extracted = this.validatedArchive(download, packageKey); - const refreshRoot = fs.mkdtempSync(path.join(os.tmpdir(), "content-cli-pacman-refresh-")); + const refreshRoot = fs.mkdtempSync(path.join(root, ".pacman-refresh-")); const stagedMetadata = path.join(refreshRoot, "metadata"); const previousMetadata = path.join(refreshRoot, "previous"); const metadata = path.join(root, ".pacman"); diff --git a/tests/commands/workspace/workspace-change-classifier.spec.ts b/tests/commands/workspace/workspace-change-classifier.spec.ts index 6b3e70b7..ed8ddfe1 100644 --- a/tests/commands/workspace/workspace-change-classifier.spec.ts +++ b/tests/commands/workspace/workspace-change-classifier.spec.ts @@ -31,6 +31,16 @@ describe("Workspace change classifier", () => { ).toEqual([{ nodeKey: "node-1", path: "Pages/Guide.md", status: "moved, modified" }]); }); + it("uses a reconciliation hint when metadata already names the destination", () => { + expect( + classifyWorkspaceChanges( + [{ nodeKey: "node-1", path: "Pages/Guide.md", digest: "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 distinct deletion and addition separate", () => { expect( classifyWorkspaceChanges( diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index 3df56c20..9f522acd 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -202,6 +202,36 @@ describe("Workspace service", () => { }); }); + 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(), ".pacman", "local"), { recursive: true }); + mockAxiosGet( + ARCHIVE_URL, + archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]), + { etag: eTag("revision-2") } + ); + 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(), ".pacman", "local", "state.json"), "utf-8"))).toMatchObject({ + moveHints: { "node-1": "Pages/Guide.md" }, + }); + + mockAxiosPost(PUSH_URL, {}); + mockAxiosGet( + ARCHIVE_URL, + archive([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "original" }]), + { etag: eTag("revision-3") } + ); + await service.push(); + + const form = (mockedAxiosInstance.post as jest.Mock).mock.calls[0][1] as { _streams: unknown[] }; + expect(form._streams).toContain(JSON.stringify({ moves: { "node-1": "Pages/Guide.md" } })); + expect(service.status()).toEqual([]); + }); + it("refuses to pull over local changes", async () => { writeWorkspace(); fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "local"); @@ -509,6 +539,15 @@ describe("Workspace service", () => { const service = new WorkspaceService(testContext); service.move("Guides/Guide.md", "Pages/Guide.md", true); const zip = jest.spyOn(fileService, "zipDirectoryAsSinglePackage"); + const originalRename = fs.renameSync; + const rename = jest.spyOn(fs, "renameSync").mockImplementation((source, target) => { + const sourceInsideWorkspace = path.resolve(source.toString()).startsWith(`${process.cwd()}${path.sep}`); + const targetInsideWorkspace = path.resolve(target.toString()).startsWith(`${process.cwd()}${path.sep}`); + if (sourceInsideWorkspace !== targetInsideWorkspace) { + throw Object.assign(new Error("cross-device rename"), { code: "EXDEV" }); + } + originalRename(source, target); + }); mockAxiosPost(PUSH_URL, {}); mockAxiosGet( ARCHIVE_URL, @@ -516,7 +555,11 @@ describe("Workspace service", () => { { etag: eTag("revision-2") } ); - await service.push(undefined, true); + try { + await service.push(undefined, true); + } finally { + rename.mockRestore(); + } expect(mockedAxiosInstance.post).toHaveBeenCalledWith( PUSH_URL, From 9c16030e6632705492b7d58ec42e10532025cd51 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Wed, 19 Aug 2026 19:46:11 +0200 Subject: [PATCH 16/46] Handle ambiguous workspace moves Includes-AI-Code: true --- .../workspace/workspace-change-classifier.ts | 78 +++++++++++++------ .../workspace-change-classifier.spec.ts | 37 +++++++++ 2 files changed, 90 insertions(+), 25 deletions(-) diff --git a/src/commands/workspace/workspace-change-classifier.ts b/src/commands/workspace/workspace-change-classifier.ts index 1c12beb8..f6f9f396 100644 --- a/src/commands/workspace/workspace-change-classifier.ts +++ b/src/commands/workspace/workspace-change-classifier.ts @@ -17,12 +17,10 @@ export function classifyWorkspaceChanges( resolveDigestMoves(missing, visibleFiles, consumedPaths, changes); const newPaths = [...visibleFiles.keys()].filter(filePath => !consumedPaths.has(filePath)); - possibleMovedAndEdited(missing, newPaths).forEach(([file, filePath]) => { - changes.push({ nodeKey: file.nodeKey, path: filePath, status: "unresolved" }); - missing.splice(missing.indexOf(file), 1); - newPaths.splice(newPaths.indexOf(filePath), 1); - }); - missing.forEach(file => changes.push({ nodeKey: file.nodeKey, path: file.path, status: "deleted" })); + 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)); @@ -37,12 +35,11 @@ function classifyExpectedFile( missing: ExpectedWorkspaceFile[] ): void { if (!file.digest) { - if (hint || !visibleFiles.has(file.path)) { - changes.push({ nodeKey: file.nodeKey, path: hint || file.path, status: "unresolved" }); - return; + if (hint || visibleFiles.has(file.path)) { + classifyNewFile(file, hint, visibleFiles, changes, consumedPaths); + } else { + missing.push(file); } - consumedPaths.add(file.path); - changes.push({ nodeKey: file.nodeKey, path: file.path, status: "added" }); return; } if (hint) { @@ -59,6 +56,29 @@ function classifyExpectedFile( missing.push(file); } +function classifyNewFile( + file: ExpectedWorkspaceFile, + hint: string | undefined, + visibleFiles: Map, + changes: ClassifiedWorkspaceChange[], + consumedPaths: Set +): void { + const target = hint || file.path; + const sourceStillPresent = Boolean(hint && hint !== file.path && visibleFiles.has(file.path)); + if (sourceStillPresent || !visibleFiles.has(target) || consumedPaths.has(target)) { + changes.push({ nodeKey: file.nodeKey, path: target, status: "unresolved" }); + if (visibleFiles.has(target)) { + consumedPaths.add(target); + } + if (sourceStillPresent) { + consumedPaths.add(file.path); + } + return; + } + consumedPaths.add(target); + changes.push({ nodeKey: file.nodeKey, path: target, status: "added" }); +} + function classifyHintedFile( file: ExpectedWorkspaceFile, hint: string, @@ -91,7 +111,10 @@ function resolveDigestMoves( consumedPaths: Set, changes: ClassifiedWorkspaceChange[] ): void { - groupBy(missing, file => file.digest!).forEach((files, digest) => { + 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); @@ -114,22 +137,27 @@ function resolveDigestMoves( }); } -function possibleMovedAndEdited( +function resolveMovedAndEdited( missing: ExpectedWorkspaceFile[], - newPaths: string[] -): Array<[ExpectedWorkspaceFile, string]> { - const pairs: Array<[ExpectedWorkspaceFile, string]> = []; - const remainingPaths = new Set(newPaths); - missing.forEach(file => { - const matchingBasenames = [...remainingPaths].filter( - filePath => path.posix.basename(filePath).toLowerCase() === path.posix.basename(file.path).toLowerCase() - ); - if (matchingBasenames.length === 1) { - pairs.push([file, matchingBasenames[0]]); - remainingPaths.delete(matchingBasenames[0]); + 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)); }); - return pairs; } function groupBy(values: T[], key: (value: T) => string): Map { diff --git a/tests/commands/workspace/workspace-change-classifier.spec.ts b/tests/commands/workspace/workspace-change-classifier.spec.ts index ed8ddfe1..57863aac 100644 --- a/tests/commands/workspace/workspace-change-classifier.spec.ts +++ b/tests/commands/workspace/workspace-change-classifier.spec.ts @@ -54,6 +54,23 @@ describe("Workspace change classifier", () => { ]); }); + it("keeps ambiguous moved and edited basenames unresolved", () => { + expect( + classifyWorkspaceChanges( + [ + { nodeKey: "node-1", path: "Guides/Guide.md", digest: "sha256:one" }, + { nodeKey: "node-2", path: "Tutorials/Guide.md", digest: "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( @@ -64,6 +81,26 @@ describe("Workspace change classifier", () => { ).toEqual([{ nodeKey: "node-1", path: "Guides/New.md", status: "added" }]); }); + it("keeps an unrecorded move without a baseline unresolved once", () => { + expect( + classifyWorkspaceChanges( + [{ nodeKey: "node-1", path: "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( + [{ nodeKey: "node-1", path: "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( From 2098478f818685c46c9b89d2d848d9041e068835 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Wed, 19 Aug 2026 19:55:56 +0200 Subject: [PATCH 17/46] Harden workspace push recovery Includes-AI-Code: true --- src/commands/workspace/workspace.service.ts | 20 +++++-- .../workspace/workspace.service.spec.ts | 52 +++++++++++++++++-- 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index cac4ed2b..0183d5d2 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -143,8 +143,18 @@ export class WorkspaceService { form.append("moveMappings", JSON.stringify({ moves }), { contentType: "application/json" }); } await this.api.push(snapshot.packageKey, form, overwrite, snapshot.state.serverRevision); - const refreshedArchive = await this.api.download(snapshot.packageKey); - this.refreshMetadata(root, refreshedArchive, snapshot.packageKey); + fs.rmSync(this.statePath(root), { force: true }); + try { + const refreshedArchive = await this.api.download(snapshot.packageKey); + this.refreshMetadata(root, refreshedArchive, snapshot.packageKey); + } catch (error) { + const detail = error instanceof GracefulError ? ` ${error.message}` : ""; + const failure = new GracefulError( + `Push succeeded, but local state refresh failed.${detail} Run workspace pull before retrying.` + ); + failure.cause = error; + throw failure; + } } finally { fs.rmSync(zipPath, { force: true }); } @@ -185,6 +195,8 @@ export class WorkspaceService { } 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}`); } @@ -347,7 +359,9 @@ export class WorkspaceService { packageKey: string ): void { const extracted = this.validatedArchive(download, packageKey); - const refreshRoot = fs.mkdtempSync(path.join(root, ".pacman-refresh-")); + const refreshRoot = fs.mkdtempSync( + path.join(path.dirname(root), `.${path.basename(root)}-pacman-refresh-`) + ); const stagedMetadata = path.join(refreshRoot, "metadata"); const previousMetadata = path.join(refreshRoot, "previous"); const metadata = path.join(root, ".pacman"); diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index 9f522acd..79d9ef58 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -5,7 +5,7 @@ import AdmZip = require("adm-zip"); import { WorkspaceService } from "../../../src/commands/workspace/workspace.service"; import { fileService } from "../../../src/core/utils/file-service"; import { testContext } from "../../utls/test-context"; -import { mockAxiosGet, mockAxiosPost, mockedAxiosInstance } from "../../utls/http-requests-mock"; +import { mockAxiosGet, mockAxiosGetError, mockAxiosPost, mockedAxiosInstance } from "../../utls/http-requests-mock"; const PACKAGE_KEY = "pkg-1"; const ARCHIVE_URL = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${PACKAGE_KEY}/file-archive`; @@ -376,6 +376,19 @@ describe("Workspace service", () => { expect(fs.existsSync(path.join(process.cwd(), "Pages", "Guide.md"))).toBe(true); }); + 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(), ".pacman", "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" }, @@ -541,9 +554,18 @@ describe("Workspace service", () => { const zip = jest.spyOn(fileService, "zipDirectoryAsSinglePackage"); const originalRename = fs.renameSync; const rename = jest.spyOn(fs, "renameSync").mockImplementation((source, target) => { - const sourceInsideWorkspace = path.resolve(source.toString()).startsWith(`${process.cwd()}${path.sep}`); - const targetInsideWorkspace = path.resolve(target.toString()).startsWith(`${process.cwd()}${path.sep}`); - if (sourceInsideWorkspace !== targetInsideWorkspace) { + const workspaceRoot = `${process.cwd()}${path.sep}`; + const refreshPrefix = path.join( + path.dirname(process.cwd()), + `.${path.basename(process.cwd())}-pacman-refresh-` + ); + const sourcePath = path.resolve(source.toString()); + const targetPath = path.resolve(target.toString()); + const sourceOnWorkspaceFilesystem = + sourcePath.startsWith(workspaceRoot) || sourcePath.startsWith(refreshPrefix); + const targetOnWorkspaceFilesystem = + targetPath.startsWith(workspaceRoot) || targetPath.startsWith(refreshPrefix); + if (sourceOnWorkspaceFilesystem !== targetOnWorkspaceFilesystem) { throw Object.assign(new Error("cross-device rename"), { code: "EXDEV" }); } originalRename(source, target); @@ -583,6 +605,25 @@ describe("Workspace service", () => { }); }); + it("invalidates local state when post-push refresh fails", async () => { + writeWorkspace(); + fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "changed"); + mockAxiosPost(PUSH_URL, {}); + mockAxiosGetError(ARCHIVE_URL, 503, { message: "unavailable" }); + const service = new WorkspaceService(testContext); + + await expect(service.push()).rejects.toThrow("Push succeeded, but local state refresh failed"); + expect(fs.existsSync(path.join(process.cwd(), ".pacman", "local", "state.json"))).toBe(false); + + mockAxiosGet( + ARCHIVE_URL, + archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "changed" }]), + { etag: eTag("revision-2") } + ); + await service.pull(); + expect(service.status()).toEqual([]); + }); + it("preserves the metadata backup when refresh and rollback both fail", async () => { writeWorkspace(); fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "changed"); @@ -605,7 +646,7 @@ describe("Workspace service", () => { await new WorkspaceService(testContext).push(); } catch (error) { expect(error).toBeInstanceOf(Error); - const match = (error as Error).message.match(/backup remains at (.+)\.$/); + const match = (error as Error).message.match(/backup remains at (.+?)\. Run workspace pull before retrying\.$/); backup = match?.[1]; } finally { rename.mockRestore(); @@ -613,6 +654,7 @@ describe("Workspace service", () => { expect(backup).toBeDefined(); expect(fs.existsSync(backup!)).toBe(true); + expect(backup!.startsWith(`${process.cwd()}${path.sep}`)).toBe(false); originalRename(backup!, path.join(process.cwd(), ".pacman")); fs.rmSync(path.dirname(backup!), { recursive: true, force: true }); }); From d1c029adf7d63b58d9a3ca21f052afbd9211f687 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Wed, 19 Aug 2026 20:02:24 +0200 Subject: [PATCH 18/46] Accept Windows workspace gitignore Includes-AI-Code: true --- src/commands/workspace/workspace.service.ts | 3 ++- .../commands/workspace/workspace.service.spec.ts | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index 0183d5d2..9ac8443d 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -645,7 +645,8 @@ export class WorkspaceService { private validateGitignore(root: string): void { const gitignore = path.join(root, ".pacman", ".gitignore"); - if (!fs.existsSync(gitignore) || fs.readFileSync(gitignore, "utf-8") !== "local/\n") { + const content = fs.existsSync(gitignore) ? fs.readFileSync(gitignore, "utf-8") : undefined; + if (content !== "local/\n" && content !== "local/\r\n") { throw new GracefulError("Workspace .pacman/.gitignore must contain local/."); } } diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index 79d9ef58..974fd56f 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -202,6 +202,22 @@ describe("Workspace service", () => { }); }); + it("hydrates a Git-restored workspace with CRLF metadata ignore rules", async () => { + writeWorkspace(); + fs.rmSync(path.join(process.cwd(), ".pacman", "local"), { recursive: true }); + fs.writeFileSync(path.join(process.cwd(), ".pacman", ".gitignore"), "local/\r\n"); + mockAxiosGet( + ARCHIVE_URL, + archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]), + { etag: eTag("revision-2") } + ); + + 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(), ".pacman", "local"), { recursive: true }); From fee919c12a58978bd9fe5e48611020cacb1e2404 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Wed, 19 Aug 2026 20:09:50 +0200 Subject: [PATCH 19/46] Handle case-only workspace moves Includes-AI-Code: true --- .../workspace/workspace-change-classifier.ts | 66 ++++++++++++------- .../workspace-change-classifier.spec.ts | 10 +++ .../workspace/workspace.service.spec.ts | 5 +- 3 files changed, 56 insertions(+), 25 deletions(-) diff --git a/src/commands/workspace/workspace-change-classifier.ts b/src/commands/workspace/workspace-change-classifier.ts index f6f9f396..00d94109 100644 --- a/src/commands/workspace/workspace-change-classifier.ts +++ b/src/commands/workspace/workspace-change-classifier.ts @@ -9,9 +9,18 @@ export function classifyWorkspaceChanges( 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, moveHints[file.nodeKey], changes, consumedPaths, missing) + classifyExpectedFile( + file, + visibleFiles, + visiblePathIndex, + moveHints[file.nodeKey], + changes, + consumedPaths, + missing + ) ); resolveDigestMoves(missing, visibleFiles, consumedPaths, changes); @@ -29,27 +38,32 @@ export function classifyWorkspaceChanges( 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 || visibleFiles.has(file.path)) { - classifyNewFile(file, hint, visibleFiles, changes, consumedPaths); + if (hint || currentPath) { + classifyNewFile(file, hint, visibleFiles, visiblePathIndex, changes, consumedPaths); } else { missing.push(file); } return; } if (hint) { - classifyHintedFile(file, hint, visibleFiles, changes, consumedPaths); + classifyHintedFile(file, hint, visibleFiles, visiblePathIndex, changes, consumedPaths); return; } - if (visibleFiles.has(file.path)) { - consumedPaths.add(file.path); - if (visibleFiles.get(file.path) !== file.digest) { - changes.push({ nodeKey: file.nodeKey, path: file.path, status: "modified" }); + if (currentPath) { + consumedPaths.add(currentPath); + const changed = visibleFiles.get(currentPath) !== file.digest; + if (currentPath !== file.path) { + changes.push({ nodeKey: file.nodeKey, path: currentPath, status: changed ? "moved, modified" : "moved" }); + } else if (changed) { + changes.push({ nodeKey: file.nodeKey, path: currentPath, status: "modified" }); } return; } @@ -60,22 +74,25 @@ function classifyNewFile( file: ExpectedWorkspaceFile, hint: string | undefined, visibleFiles: Map, + visiblePathIndex: Map, changes: ClassifiedWorkspaceChange[], consumedPaths: Set ): void { const target = hint || file.path; - const sourceStillPresent = Boolean(hint && hint !== file.path && visibleFiles.has(file.path)); - if (sourceStillPresent || !visibleFiles.has(target) || consumedPaths.has(target)) { + 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 || !targetPath || consumedPaths.has(targetPath)) { changes.push({ nodeKey: file.nodeKey, path: target, status: "unresolved" }); - if (visibleFiles.has(target)) { - consumedPaths.add(target); + if (targetPath) { + consumedPaths.add(targetPath); } - if (sourceStillPresent) { - consumedPaths.add(file.path); + if (sourceStillPresent && sourcePath) { + consumedPaths.add(sourcePath); } return; } - consumedPaths.add(target); + consumedPaths.add(targetPath); changes.push({ nodeKey: file.nodeKey, path: target, status: "added" }); } @@ -83,25 +100,28 @@ function classifyHintedFile( file: ExpectedWorkspaceFile, hint: string, visibleFiles: Map, + visiblePathIndex: Map, changes: ClassifiedWorkspaceChange[], consumedPaths: Set ): void { - const sourceStillPresent = hint !== file.path && visibleFiles.has(file.path); - if (sourceStillPresent || !visibleFiles.has(hint) || consumedPaths.has(hint)) { + const targetPath = visiblePathIndex.get(hint.toLowerCase()); + const sourcePath = visiblePathIndex.get(file.path.toLowerCase()); + const sourceStillPresent = hint.toLowerCase() !== file.path.toLowerCase() && Boolean(sourcePath); + if (sourceStillPresent || !targetPath || consumedPaths.has(targetPath)) { changes.push({ nodeKey: file.nodeKey, path: hint, status: "unresolved" }); - if (visibleFiles.has(hint)) { - consumedPaths.add(hint); + if (targetPath) { + consumedPaths.add(targetPath); } - if (sourceStillPresent) { - consumedPaths.add(file.path); + if (sourceStillPresent && sourcePath) { + consumedPaths.add(sourcePath); } return; } - consumedPaths.add(hint); + consumedPaths.add(targetPath); changes.push({ nodeKey: file.nodeKey, path: hint, - status: visibleFiles.get(hint) === file.digest ? "moved" : "moved, modified", + status: visibleFiles.get(targetPath) === file.digest ? "moved" : "moved, modified", }); } diff --git a/tests/commands/workspace/workspace-change-classifier.spec.ts b/tests/commands/workspace/workspace-change-classifier.spec.ts index 57863aac..1260364c 100644 --- a/tests/commands/workspace/workspace-change-classifier.spec.ts +++ b/tests/commands/workspace/workspace-change-classifier.spec.ts @@ -41,6 +41,16 @@ describe("Workspace change classifier", () => { ).toEqual([{ nodeKey: "node-1", path: "Pages/Guide.md", status: "moved" }]); }); + it("uses a recorded case-only move on a case-insensitive path", () => { + expect( + classifyWorkspaceChanges( + [{ nodeKey: "node-1", path: "Guides/Guide.md", digest: "sha256:one" }], + new Map([["Guides/Guide.md", "sha256:one"]]), + { "node-1": "Guides/guide.md" } + ) + ).toEqual([{ nodeKey: "node-1", path: "Guides/guide.md", status: "moved" }]); + }); + it("keeps a distinct deletion and addition separate", () => { expect( classifyWorkspaceChanges( diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index 974fd56f..8cf095f3 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -524,8 +524,9 @@ describe("Workspace service", () => { }); try { - new WorkspaceService(testContext).move("Guides/Guide.md", "Guides/guide.md"); - expect(fs.renameSync).toBeDefined(); + const service = new WorkspaceService(testContext); + service.move("Guides/Guide.md", "Guides/guide.md"); + expect(service.status()).toEqual([{ path: "Guides/guide.md", status: "moved" }]); } finally { lstat.mockRestore(); exists.mockRestore(); From 60bc926fbb8b7dcefd03babdaa3845f538c3a69c Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Wed, 19 Aug 2026 20:18:01 +0200 Subject: [PATCH 20/46] Align workspace move path matching Includes-AI-Code: true --- src/commands/workspace/workspace.service.ts | 12 +++++++++--- .../workspace/workspace.service.spec.ts | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index 9ac8443d..3e5fd37f 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -339,16 +339,22 @@ export class WorkspaceService { } private trackedFile(snapshot: WorkspaceSnapshot, sourcePath: string): ExpectedWorkspaceFile | undefined { - const expected = snapshot.expectedFiles.find(file => file.path === sourcePath); + const foldedSourcePath = sourcePath.toLowerCase(); + const expected = snapshot.expectedFiles.find(file => file.path.toLowerCase() === foldedSourcePath); if (expected) { return expected; } - const hinted = snapshot.expectedFiles.find(file => snapshot.state.moveHints[file.nodeKey] === sourcePath); + const hinted = snapshot.expectedFiles.find( + file => snapshot.state.moveHints[file.nodeKey]?.toLowerCase() === foldedSourcePath + ); if (hinted) { return hinted; } const classified = snapshot.changes.find( - change => change.path === sourcePath && change.nodeKey && (change.status === "moved" || change.status === "moved, modified") + 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; } diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index 8cf095f3..dd7cccc7 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -350,6 +350,23 @@ describe("Workspace service", () => { }); }); + 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(), ".pacman", "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" }, From e08e56aa7826a51b00a2de9ce6e9531316201fe3 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Wed, 19 Aug 2026 20:25:40 +0200 Subject: [PATCH 21/46] Recover workspace after refresh failure Includes-AI-Code: true --- src/commands/workspace/workspace.models.ts | 1 + src/commands/workspace/workspace.service.ts | 19 ++++++++++++++----- .../workspace/workspace.service.spec.ts | 17 +++++++++++++---- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/commands/workspace/workspace.models.ts b/src/commands/workspace/workspace.models.ts index deb12b62..f3fec7de 100644 --- a/src/commands/workspace/workspace.models.ts +++ b/src/commands/workspace/workspace.models.ts @@ -3,6 +3,7 @@ export interface WorkspaceState { serverRevision: string; baselineDigests: Record; moveHints: Record; + refreshRequired?: boolean; } export interface WorkspacePackageIdentity { diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index 3e5fd37f..468d7df1 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -85,12 +85,13 @@ export class WorkspaceService { const root = this.root(directory); const packageKey = this.packageIdentity(root).packageKey; const hasLocalState = fs.existsSync(this.statePath(root)); - if (hasLocalState && this.snapshot(root).changes.length !== 0) { + const localState = hasLocalState ? this.state(root) : undefined; + if (localState && !localState.refreshRequired && this.snapshot(root).changes.length !== 0) { throw new GracefulError("Workspace has local changes. Push or discard them before pull."); } const temporary = this.validatedArchive(await this.api.download(packageKey), packageKey); try { - if (hasLocalState) { + if (localState) { this.replaceWorkspaceContents(root, temporary); } else { this.reconcileLocalState(root, temporary); @@ -143,7 +144,7 @@ export class WorkspaceService { form.append("moveMappings", JSON.stringify({ moves }), { contentType: "application/json" }); } await this.api.push(snapshot.packageKey, form, overwrite, snapshot.state.serverRevision); - fs.rmSync(this.statePath(root), { force: true }); + this.writeState(root, { ...snapshot.state, refreshRequired: true }); try { const refreshedArchive = await this.api.download(snapshot.packageKey); this.refreshMetadata(root, refreshedArchive, snapshot.packageKey); @@ -203,6 +204,9 @@ export class WorkspaceService { private snapshot(root: string): WorkspaceSnapshot { const packageKey = this.packageIdentity(root).packageKey; const state = this.state(root); + if (state.refreshRequired) { + throw new GracefulError("Workspace synchronization state needs refresh. Run workspace pull."); + } const expectedFiles = this.expectedFiles(root, state, packageKey); const visibleFiles = this.visibleFiles(root); return { @@ -585,16 +589,21 @@ export class WorkspaceService { (typeof parsed.moveHints !== "object" || parsed.moveHints === null || Array.isArray(parsed.moveHints) || - !Object.values(parsed.moveHints).every(value => typeof value === "string"))) + !Object.values(parsed.moveHints).every(value => typeof value === "string"))) || + (parsed.refreshRequired !== undefined && parsed.refreshRequired !== true) ) { throw new GracefulError("Unsupported Pacman workspace state."); } - return { + const state: WorkspaceState = { schemaVersion: parsed.schemaVersion, serverRevision: parsed.serverRevision, baselineDigests: parsed.baselineDigests, moveHints: parsed.moveHints || {}, }; + if (parsed.refreshRequired) { + state.refreshRequired = true; + } + return state; } private relativeVisiblePath(root: string, value: string): string { diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index dd7cccc7..df252535 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -639,23 +639,32 @@ describe("Workspace service", () => { }); }); - it("invalidates local state when post-push refresh fails", async () => { + it("replaces the workspace on pull when post-push refresh fails", async () => { writeWorkspace(); - fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "changed"); + fs.mkdirSync(path.join(process.cwd(), "Pages")); + fs.renameSync( + path.join(process.cwd(), "Guides", "Guide.md"), + path.join(process.cwd(), "Pages", "Guide.md") + ); mockAxiosPost(PUSH_URL, {}); mockAxiosGetError(ARCHIVE_URL, 503, { message: "unavailable" }); const service = new WorkspaceService(testContext); await expect(service.push()).rejects.toThrow("Push succeeded, but local state refresh failed"); - expect(fs.existsSync(path.join(process.cwd(), ".pacman", "local", "state.json"))).toBe(false); + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8")) + ).toMatchObject({ refreshRequired: true }); + expect(() => service.status()).toThrow("Workspace synchronization state needs refresh"); mockAxiosGet( ARCHIVE_URL, - archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "changed" }]), + archive([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "original" }]), { etag: eTag("revision-2") } ); await service.pull(); expect(service.status()).toEqual([]); + expect(fs.existsSync(path.join(process.cwd(), "Guides", "Guide.md"))).toBe(false); + expect(fs.readFileSync(path.join(process.cwd(), "Pages", "Guide.md"), "utf-8")).toBe("original"); }); it("preserves the metadata backup when refresh and rollback both fail", async () => { From 8551a17ce24eaef5de843af007fae0b56cdf4c64 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Thu, 20 Aug 2026 10:47:04 +0200 Subject: [PATCH 22/46] Add incremental workspace push Includes-AI-Code: true --- src/commands/workspace/module.ts | 10 +- src/commands/workspace/workspace-api.ts | 53 ++- .../workspace/workspace-push.service.ts | 204 +++++++++ src/commands/workspace/workspace.models.ts | 20 + src/commands/workspace/workspace.service.ts | 116 ++++- src/core/http/http-client.ts | 43 +- tests/commands/workspace/module.spec.ts | 6 +- .../workspace/workspace.service.spec.ts | 408 ++++++++++++++---- tests/utls/http-requests-mock.ts | 74 +++- 9 files changed, 798 insertions(+), 136 deletions(-) create mode 100644 src/commands/workspace/workspace-push.service.ts diff --git a/src/commands/workspace/module.ts b/src/commands/workspace/module.ts index 01ff2132..a84c77fe 100644 --- a/src/commands/workspace/module.ts +++ b/src/commands/workspace/module.ts @@ -14,10 +14,11 @@ class Module extends IModule { workspace.command("status [directory]").beta().description("Show local changes.").action(this.status); workspace - .command("push [directory]") + .command("push [paths...]") .beta() .description("Push local changes.") - .option("--overwrite", "Replace missing remote files", false) + .option("--full", "Push the full workspace archive", false) + .option("--overwrite", "Replace missing remote files during a full push", false) .action(this.push); workspace @@ -41,7 +42,10 @@ class Module extends IModule { } private async push(context: Context, command: Command, options: OptionValues): Promise { - await new WorkspaceService(context).push(command.args[0], options.overwrite); + await new WorkspaceService(context).push(command.args, { + full: options.full, + overwrite: options.overwrite, + }); } private async move(context: Context, command: Command, options: OptionValues): Promise { diff --git a/src/commands/workspace/workspace-api.ts b/src/commands/workspace/workspace-api.ts index 2bbecb75..0745d103 100644 --- a/src/commands/workspace/workspace-api.ts +++ b/src/commands/workspace/workspace-api.ts @@ -1,6 +1,7 @@ import * as FormData from "form-data"; import { Context } from "../../core/command/cli-context"; import { GracefulError } from "../../core/utils/logger"; +import { NodeFileWriteResponse } from "./workspace.models"; export class WorkspaceApi { constructor(private readonly context: Context) {} @@ -16,7 +17,7 @@ export class WorkspaceApi { return { archive: response.data, eTag }; } - public push(packageKey: string, data: FormData, overwrite: boolean, eTag: string): Promise { + public pushArchive(packageKey: string, data: FormData, overwrite: boolean, eTag: string): Promise { return this.context.httpClient.postFile( `/pacman/api/core/staging/packages/${encodeURIComponent(packageKey)}/file-archive`, data, @@ -24,4 +25,54 @@ export class WorkspaceApi { { "If-Match": eTag } ); } + + 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 putFile( + packageKey: string, + filePath: string, + body: Buffer, + contentType: string, + headers: Record + ): Promise { + return this.context.httpClient.putFile( + this.fileUrl(packageKey, filePath), + body, + contentType, + undefined, + headers + ); + } + + 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 }); + } + + private fileUrl(packageKey: string, filePath: string): string { + const encodedPath = filePath + .split("/") + .map(segment => encodeURIComponent(segment)) + .join("/"); + return `/pacman/api/core/staging/packages/${encodeURIComponent(packageKey)}/files/${encodedPath}`; + } } diff --git a/src/commands/workspace/workspace-push.service.ts b/src/commands/workspace/workspace-push.service.ts new file mode 100644 index 00000000..78ddccf5 --- /dev/null +++ b/src/commands/workspace/workspace-push.service.ts @@ -0,0 +1,204 @@ +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 { + ClassifiedWorkspaceChange, + ExpectedWorkspaceFile, + WorkspacePushOutcome, + WorkspaceSnapshot, +} from "./workspace.models"; + +interface Selection { + path: string; + directory: boolean; +} + +export class WorkspacePushService { + constructor(private readonly api: WorkspaceApi) {} + + public async push(root: string, snapshot: WorkspaceSnapshot, paths: string[]): Promise { + const changes = this.select(root, snapshot, paths); + const outcomes: WorkspacePushOutcome[] = []; + for (const change of changes) { + try { + await this.pushChange(root, snapshot, change); + outcomes.push({ ...change, success: true }); + } catch (error) { + outcomes.push({ + ...change, + success: false, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return outcomes; + } + + private select(root: string, snapshot: WorkspaceSnapshot, paths: string[]): ClassifiedWorkspaceChange[] { + if (paths.length === 0) { + return snapshot.changes; + } + const expectedByNodeKey = new Map(snapshot.expectedFiles.map(file => [file.nodeKey, file])); + const candidates = snapshot.changes.map(change => ({ + change, + paths: [change.path, change.nodeKey ? expectedByNodeKey.get(change.nodeKey)?.path : undefined].filter( + (value): value is string => Boolean(value) + ), + })); + const selections = paths.map(value => + this.selection( + root, + value, + candidates.flatMap(candidate => candidate.paths) + ) + ); + return candidates + .filter(candidate => + selections.some(selection => + candidate.paths.some(candidatePath => + selection.directory + ? !selection.path || + candidatePath === selection.path || + candidatePath.startsWith(`${selection.path}/`) + : candidatePath === selection.path + ) + ) + ) + .map(candidate => candidate.change); + } + + private 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("/"); + if ( + relative === ".." || + relative.startsWith("../") || + path.isAbsolute(relative) || + relative.toLowerCase() === ".pacman" || + relative.toLowerCase().startsWith(".pacman/") || + relative.toLowerCase() === ".git" || + relative.toLowerCase().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.startsWith(`${relative}/`)); + return { path: relative, directory }; + } + + private async pushChange( + root: string, + snapshot: WorkspaceSnapshot, + change: ClassifiedWorkspaceChange + ): Promise { + 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": + if (expected) { + throw new GracefulError("Tracked file is missing synchronization state. Run workspace pull."); + } + await this.api.putFile( + snapshot.packageKey, + change.path, + this.content(root, change.path), + this.contentType(change.path), + { "If-None-Match": "*" } + ); + return; + case "modified": { + const eTag = await this.currentETag(snapshot.packageKey, this.requireExpected(expected), change.path); + await this.api.putFile( + snapshot.packageKey, + change.path, + this.content(root, change.path), + this.contentType(change.path), + { "If-Match": eTag } + ); + return; + } + case "moved": { + const tracked = this.requireExpected(expected); + const eTag = await this.currentETag(snapshot.packageKey, tracked, tracked.path); + await this.api.moveFile(snapshot.packageKey, tracked.path, change.path, eTag); + return; + } + case "moved, modified": { + const tracked = this.requireExpected(expected); + let eTag = await this.currentETag(snapshot.packageKey, tracked, tracked.path); + if (tracked.path !== change.path) { + eTag = (await this.api.moveFile(snapshot.packageKey, tracked.path, change.path, eTag)).eTag; + } + await this.api.putFile( + snapshot.packageKey, + change.path, + this.content(root, change.path), + this.contentType(change.path), + { "If-Match": eTag } + ); + return; + } + 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 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 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 index f3fec7de..2bc9f08d 100644 --- a/src/commands/workspace/workspace.models.ts +++ b/src/commands/workspace/workspace.models.ts @@ -45,3 +45,23 @@ export interface WorkspaceSnapshot { visibleFiles: Map; changes: ClassifiedWorkspaceChange[]; } + +export interface WorkspacePushOptions { + full?: boolean; + overwrite?: boolean; +} + +export interface WorkspacePushOutcome { + path: string; + status: WorkspaceChangeStatus; + nodeKey?: string; + success: 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 index 468d7df1..0d4d24f8 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -8,11 +8,13 @@ import { fileService } from "../../core/utils/file-service"; import { GracefulError, logger } from "../../core/utils/logger"; import { WorkspaceApi } from "./workspace-api"; import { classifyWorkspaceChanges } from "./workspace-change-classifier"; +import { WorkspacePushService } from "./workspace-push.service"; import { ExpectedWorkspaceFile, WorkspaceChange, WorkspaceNodeMetadata, WorkspacePackageIdentity, + WorkspacePushOptions, WorkspaceSnapshot, WorkspaceState, } from "./workspace.models"; @@ -89,7 +91,16 @@ export class WorkspaceService { if (localState && !localState.refreshRequired && this.snapshot(root).changes.length !== 0) { throw new GracefulError("Workspace has local changes. Push or discard them before pull."); } - const temporary = this.validatedArchive(await this.api.download(packageKey), packageKey); + const download = await this.api.download(packageKey); + if (localState?.refreshRequired) { + const moveHints = localState.moveHints; + this.refreshMetadata(root, download, packageKey); + const refreshed = this.state(root); + this.writeState(root, { ...refreshed, moveHints: { ...refreshed.moveHints, ...moveHints } }); + logger.info(`Pulled ${packageKey}.`); + return; + } + const temporary = this.validatedArchive(download, packageKey); try { if (localState) { this.replaceWorkspaceContents(root, temporary); @@ -115,8 +126,69 @@ export class WorkspaceService { return changes; } - public async push(directory?: string, overwrite: boolean = false): Promise { - const root = this.root(directory); + public async push(paths: string[] = [], options: WorkspacePushOptions = {}): Promise { + if (options.full) { + if (paths.length > 0) { + throw new GracefulError("Workspace paths cannot be combined with --full."); + } + await this.pushFull(Boolean(options.overwrite)); + return; + } + if (options.overwrite) { + throw new GracefulError("--overwrite requires --full."); + } + const root = this.root(); + const snapshot = this.snapshot(root); + const outcomes = await new WorkspacePushService(this.api).push(root, snapshot, paths); + outcomes.forEach(outcome => + logger.info( + `${outcome.success ? "succeeded" : "failed"}: ${outcome.status} ${outcome.path}` + + (outcome.error ? ` (${outcome.error})` : "") + ) + ); + const succeeded = outcomes.filter(outcome => outcome.success); + const failed = outcomes.filter(outcome => !outcome.success); + if (succeeded.length === 0) { + if (failed.length > 0) { + throw new GracefulError(`Workspace push failed for ${failed.length} file(s).`); + } + logger.info("Workspace is clean."); + return; + } + const retainedHints = Object.fromEntries( + failed + .filter( + outcome => outcome.nodeKey && (outcome.status === "moved" || outcome.status === "moved, modified") + ) + .map(outcome => [outcome.nodeKey!, outcome.path]) + ); + try { + this.refreshMetadata(root, await this.api.download(snapshot.packageKey), snapshot.packageKey); + const refreshed = this.state(root); + this.writeState(root, { + ...refreshed, + moveHints: { ...refreshed.moveHints, ...retainedHints }, + }); + } catch (error) { + this.writeState(root, { + ...snapshot.state, + moveHints: { ...snapshot.state.moveHints, ...retainedHints }, + 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} file(s).`); + } + logger.info(`Pushed ${snapshot.packageKey}.`); + } + + private async pushFull(overwrite: boolean): Promise { + const root = this.root(); const snapshot = this.snapshot(root); if (snapshot.changes.some(change => change.status === "unresolved")) { throw new GracefulError("Workspace has unresolved file identities. Record the intended moves before push."); @@ -135,15 +207,17 @@ export class WorkspaceService { form.append("packageFile", fs.createReadStream(zipPath), { filename: "workspace.zip" }); const moves = Object.fromEntries( snapshot.changes - .filter(change => - Boolean(change.nodeKey) && (change.status === "moved" || change.status === "moved, modified") + .filter( + change => + Boolean(change.nodeKey) && + (change.status === "moved" || change.status === "moved, modified") ) .map(change => [change.nodeKey!, change.path]) ); if (Object.keys(moves).length > 0) { form.append("moveMappings", JSON.stringify({ moves }), { contentType: "application/json" }); } - await this.api.push(snapshot.packageKey, form, overwrite, snapshot.state.serverRevision); + await this.api.pushArchive(snapshot.packageKey, form, overwrite, snapshot.state.serverRevision); this.writeState(root, { ...snapshot.state, refreshRequired: true }); try { const refreshedArchive = await this.api.download(snapshot.packageKey); @@ -281,7 +355,9 @@ export class WorkspaceService { .filter(entry => entry.isFile() && entry.name.endsWith(".json")) .sort((left, right) => left.name.localeCompare(right.name)) .map(entry => { - const node = JSON.parse(fs.readFileSync(path.join(directory, entry.name), "utf-8")) as WorkspaceNodeMetadata; + const node = JSON.parse( + fs.readFileSync(path.join(directory, entry.name), "utf-8") + ) as WorkspaceNodeMetadata; const fields = node as unknown as Record; if ( !node.key || @@ -363,21 +439,23 @@ export class WorkspaceService { return classified ? snapshot.expectedFiles.find(file => file.nodeKey === classified.nodeKey) : undefined; } - private refreshMetadata( - root: string, - download: { archive: Buffer; eTag: string }, - packageKey: string - ): void { + private refreshMetadata(root: string, download: { archive: Buffer; eTag: string }, packageKey: string): void { const extracted = this.validatedArchive(download, packageKey); - const refreshRoot = fs.mkdtempSync( - path.join(path.dirname(root), `.${path.basename(root)}-pacman-refresh-`) - ); + try { + this.replaceMetadataDirectory(root, path.join(extracted, ".pacman")); + } finally { + fs.rmSync(extracted, { recursive: true, force: true }); + } + } + + private replaceMetadataDirectory(root: string, sourceMetadata: string): void { + const refreshRoot = fs.mkdtempSync(path.join(path.dirname(root), `.${path.basename(root)}-pacman-refresh-`)); const stagedMetadata = path.join(refreshRoot, "metadata"); const previousMetadata = path.join(refreshRoot, "previous"); const metadata = path.join(root, ".pacman"); let preserveBackup = false; try { - fs.cpSync(path.join(extracted, ".pacman"), stagedMetadata, { recursive: true }); + fs.cpSync(sourceMetadata, stagedMetadata, { recursive: true }); fs.renameSync(metadata, previousMetadata); try { fs.renameSync(stagedMetadata, metadata); @@ -395,7 +473,6 @@ export class WorkspaceService { throw error; } } finally { - fs.rmSync(extracted, { recursive: true, force: true }); if (!preserveBackup) { fs.rmSync(refreshRoot, { recursive: true, force: true }); } @@ -465,6 +542,7 @@ export class WorkspaceService { const reconciledState = { ...remoteState, moveHints }; const expectedFiles = this.expectedFiles(root, reconciledState, packageKey); classifyWorkspaceChanges(expectedFiles, this.visibleFiles(root), moveHints); + this.replaceMetadataDirectory(root, path.join(remoteRoot, ".pacman")); this.writeState(root, reconciledState); } @@ -647,7 +725,9 @@ export class WorkspaceService { } private packageIdentity(root: string): WorkspacePackageIdentity { - const parsed = JSON.parse(fs.readFileSync(this.packageIdentityPath(root), "utf-8")) as Partial; + const parsed = JSON.parse( + fs.readFileSync(this.packageIdentityPath(root), "utf-8") + ) as Partial; if (parsed.schemaVersion !== 1 || !parsed.packageKey || Object.keys(parsed).length !== 2) { throw new GracefulError("Unsupported Pacman package metadata."); } diff --git a/src/core/http/http-client.ts b/src/core/http/http-client.ts index e24e53a0..4d08c5ac 100644 --- a/src/core/http/http-client.ts +++ b/src/core/http/http-client.ts @@ -157,10 +157,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 => { diff --git a/tests/commands/workspace/module.spec.ts b/tests/commands/workspace/module.spec.ts index e4cd3da8..c945d486 100644 --- a/tests/commands/workspace/module.spec.ts +++ b/tests/commands/workspace/module.spec.ts @@ -15,7 +15,7 @@ describe("Workspace module", () => { expect(configurator.command).toHaveBeenCalledWith("clone [directory]"); expect(configurator.command).toHaveBeenCalledWith("pull [directory]"); expect(configurator.command).toHaveBeenCalledWith("status [directory]"); - expect(configurator.command).toHaveBeenCalledWith("push [directory]"); + expect(configurator.command).toHaveBeenCalledWith("push [paths...]"); expect(configurator.command).toHaveBeenCalledWith("move "); expect(configurator.beta).toHaveBeenCalledTimes(6); expect(configurator.action).toHaveBeenCalledTimes(5); @@ -37,13 +37,13 @@ describe("Workspace module", () => { await execute("workspace", "clone", "package-key", "target"); await execute("workspace", "pull", "target"); await execute("workspace", "status", "target"); - await execute("workspace", "push", "target", "--overwrite"); + await execute("workspace", "push", "target", "other.md"); await execute("workspace", "move", "old.md", "new.md", "--record"); expect(clone).toHaveBeenCalledWith("package-key", "target"); expect(pull).toHaveBeenCalledWith("target"); expect(status).toHaveBeenCalledWith("target"); - expect(push).toHaveBeenCalledWith("target", true); + expect(push).toHaveBeenCalledWith(["target", "other.md"], { full: false, overwrite: false }); expect(move).toHaveBeenCalledWith("old.md", "new.md", true); }); }); diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index df252535..33562c93 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -5,12 +5,25 @@ import AdmZip = require("adm-zip"); import { WorkspaceService } from "../../../src/commands/workspace/workspace.service"; import { fileService } from "../../../src/core/utils/file-service"; import { testContext } from "../../utls/test-context"; -import { mockAxiosGet, mockAxiosGetError, mockAxiosPost, mockedAxiosInstance } from "../../utls/http-requests-mock"; +import { + mockAxiosDelete, + mockAxiosGet, + mockAxiosGetError, + mockAxiosPatch, + mockAxiosPost, + mockAxiosPut, + mockAxiosPutError, + mockedAxiosInstance, +} from "../../utls/http-requests-mock"; const PACKAGE_KEY = "pkg-1"; const ARCHIVE_URL = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${PACKAGE_KEY}/file-archive`; const PUSH_URL = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${PACKAGE_KEY}/file-archive`; +function fileUrl(filePath: string): string { + return `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${PACKAGE_KEY}/files/${filePath}`; +} + interface TestFile { nodeKey: string; path: string; @@ -75,6 +88,7 @@ function archive(files: TestFile[]): Buffer { const zip = new AdmZip(); zip.addFile(".pacman/.gitignore", Buffer.from("local/\n")); zip.addFile(".pacman/package.json", Buffer.from(JSON.stringify({ schemaVersion: 1, packageKey: PACKAGE_KEY }))); + zip.addFile(".pacman/nodes/", Buffer.alloc(0)); Object.entries(metadata(files)).forEach(([nodeKey, node]) => { zip.addFile(`.pacman/nodes/${nodeKey}.json`, Buffer.from(JSON.stringify(node))); }); @@ -82,7 +96,9 @@ function archive(files: TestFile[]): Buffer { return zip.toBuffer(); } -function writeWorkspace(files: TestFile[] = [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]): void { +function writeWorkspace( + files: TestFile[] = [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }] +): void { fs.mkdirSync(path.join(process.cwd(), ".pacman", "nodes"), { recursive: true }); fs.mkdirSync(path.join(process.cwd(), ".pacman", "local"), { recursive: true }); fs.writeFileSync(path.join(process.cwd(), ".pacman", ".gitignore"), "local/\n"); @@ -101,8 +117,8 @@ function writeWorkspace(files: TestFile[] = [{ nodeKey: "node-1", path: "Guides/ } function removeWorkspace(): void { - [".git", ".pacman", "Guides", "Pages", "Other", "New", PACKAGE_KEY].forEach(entry => - fs.rmSync(path.join(process.cwd(), entry), { recursive: true, force: true }) + [".git", ".pacman", "Guides", "Pages", "Other", "New", "Bulk", "Selected", "Unselected", PACKAGE_KEY].forEach( + entry => fs.rmSync(path.join(process.cwd(), entry), { recursive: true, force: true }) ); } @@ -111,11 +127,9 @@ describe("Workspace service", () => { afterEach(removeWorkspace); it("clones and validates a filesystem archive", async () => { - mockAxiosGet( - ARCHIVE_URL, - archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]), - { etag: eTag("revision-1") } - ); + mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]), { + etag: eTag("revision-1"), + }); const rename = jest.spyOn(fs, "renameSync"); try { @@ -146,10 +160,7 @@ describe("Workspace service", () => { }); it("rejects a clone response without an ETag", async () => { - mockAxiosGet( - ARCHIVE_URL, - archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]) - ); + mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }])); await expect(new WorkspaceService(testContext).clone(PACKAGE_KEY)).rejects.toThrow( "Filesystem archive response does not contain an ETag." @@ -161,11 +172,9 @@ describe("Workspace service", () => { fs.mkdirSync(path.join(process.cwd(), ".git")); fs.writeFileSync(path.join(process.cwd(), ".git", "marker"), "keep"); const workspaceInode = fs.statSync(process.cwd()).ino; - mockAxiosGet( - ARCHIVE_URL, - archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }]), - { etag: eTag("revision-2") } - ); + mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }]), { + etag: eTag("revision-2"), + }); await new WorkspaceService(testContext).pull(); @@ -173,7 +182,9 @@ describe("Workspace service", () => { 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(), ".pacman", "local", "state.json"), "utf-8"))).toMatchObject({ + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8")) + ).toMatchObject({ serverRevision: eTag("revision-2"), baselineDigests: { "node-1": digest("remote") }, moveHints: {}, @@ -184,19 +195,17 @@ describe("Workspace service", () => { writeWorkspace(); fs.rmSync(path.join(process.cwd(), ".pacman", "local"), { recursive: true }); fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "git change"); - mockAxiosGet( - ARCHIVE_URL, - archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }]), - { etag: eTag("revision-2") } - ); + mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }]), { + etag: eTag("revision-2"), + }); 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(), ".pacman", "local", "state.json"), "utf-8"))).toMatchObject({ + expect(new WorkspaceService(testContext).status()).toEqual([{ path: "Guides/Guide.md", status: "modified" }]); + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8")) + ).toMatchObject({ serverRevision: eTag("revision-2"), baselineDigests: { "node-1": digest("remote") }, }); @@ -206,11 +215,9 @@ describe("Workspace service", () => { writeWorkspace(); fs.rmSync(path.join(process.cwd(), ".pacman", "local"), { recursive: true }); fs.writeFileSync(path.join(process.cwd(), ".pacman", ".gitignore"), "local/\r\n"); - mockAxiosGet( - ARCHIVE_URL, - archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]), - { etag: eTag("revision-2") } - ); + mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]), { + etag: eTag("revision-2"), + }); const service = new WorkspaceService(testContext); await service.pull(); @@ -221,30 +228,37 @@ describe("Workspace service", () => { 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(), ".pacman", "local"), { recursive: true }); - mockAxiosGet( - ARCHIVE_URL, - archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]), - { etag: eTag("revision-2") } - ); + mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]), { + etag: eTag("revision-2"), + }); 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(), ".pacman", "local", "state.json"), "utf-8"))).toMatchObject({ + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8")) + ).toMatchObject({ moveHints: { "node-1": "Pages/Guide.md" }, }); - mockAxiosPost(PUSH_URL, {}); - mockAxiosGet( - ARCHIVE_URL, - archive([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "original" }]), - { etag: eTag("revision-3") } - ); + 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"), + }); + mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "original" }]), { + etag: eTag("revision-3"), + }); await service.push(); - const form = (mockedAxiosInstance.post as jest.Mock).mock.calls[0][1] as { _streams: unknown[] }; - expect(form._streams).toContain(JSON.stringify({ moves: { "node-1": "Pages/Guide.md" } })); + 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([]); }); @@ -259,11 +273,9 @@ describe("Workspace service", () => { it("restores the existing workspace when applying a pull fails", async () => { writeWorkspace(); - mockAxiosGet( - ARCHIVE_URL, - archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }]), - { etag: eTag("revision-2") } - ); + mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }]), { + etag: eTag("revision-2"), + }); const originalRename = fs.renameSync; const rename = jest.spyOn(fs, "renameSync").mockImplementation((source, target) => { const sourceParent = path.basename(path.dirname(source.toString())); @@ -284,11 +296,9 @@ describe("Workspace service", () => { it("preserves the workspace backup when pull and rollback both fail", async () => { writeWorkspace(); - mockAxiosGet( - ARCHIVE_URL, - archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }]), - { etag: eTag("revision-2") } - ); + mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }]), { + etag: eTag("revision-2"), + }); const originalRename = fs.renameSync; const rename = jest.spyOn(fs, "renameSync").mockImplementation((source, target) => { const sourceParent = path.basename(path.dirname(source.toString())); @@ -345,7 +355,9 @@ describe("Workspace service", () => { 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(), ".pacman", "local", "state.json"), "utf-8"))).toMatchObject({ + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8")) + ).toMatchObject({ moveHints: { "node-1": "Pages/Guide.md" }, }); }); @@ -353,10 +365,7 @@ describe("Workspace service", () => { 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") - ); + 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); @@ -417,7 +426,9 @@ describe("Workspace service", () => { 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(), ".pacman", "local", "state.json"), "utf-8"))).toMatchObject({ + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8")) + ).toMatchObject({ moveHints: { "node-1": "Pages/Guide.md" }, }); }); @@ -445,9 +456,9 @@ describe("Workspace service", () => { 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"); + 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", () => { @@ -510,7 +521,10 @@ describe("Workspace service", () => { isDirectory: () => false, isFile: () => true, }); - const readdir = jest.spyOn(fs, "readdirSync").mockImplementation(((directory: fs.PathLike, options?: object) => { + 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")]; } @@ -578,6 +592,219 @@ describe("Workspace service", () => { ); }); + 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(fileUrl(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, + })); + mockAxiosGet(ARCHIVE_URL, archive(remote), { etag: eTag("revision-2") }); + + 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([]); + }); + + 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(fileUrl("Selected/One.md"), { + path: "Selected/One.md", + nodeKey: "node-1", + assetType: "MARKDOWN_FILE", + eTag: eTag("one changed"), + }); + mockAxiosGet(ARCHIVE_URL, archive([{ ...original[0], content: "one changed" }, original[1], original[2]]), { + etag: eTag("revision-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("expands a 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(fileUrl(file.path), { + path: file.path, + nodeKey: file.nodeKey, + assetType: "MARKDOWN_FILE", + eTag: eTag(`${file.nodeKey}-changed`), + }); + }); + mockAxiosGet( + ARCHIVE_URL, + archive( + original.map((file, index) => (index < 2 ? { ...file, content: `${file.content} changed` } : file)) + ), + { etag: eTag("revision-2") } + ); + + await new WorkspaceService(testContext).push(["Selected"]); + + expect(mockedAxiosInstance.put).toHaveBeenCalledTimes(2); + expect(new WorkspaceService(testContext).status()).toEqual([ + { path: "Unselected/Three.md", status: "modified" }, + ]); + }); + + 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(fileUrl("Guides/Guide.md"), 412, { message: "stale" }); + + await expect(new WorkspaceService(testContext).push()).rejects.toThrow("Workspace push failed for 1 file(s)"); + + expect(new WorkspaceService(testContext).status()).toEqual([{ path: "Guides/Guide.md", status: "modified" }]); + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman/local/state.json"), "utf-8")) + ).toMatchObject({ baselineDigests: { "node-1": digest("original") } }); + }); + + 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(fileUrl("Guides/One.md"), { + path: "Guides/One.md", + nodeKey: "node-1", + assetType: "MARKDOWN_FILE", + eTag: eTag("one changed"), + }); + mockAxiosPutError(fileUrl("Guides/Two.md"), 412, { message: "stale" }); + mockAxiosGet(ARCHIVE_URL, archive([{ ...original[0], content: "one changed" }, original[1]]), { + etag: eTag("revision-2"), + }); + + await expect(new WorkspaceService(testContext).push()).rejects.toThrow("Workspace push failed for 1 file(s)"); + + expect(new WorkspaceService(testContext).status()).toEqual([{ path: "Guides/Two.md", status: "modified" }]); + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman/local/state.json"), "utf-8")) + ).toMatchObject({ + serverRevision: eTag("revision-2"), + 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")); + mockAxiosGet(ARCHIVE_URL, archive([]), { etag: eTag("revision-2") }); + + 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(fileUrl("Pages/Guide.md"), { + path: "Pages/Guide.md", + nodeKey: "node-1", + assetType: "MARKDOWN_FILE", + eTag: eTag("file-3"), + }); + mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "changed" }]), { + etag: eTag("revision-2"), + }); + + 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( + fileUrl("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("rejects paths with a full push and overwrite without a full push", async () => { + writeWorkspace(); + + await expect(new WorkspaceService(testContext).push(["Guides"], { full: true })).rejects.toThrow( + "Workspace paths cannot be combined with --full" + ); + await expect(new WorkspaceService(testContext).push([], { overwrite: true })).rejects.toThrow( + "--overwrite requires --full" + ); + }); + it("pushes resolved changes and refreshes disposable metadata", async () => { writeWorkspace(); fs.mkdirSync(path.join(process.cwd(), "Pages")); @@ -605,14 +832,12 @@ describe("Workspace service", () => { originalRename(source, target); }); mockAxiosPost(PUSH_URL, {}); - mockAxiosGet( - ARCHIVE_URL, - archive([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "changed" }]), - { etag: eTag("revision-2") } - ); + mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "changed" }]), { + etag: eTag("revision-2"), + }); try { - await service.push(undefined, true); + await service.push([], { full: true, overwrite: true }); } finally { rename.mockRestore(); } @@ -631,7 +856,9 @@ describe("Workspace service", () => { const form = (mockedAxiosInstance.post as jest.Mock).mock.calls[0][1] as { _streams: unknown[] }; expect(form._streams).toContain(JSON.stringify({ moves: { "node-1": "Pages/Guide.md" } })); expect(service.status()).toEqual([]); - expect(JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8"))).toEqual({ + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8")) + ).toEqual({ schemaVersion: 1, serverRevision: eTag("revision-2"), baselineDigests: { "node-1": digest("changed") }, @@ -642,25 +869,22 @@ describe("Workspace service", () => { it("replaces the workspace on pull when post-push refresh fails", 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.renameSync(path.join(process.cwd(), "Guides", "Guide.md"), path.join(process.cwd(), "Pages", "Guide.md")); mockAxiosPost(PUSH_URL, {}); mockAxiosGetError(ARCHIVE_URL, 503, { message: "unavailable" }); const service = new WorkspaceService(testContext); - await expect(service.push()).rejects.toThrow("Push succeeded, but local state refresh failed"); + await expect(service.push([], { full: true })).rejects.toThrow( + "Push succeeded, but local state refresh failed" + ); expect( JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8")) ).toMatchObject({ refreshRequired: true }); expect(() => service.status()).toThrow("Workspace synchronization state needs refresh"); - mockAxiosGet( - ARCHIVE_URL, - archive([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "original" }]), - { etag: eTag("revision-2") } - ); + mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "original" }]), { + etag: eTag("revision-2"), + }); await service.pull(); expect(service.status()).toEqual([]); expect(fs.existsSync(path.join(process.cwd(), "Guides", "Guide.md"))).toBe(false); @@ -671,11 +895,9 @@ describe("Workspace service", () => { writeWorkspace(); fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "changed"); mockAxiosPost(PUSH_URL, {}); - mockAxiosGet( - ARCHIVE_URL, - archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "changed" }]), - { etag: eTag("revision-2") } - ); + mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "changed" }]), { + etag: eTag("revision-2"), + }); const originalRename = fs.renameSync; const rename = jest.spyOn(fs, "renameSync").mockImplementation((source, target) => { if (target.toString() === path.join(process.cwd(), ".pacman")) { @@ -686,10 +908,12 @@ describe("Workspace service", () => { let backup: string | undefined; try { - await new WorkspaceService(testContext).push(); + await new WorkspaceService(testContext).push([], { full: true }); } catch (error) { expect(error).toBeInstanceOf(Error); - const match = (error as Error).message.match(/backup remains at (.+?)\. Run workspace pull before retrying\.$/); + const match = (error as Error).message.match( + /backup remains at (.+?)\. Run workspace pull before retrying\.$/ + ); backup = match?.[1]; } finally { rename.mockRestore(); @@ -708,7 +932,7 @@ describe("Workspace service", () => { 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 has unresolved file identities"); + await expect(new WorkspaceService(testContext).push()).rejects.toThrow("Workspace push failed for 1 file(s)"); expect(mockedAxiosInstance.post).not.toHaveBeenCalled(); }); diff --git a/tests/utls/http-requests-mock.ts b/tests/utls/http-requests-mock.ts index 7baa751e..dbe8a139 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; @@ -15,13 +15,17 @@ 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) => { @@ -35,7 +39,7 @@ 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, @@ -51,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) => { @@ -65,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) => { @@ -79,9 +83,31 @@ const mockAxios = () : void => { return Promise.resolve(response); } - fail("API call not mocked.") + fail("API call not mocked."); }); -} + + (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); @@ -123,14 +149,22 @@ 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(); @@ -142,7 +176,10 @@ afterEach(() => { mockedPostRequestBodyByUrl.clear(); mockedPutErrorByUrl.clear(); mockedDeleteResponseByUrl.clear(); -}) + mockedDeleteErrorByUrl.clear(); + mockedPatchResponseByUrl.clear(); + mockedPatchErrorByUrl.clear(); +}); export { mockedAxiosInstance, @@ -155,5 +192,8 @@ export { mockAxiosPut, mockAxiosPutError, mockAxiosDelete, - mockedPostRequestBodyByUrl + mockAxiosDeleteError, + mockAxiosPatch, + mockAxiosPatchError, + mockedPostRequestBodyByUrl, }; From 2d29132c086e1a31244ce0119f9fa68fb809d4ff Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Thu, 20 Aug 2026 11:10:33 +0200 Subject: [PATCH 23/46] Add branch-aware package workspaces Includes-AI-Code: true --- src/commands/workspace/module.ts | 34 +- src/commands/workspace/workspace-api.ts | 9 +- .../workspace/workspace-git.service.ts | 108 +++++ .../workspace/workspace-push.service.ts | 18 +- src/commands/workspace/workspace.models.ts | 32 +- src/commands/workspace/workspace.service.ts | 413 ++++++++++++++++-- tests/commands/workspace/module.spec.ts | 26 +- .../workspace/workspace-git.service.spec.ts | 63 +++ .../workspace/workspace.service.spec.ts | 252 ++++++++++- 9 files changed, 880 insertions(+), 75 deletions(-) create mode 100644 src/commands/workspace/workspace-git.service.ts create mode 100644 tests/commands/workspace/workspace-git.service.spec.ts diff --git a/src/commands/workspace/module.ts b/src/commands/workspace/module.ts index a84c77fe..88b39df2 100644 --- a/src/commands/workspace/module.ts +++ b/src/commands/workspace/module.ts @@ -7,7 +7,21 @@ 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.").action(this.clone); + 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 [directory]").beta().description("Pull remote changes.").action(this.pull); @@ -29,8 +43,20 @@ class Module extends IModule { .action(this.move); } - private async clone(context: Context, command: Command): Promise { - await new WorkspaceService(context).clone(command.args[0], command.args[1]); + private async clone(context: Context, command: Command, options: OptionValues): Promise { + await new WorkspaceService(context).clone(command.args[0], command.args[1], { branch: options.branch }); + } + + private async checkout(context: Context, command: Command, options: OptionValues): Promise { + 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 { @@ -38,7 +64,7 @@ class Module extends IModule { } private async status(context: Context, command: Command): Promise { - new WorkspaceService(context).status(command.args[0]); + await new WorkspaceService(context).statusWithGit(command.args[0]); } private async push(context: Context, command: Command, options: OptionValues): Promise { diff --git a/src/commands/workspace/workspace-api.ts b/src/commands/workspace/workspace-api.ts index 0745d103..6480272e 100644 --- a/src/commands/workspace/workspace-api.ts +++ b/src/commands/workspace/workspace-api.ts @@ -1,7 +1,7 @@ import * as FormData from "form-data"; import { Context } from "../../core/command/cli-context"; import { GracefulError } from "../../core/utils/logger"; -import { NodeFileWriteResponse } from "./workspace.models"; +import { NodeFileWriteResponse, WorkspaceBranch } from "./workspace.models"; export class WorkspaceApi { constructor(private readonly context: Context) {} @@ -68,6 +68,13 @@ export class WorkspaceApi { 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 { const encodedPath = filePath .split("/") diff --git a/src/commands/workspace/workspace-git.service.ts b/src/commands/workspace/workspace-git.service.ts new file mode 100644 index 00000000..df961722 --- /dev/null +++ b/src/commands/workspace/workspace-git.service.ts @@ -0,0 +1,108 @@ +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import * as path from "node:path"; +import { GracefulError } from "../../core/utils/logger"; +import { WorkspaceGitObservation } from "./workspace.models"; + +interface GitContext extends WorkspaceGitObservation { + root: string; +} + +export class WorkspaceGitService { + public observe(workspaceRoot: string): WorkspaceGitObservation | undefined { + const context = this.context(workspaceRoot); + return context ? { branch: context.branch, head: context.head } : undefined; + } + + public mappedPacmanBranch(workspaceRoot: string, projectKey: string, gitBranch: string): string | undefined { + const context = this.context(workspaceRoot); + if (!context) { + return undefined; + } + return this.mappings(context.root, workspaceRoot, projectKey)[gitBranch]; + } + + public link( + workspaceRoot: string, + projectKey: string, + gitBranch: string, + pacmanBranch: string + ): WorkspaceGitObservation { + const context = 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 = this.mappings(context.root, workspaceRoot, projectKey); + mappings[gitBranch] = pacmanBranch; + this.run(context.root, [ + "config", + "--local", + this.mappingKey(context.root, workspaceRoot, projectKey), + JSON.stringify(mappings), + ]); + return { branch: context.branch, head: context.head }; + } + + private context(workspaceRoot: string): GitContext | undefined { + const gitRoot = this.tryRun(workspaceRoot, ["rev-parse", "--show-toplevel"]); + if (!gitRoot) { + return undefined; + } + const head = this.tryRun(gitRoot, ["rev-parse", "HEAD"]); + if (!head) { + return undefined; + } + const branch = this.tryRun(gitRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"]); + return { root: path.resolve(gitRoot), branch: branch || "", head }; + } + + private mappings(gitRoot: string, workspaceRoot: string, projectKey: string): Record { + const raw = 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(); + } + 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 tryRun(root: string, args: string[]): string | undefined { + try { + return this.run(root, args); + } catch { + return undefined; + } + } + + private run(root: string, args: string[]): string { + return execFileSync("git", ["-C", root, ...args], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } +} diff --git a/src/commands/workspace/workspace-push.service.ts b/src/commands/workspace/workspace-push.service.ts index 78ddccf5..a35ebfc7 100644 --- a/src/commands/workspace/workspace-push.service.ts +++ b/src/commands/workspace/workspace-push.service.ts @@ -57,13 +57,7 @@ export class WorkspacePushService { return candidates .filter(candidate => selections.some(selection => - candidate.paths.some(candidatePath => - selection.directory - ? !selection.path || - candidatePath === selection.path || - candidatePath.startsWith(`${selection.path}/`) - : candidatePath === selection.path - ) + candidate.paths.some(candidatePath => this.matches(selection, candidatePath)) ) ) .map(candidate => candidate.change); @@ -89,10 +83,18 @@ export class WorkspacePushService { } const directory = exists ? fs.lstatSync(absolute).isDirectory() - : candidatePaths.some(candidate => candidate.startsWith(`${relative}/`)); + : candidatePaths.some(candidate => candidate.toLowerCase().startsWith(`${relative.toLowerCase()}/`)); return { path: relative, directory }; } + private 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; + } + private async pushChange( root: string, snapshot: WorkspaceSnapshot, diff --git a/src/commands/workspace/workspace.models.ts b/src/commands/workspace/workspace.models.ts index 2bc9f08d..39d2c778 100644 --- a/src/commands/workspace/workspace.models.ts +++ b/src/commands/workspace/workspace.models.ts @@ -1,13 +1,42 @@ export interface WorkspaceState { schemaVersion: number; + activePackageKey: string; + activeBranch: string; serverRevision: string; baselineDigests: Record; - moveHints: 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; } @@ -39,6 +68,7 @@ export interface ClassifiedWorkspaceChange extends WorkspaceChange { } export interface WorkspaceSnapshot { + projectKey: string; packageKey: string; state: WorkspaceState; expectedFiles: ExpectedWorkspaceFile[]; diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index 0d4d24f8..d3a52c11 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -6,15 +6,22 @@ import AdmZip = require("adm-zip"); import { Context } from "../../core/command/cli-context"; import { fileService } from "../../core/utils/file-service"; 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 { WorkspacePushService } from "./workspace-push.service"; import { ExpectedWorkspaceFile, WorkspaceChange, + WorkspaceCheckoutOptions, + WorkspaceCloneOptions, + WorkspaceGitObservation, + WorkspaceMoveHint, WorkspaceNodeMetadata, WorkspacePackageIdentity, WorkspacePushOptions, + WorkspacePushOutcome, WorkspaceSnapshot, WorkspaceState, } from "./workspace.models"; @@ -51,16 +58,21 @@ export { WorkspaceChange } from "./workspace.models"; export class WorkspaceService { private readonly api: WorkspaceApi; - constructor(context: Context) { + constructor( + context: Context, + private readonly gitService: WorkspaceGitService = new WorkspaceGitService() + ) { this.api = new WorkspaceApi(context); } - public async clone(packageKey: string, directory?: string): Promise { - const target = path.resolve(process.cwd(), directory || packageKey); + 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 temporary = this.validatedArchive(await this.api.download(packageKey), packageKey); + const temporary = this.validatedArchive(await this.api.download(packageKey), packageKey, projectKey); const parent = path.dirname(target); let staging: string | undefined; try { @@ -83,29 +95,97 @@ export class WorkspaceService { 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; + let packageKey = this.packageKey(projectKey, branch); + if (options.create) { + 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."); + } + packageKey = created.packageKey; + } + const download = await this.api.download(packageKey); + const temporary = this.validatedArchive(download, packageKey, projectKey); + try { + if (options.create || options.linkGit) { + const observation = options.linkGit ? this.linkCurrentGitBranch(root, projectKey, branch) : undefined; + this.reconcileLocalState(root, temporary, packageKey, branch, observation); + } else { + if (!options.discard && (!current || this.snapshot(root).changes.length !== 0)) { + throw new GracefulError("Workspace has local changes. Use --discard or push them before checkout."); + } + this.replaceWorkspaceContents(root, temporary); + } + } finally { + fs.rmSync(temporary, { recursive: true, force: true }); + } + logger.info(`${options.create ? "Created and selected" : "Selected"} ${packageKey}.`); + } + public async pull(directory?: string): Promise { const root = this.root(directory); - const packageKey = this.packageIdentity(root).packageKey; + 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; + let restoredObservation: WorkspaceGitObservation | undefined; + let packageKey = localState?.activePackageKey || projectKey; + if (!localState) { + restoredObservation = this.gitService.observe(root); + if (restoredObservation) { + if (!restoredObservation.branch) { + throw new GracefulError("Detached Git HEAD cannot select a Pacman workspace branch."); + } + const mappedBranch = this.gitService.mappedPacmanBranch(root, projectKey, restoredObservation.branch); + if (!mappedBranch) { + throw new GracefulError( + `Git branch '${restoredObservation.branch}' is not mapped. Use workspace checkout --link-git.` + ); + } + packageKey = this.packageKey(projectKey, this.branch(mappedBranch)); + } + } if (localState && !localState.refreshRequired && this.snapshot(root).changes.length !== 0) { throw new GracefulError("Workspace has local changes. Push or discard them before pull."); } const download = await this.api.download(packageKey); if (localState?.refreshRequired) { const moveHints = localState.moveHints; - this.refreshMetadata(root, download, packageKey); + this.refreshMetadata(root, download, packageKey, projectKey, localState.git); const refreshed = this.state(root); - this.writeState(root, { ...refreshed, moveHints: { ...refreshed.moveHints, ...moveHints } }); + this.writeState(root, { ...refreshed, moveHints: this.reconciledMoveHints(root, moveHints) }); logger.info(`Pulled ${packageKey}.`); return; } - const temporary = this.validatedArchive(download, packageKey); + const temporary = this.validatedArchive(download, packageKey, projectKey); try { if (localState) { this.replaceWorkspaceContents(root, temporary); } else { - this.reconcileLocalState(root, temporary); + this.reconcileLocalState( + root, + temporary, + packageKey, + this.branchFromPackageKey(projectKey, packageKey), + restoredObservation + ); } } finally { fs.rmSync(temporary, { recursive: true, force: true }); @@ -126,6 +206,12 @@ export class WorkspaceService { 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 { if (options.full) { if (paths.length > 0) { @@ -138,8 +224,16 @@ export class WorkspaceService { throw new GracefulError("--overwrite requires --full."); } const root = this.root(); + await this.synchronizeGitTarget(root, true); const snapshot = this.snapshot(root); - const outcomes = await new WorkspacePushService(this.api).push(root, snapshot, paths); + this.writeState(root, { ...snapshot.state, refreshRequired: true }); + let outcomes: WorkspacePushOutcome[]; + try { + outcomes = await new WorkspacePushService(this.api).push(root, snapshot, paths); + } catch (error) { + this.writeState(root, snapshot.state); + throw error; + } outcomes.forEach(outcome => logger.info( `${outcome.success ? "succeeded" : "failed"}: ${outcome.status} ${outcome.path}` + @@ -149,6 +243,7 @@ export class WorkspaceService { const succeeded = outcomes.filter(outcome => outcome.success); const failed = outcomes.filter(outcome => !outcome.success); if (succeeded.length === 0) { + this.writeState(root, snapshot.state); if (failed.length > 0) { throw new GracefulError(`Workspace push failed for ${failed.length} file(s).`); } @@ -160,21 +255,27 @@ export class WorkspaceService { .filter( outcome => outcome.nodeKey && (outcome.status === "moved" || outcome.status === "moved, modified") ) - .map(outcome => [outcome.nodeKey!, outcome.path]) + .map(outcome => [outcome.nodeKey!, snapshot.state.moveHints[outcome.nodeKey!] || outcome.path]) ); + this.writeState(root, { + ...snapshot.state, + moveHints: retainedHints, + refreshRequired: true, + }); try { - this.refreshMetadata(root, await this.api.download(snapshot.packageKey), snapshot.packageKey); + this.refreshMetadata( + root, + await this.api.download(snapshot.packageKey), + snapshot.packageKey, + snapshot.projectKey, + snapshot.state.git + ); const refreshed = this.state(root); this.writeState(root, { ...refreshed, - moveHints: { ...refreshed.moveHints, ...retainedHints }, + moveHints: this.reconciledMoveHints(root, retainedHints), }); } catch (error) { - this.writeState(root, { - ...snapshot.state, - moveHints: { ...snapshot.state.moveHints, ...retainedHints }, - refreshRequired: true, - }); const failure = new GracefulError( "Workspace changes reached the server, but local synchronization state could not be refreshed. Run workspace pull before retrying." ); @@ -189,6 +290,7 @@ export class WorkspaceService { private async pushFull(overwrite: boolean): Promise { const root = this.root(); + await this.synchronizeGitTarget(root, true); const snapshot = this.snapshot(root); if (snapshot.changes.some(change => change.status === "unresolved")) { throw new GracefulError("Workspace has unresolved file identities. Record the intended moves before push."); @@ -221,7 +323,13 @@ export class WorkspaceService { this.writeState(root, { ...snapshot.state, refreshRequired: true }); try { const refreshedArchive = await this.api.download(snapshot.packageKey); - this.refreshMetadata(root, refreshedArchive, snapshot.packageKey); + this.refreshMetadata( + root, + refreshedArchive, + snapshot.packageKey, + snapshot.projectKey, + snapshot.state.git + ); } catch (error) { const detail = error instanceof GracefulError ? ` ${error.message}` : ""; const failure = new GracefulError( @@ -276,19 +384,23 @@ export class WorkspaceService { } private snapshot(root: string): WorkspaceSnapshot { - const packageKey = this.packageIdentity(root).packageKey; + const projectKey = this.packageIdentity(root).projectKey; const state = this.state(root); if (state.refreshRequired) { throw new GracefulError("Workspace synchronization state needs refresh. Run workspace pull."); } - const expectedFiles = this.expectedFiles(root, state, packageKey); + 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 visibleFiles = this.visibleFiles(root); return { - packageKey, + projectKey, + packageKey: state.activePackageKey, state, expectedFiles, visibleFiles, - changes: classifyWorkspaceChanges(expectedFiles, visibleFiles, state.moveHints), + changes: classifyWorkspaceChanges(expectedFiles, visibleFiles, this.moveHintTargets(state.moveHints)), }; } @@ -326,7 +438,12 @@ export class WorkspaceService { if (baseline && !/^sha256:[0-9a-f]{64}$/.test(baseline)) { throw new GracefulError(`Invalid baseline digest for node ${node.key}.`); } - return { nodeKey: node.key, path: resolvePath(node), digest: baseline }; + const metadataPath = resolvePath(node); + const hint = state.moveHints[node.key]; + const sourcePath = this.isStructuredMoveHint(hint) + ? this.validateRelative(hint.sourcePath) + : metadataPath; + return { nodeKey: node.key, path: sourcePath, digest: baseline }; }); const foldedPaths = new Set(); expected.forEach(file => { @@ -340,7 +457,14 @@ export class WorkspaceService { if (!byKey.has(nodeKey)) { throw new GracefulError(`Move hint references unknown node ${nodeKey}.`); } - state.moveHints[nodeKey] = this.validateRelative(targetPath); + 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)); } @@ -425,7 +549,7 @@ export class WorkspaceService { return expected; } const hinted = snapshot.expectedFiles.find( - file => snapshot.state.moveHints[file.nodeKey]?.toLowerCase() === foldedSourcePath + file => this.moveHintTarget(snapshot.state.moveHints[file.nodeKey])?.toLowerCase() === foldedSourcePath ); if (hinted) { return hinted; @@ -439,8 +563,14 @@ export class WorkspaceService { return classified ? snapshot.expectedFiles.find(file => file.nodeKey === classified.nodeKey) : undefined; } - private refreshMetadata(root: string, download: { archive: Buffer; eTag: string }, packageKey: string): void { - const extracted = this.validatedArchive(download, packageKey); + private refreshMetadata( + root: string, + download: { archive: Buffer; eTag: string }, + packageKey: string, + projectKey: string, + observation?: WorkspaceGitObservation + ): void { + const extracted = this.validatedArchive(download, packageKey, projectKey, observation); try { this.replaceMetadataDirectory(root, path.join(extracted, ".pacman")); } finally { @@ -479,7 +609,12 @@ export class WorkspaceService { } } - private validatedArchive(download: { archive: Buffer; eTag: string }, packageKey: string): string { + private validatedArchive( + download: { archive: Buffer; eTag: string }, + packageKey: string, + projectKey: string = BranchUtils.extractProjectKey(packageKey), + observation?: WorkspaceGitObservation + ): string { const zip = new AdmZip(download.archive); if (!zip.getEntry(".pacman/package.json") || !zip.getEntry(".pacman/.gitignore")) { throw new GracefulError("Archive does not contain Pacman package metadata."); @@ -494,11 +629,11 @@ export class WorkspaceService { } const temporary = fileService.extractZipBufferToTempDirectory(download.archive); try { - if (this.packageIdentity(temporary).packageKey !== packageKey) { - throw new GracefulError("Archive package key does not match the requested package."); + if (this.packageIdentity(temporary).projectKey !== projectKey) { + throw new GracefulError("Archive project key does not match the requested project."); } this.validateGitignore(temporary); - this.hydrateState(temporary, download.eTag); + this.hydrateState(temporary, download.eTag, packageKey, observation); const snapshot = this.snapshot(temporary); if (snapshot.changes.length !== 0) { throw new GracefulError("Archive content does not match its workspace baseline."); @@ -510,15 +645,23 @@ export class WorkspaceService { } } - private reconcileLocalState(root: string, remoteRoot: string): void { - const packageKey = this.packageIdentity(root).packageKey; - if (this.packageIdentity(remoteRoot).packageKey !== packageKey) { - throw new GracefulError("Remote archive package key does not match this workspace."); + private reconcileLocalState( + root: string, + remoteRoot: string, + packageKey: string, + branch: string, + observation?: WorkspaceGitObservation + ): void { + const projectKey = this.packageIdentity(root).projectKey; + if (this.packageIdentity(remoteRoot).projectKey !== projectKey) { + throw new GracefulError("Remote archive project key does not match this workspace."); } this.validateGitignore(root); const remoteState = this.state(remoteRoot); const emptyState: WorkspaceState = { schemaVersion: 1, + activePackageKey: packageKey, + activeBranch: branch, serverRevision: remoteState.serverRevision, baselineDigests: remoteState.baselineDigests, moveHints: {}, @@ -534,25 +677,44 @@ export class WorkspaceService { throw new GracefulError(`Node metadata type conflicts with the server for ${node.key}.`); } }); - const moveHints = Object.fromEntries( + const moveHints: Record = Object.fromEntries( localFiles .filter(file => remoteFiles.has(file.nodeKey) && remoteFiles.get(file.nodeKey)!.path !== file.path) - .map(file => [file.nodeKey, file.path]) + .map(file => [file.nodeKey, { sourcePath: remoteFiles.get(file.nodeKey)!.path, targetPath: file.path }]) ); - const reconciledState = { ...remoteState, moveHints }; + const reconciledState: WorkspaceState = { + ...remoteState, + activePackageKey: packageKey, + activeBranch: branch, + moveHints, + }; + if (observation) { + reconciledState.git = observation; + } else { + delete reconciledState.git; + } const expectedFiles = this.expectedFiles(root, reconciledState, packageKey); - classifyWorkspaceChanges(expectedFiles, this.visibleFiles(root), moveHints); - this.replaceMetadataDirectory(root, path.join(remoteRoot, ".pacman")); + classifyWorkspaceChanges(expectedFiles, this.visibleFiles(root), this.moveHintTargets(moveHints)); this.writeState(root, reconciledState); } - private hydrateState(root: string, eTag: string): WorkspaceState { + private hydrateState( + root: string, + eTag: string, + packageKey: string, + observation?: WorkspaceGitObservation + ): WorkspaceState { if (!/^"sha256:[0-9a-f]{64}"$/.test(eTag)) { throw new GracefulError("Filesystem archive response contains an invalid ETag."); } - const packageKey = this.packageIdentity(root).packageKey; + const projectKey = this.packageIdentity(root).projectKey; + if (BranchUtils.extractProjectKey(packageKey) !== projectKey) { + throw new GracefulError("Active Pacman package does not belong to the archive project."); + } const emptyState: WorkspaceState = { schemaVersion: 1, + activePackageKey: packageKey, + activeBranch: this.branchFromPackageKey(projectKey, packageKey), serverRevision: eTag, baselineDigests: {}, moveHints: {}, @@ -566,7 +728,10 @@ export class WorkspaceService { return [file.nodeKey, this.digest(absolute)]; }) ); - const state = { ...emptyState, baselineDigests }; + const state: WorkspaceState = { ...emptyState, baselineDigests }; + if (observation) { + state.git = observation; + } this.writeState(root, state); return state; } @@ -655,6 +820,8 @@ export class WorkspaceService { const parsed = JSON.parse(fs.readFileSync(this.statePath(root), "utf-8")) as Partial; if ( parsed.schemaVersion !== 1 || + !parsed.activePackageKey || + !parsed.activeBranch || !parsed.serverRevision || !/^"sha256:[0-9a-f]{64}"$/.test(parsed.serverRevision) || !parsed.baselineDigests || @@ -667,13 +834,23 @@ export class WorkspaceService { (typeof parsed.moveHints !== "object" || parsed.moveHints === null || Array.isArray(parsed.moveHints) || - !Object.values(parsed.moveHints).every(value => typeof value === "string"))) || + !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, serverRevision: parsed.serverRevision, baselineDigests: parsed.baselineDigests, moveHints: parsed.moveHints || {}, @@ -681,6 +858,9 @@ export class WorkspaceService { if (parsed.refreshRequired) { state.refreshRequired = true; } + if (parsed.git) { + state.git = parsed.git; + } return state; } @@ -715,10 +895,149 @@ export class WorkspaceService { 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 reconciledMoveHints( + root: string, + hints: Record + ): Record { + const refreshed = this.state(root); + const remotePathByNodeKey = new Map( + this.expectedFiles(root, refreshed, refreshed.activePackageKey).map(file => [file.nodeKey, file.path]) + ); + return Object.fromEntries( + Object.entries(hints).flatMap(([nodeKey, hint]) => { + const remotePath = remotePathByNodeKey.get(nodeKey); + const targetPath = this.moveHintTarget(hint); + if (!remotePath || !targetPath || remotePath.toLowerCase() === targetPath.toLowerCase()) { + return []; + } + return [ + [ + nodeKey, + this.isStructuredMoveHint(hint) + ? { sourcePath: remotePath, targetPath: hint.targetPath } + : hint, + ], + ]; + }) + ); + } + private statePath(root: string): string { return path.join(root, ".pacman", "local", "state.json"); } + private async synchronizeGitTarget(root: string, requirePushSafe: boolean): Promise { + const state = this.state(root); + const observation = this.gitService.observe(root); + if (!state.git) { + 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; + } + if (!observation) { + if (requirePushSafe) { + throw new GracefulError("The linked Git worktree is unavailable; workspace push is blocked."); + } + logger.warn("The linked Git worktree is unavailable; using the last Pacman baseline."); + return false; + } + if (observation.branch === state.git.branch && observation.head === state.git.head) { + return false; + } + if (!observation.branch) { + if (requirePushSafe) { + throw new GracefulError("Detached Git HEAD cannot push a Pacman workspace."); + } + logger.warn("Git HEAD is detached; using the last Pacman baseline."); + return false; + } + const projectKey = this.packageIdentity(root).projectKey; + const mappedBranch = this.gitService.mappedPacmanBranch(root, projectKey, observation.branch); + if (!mappedBranch) { + if (requirePushSafe) { + throw new GracefulError( + `Git branch '${observation.branch}' is not mapped to a Pacman branch. Use workspace checkout --link-git.` + ); + } + logger.warn(`Git branch '${observation.branch}' is not mapped; using the last Pacman baseline.`); + return false; + } + const branch = this.branch(mappedBranch); + const packageKey = this.packageKey(projectKey, branch); + const temporary = this.validatedArchive( + await this.api.download(packageKey), + packageKey, + projectKey, + observation + ); + try { + this.reconcileLocalState(root, temporary, packageKey, branch, observation); + } finally { + fs.rmSync(temporary, { recursive: true, force: true }); + } + logger.info(`Reconciled Git branch '${observation.branch}' with ${packageKey}.`); + return true; + } + + private linkCurrentGitBranch(root: string, projectKey: string, pacmanBranch: string): WorkspaceGitObservation { + const observation = 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 }); @@ -728,10 +1047,10 @@ export class WorkspaceService { const parsed = JSON.parse( fs.readFileSync(this.packageIdentityPath(root), "utf-8") ) as Partial; - if (parsed.schemaVersion !== 1 || !parsed.packageKey || Object.keys(parsed).length !== 2) { + if (parsed.schemaVersion !== 1 || !parsed.projectKey || Object.keys(parsed).length !== 2) { throw new GracefulError("Unsupported Pacman package metadata."); } - return { schemaVersion: parsed.schemaVersion, packageKey: parsed.packageKey }; + return { schemaVersion: parsed.schemaVersion, projectKey: parsed.projectKey }; } private packageIdentityPath(root: string): string { diff --git a/tests/commands/workspace/module.spec.ts b/tests/commands/workspace/module.spec.ts index c945d486..5e27bffb 100644 --- a/tests/commands/workspace/module.spec.ts +++ b/tests/commands/workspace/module.spec.ts @@ -12,19 +12,21 @@ describe("Workspace module", () => { new Module().register(testContext, configurator); expect(configurator.command).toHaveBeenCalledWith("workspace"); - expect(configurator.command).toHaveBeenCalledWith("clone [directory]"); + expect(configurator.command).toHaveBeenCalledWith("clone [directory]"); + expect(configurator.command).toHaveBeenCalledWith("checkout [branch]"); expect(configurator.command).toHaveBeenCalledWith("pull [directory]"); expect(configurator.command).toHaveBeenCalledWith("status [directory]"); expect(configurator.command).toHaveBeenCalledWith("push [paths...]"); expect(configurator.command).toHaveBeenCalledWith("move "); - expect(configurator.beta).toHaveBeenCalledTimes(6); - expect(configurator.action).toHaveBeenCalledTimes(5); + 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, "status").mockReturnValue([]); + const status = jest.spyOn(WorkspaceService.prototype, "statusWithGit").mockResolvedValue([]); const push = jest.spyOn(WorkspaceService.prototype, "push").mockResolvedValue(); const move = jest.spyOn(WorkspaceService.prototype, "move").mockReturnValue(); @@ -34,13 +36,25 @@ describe("Workspace module", () => { await program.parseAsync(["node", "content-cli", ...args]); }; - await execute("workspace", "clone", "package-key", "target"); + 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"); await execute("workspace", "status", "target"); await execute("workspace", "push", "target", "other.md"); await execute("workspace", "move", "old.md", "new.md", "--record"); - expect(clone).toHaveBeenCalledWith("package-key", "target"); + 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"); expect(status).toHaveBeenCalledWith("target"); expect(push).toHaveBeenCalledWith(["target", "other.md"], { full: false, overwrite: false }); 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..709344af --- /dev/null +++ b/tests/commands/workspace/workspace-git.service.spec.ts @@ -0,0 +1,63 @@ +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", () => { + const observation = new WorkspaceGitService().observe(workspace); + + expect(observation).toEqual({ branch: "main", head: git("rev-parse", "HEAD") }); + }); + + it("stores explicit mappings scoped to the workspace and project", () => { + const service = new WorkspaceGitService(); + + expect(service.link(workspace, "project-a", "main", "feature-a")).toEqual({ + branch: "main", + head: git("rev-parse", "HEAD"), + }); + expect(service.mappedPacmanBranch(workspace, "project-a", "main")).toBe("feature-a"); + expect(service.mappedPacmanBranch(workspace, "project-b", "main")).toBeUndefined(); + expect(service.mappedPacmanBranch(repository, "project-a", "main")).toBeUndefined(); + }); + + it("reports detached HEAD without changing Git state", () => { + const head = git("rev-parse", "HEAD"); + git("checkout", "--detach", head); + + expect(new WorkspaceGitService().observe(workspace)).toEqual({ branch: "", head }); + }); + + it("returns no observation outside a Git worktree", () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "content-cli-workspace-no-git-")); + try { + expect(new WorkspaceGitService().observe(directory)).toBeUndefined(); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + function git(...args: string[]): string { + return execFileSync("git", ["-C", repository, ...args], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } +}); diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index 33562c93..545c7064 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import AdmZip = require("adm-zip"); import { WorkspaceService } from "../../../src/commands/workspace/workspace.service"; +import { WorkspaceGitService } from "../../../src/commands/workspace/workspace-git.service"; import { fileService } from "../../../src/core/utils/file-service"; import { testContext } from "../../utls/test-context"; import { @@ -17,9 +18,15 @@ import { } from "../../utls/http-requests-mock"; const PACKAGE_KEY = "pkg-1"; +const BRANCH = "feature-a"; +const BRANCH_PACKAGE_KEY = `${PACKAGE_KEY}@${BRANCH}`; const ARCHIVE_URL = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${PACKAGE_KEY}/file-archive`; const PUSH_URL = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${PACKAGE_KEY}/file-archive`; +function archiveUrl(packageKey: string): string { + return `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${encodeURIComponent(packageKey)}/file-archive`; +} + function fileUrl(filePath: string): string { return `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${PACKAGE_KEY}/files/${filePath}`; } @@ -78,6 +85,8 @@ function state( ): object { return { schemaVersion: 1, + activePackageKey: PACKAGE_KEY, + activeBranch: "main", serverRevision, baselineDigests: Object.fromEntries(files.map(file => [file.nodeKey, digest(file.content)])), moveHints, @@ -87,7 +96,7 @@ function state( function archive(files: TestFile[]): Buffer { const zip = new AdmZip(); zip.addFile(".pacman/.gitignore", Buffer.from("local/\n")); - zip.addFile(".pacman/package.json", Buffer.from(JSON.stringify({ schemaVersion: 1, packageKey: PACKAGE_KEY }))); + zip.addFile(".pacman/package.json", Buffer.from(JSON.stringify({ schemaVersion: 1, projectKey: PACKAGE_KEY }))); zip.addFile(".pacman/nodes/", Buffer.alloc(0)); Object.entries(metadata(files)).forEach(([nodeKey, node]) => { zip.addFile(`.pacman/nodes/${nodeKey}.json`, Buffer.from(JSON.stringify(node))); @@ -104,7 +113,7 @@ function writeWorkspace( fs.writeFileSync(path.join(process.cwd(), ".pacman", ".gitignore"), "local/\n"); fs.writeFileSync( path.join(process.cwd(), ".pacman", "package.json"), - JSON.stringify({ schemaVersion: 1, packageKey: PACKAGE_KEY }) + JSON.stringify({ schemaVersion: 1, projectKey: PACKAGE_KEY }) ); fs.writeFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), JSON.stringify(state(files))); Object.entries(metadata(files)).forEach(([nodeKey, node]) => { @@ -117,9 +126,30 @@ function writeWorkspace( } function removeWorkspace(): void { - [".git", ".pacman", "Guides", "Pages", "Other", "New", "Bulk", "Selected", "Unselected", PACKAGE_KEY].forEach( - entry => fs.rmSync(path.join(process.cwd(), entry), { recursive: true, force: true }) - ); + [ + ".git", + ".pacman", + "Guides", + "Pages", + "Other", + "New", + "Bulk", + "Selected", + "Unselected", + PACKAGE_KEY, + "branch-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().mockReturnValue(observation), + mappedPacmanBranch: jest.fn().mockReturnValue(mappedBranch), + link: jest.fn().mockReturnValue(observation), + } as unknown as jest.Mocked; } describe("Workspace service", () => { @@ -147,6 +177,8 @@ describe("Workspace service", () => { ) ).toEqual({ schemaVersion: 1, + activePackageKey: PACKAGE_KEY, + activeBranch: "main", serverRevision: eTag("revision-1"), baselineDigests: { "node-1": digest("original") }, moveHints: {}, @@ -167,6 +199,150 @@ describe("Workspace service", () => { ); }); + it("clones a selected branch while keeping stable project identity", async () => { + mockAxiosGet( + archiveUrl(BRANCH_PACKAGE_KEY), + archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "branch" }]), + { etag: eTag("branch-revision") } + ); + + 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, ".pacman", "package.json"), "utf-8"))).toEqual({ + schemaVersion: 1, + projectKey: PACKAGE_KEY, + }); + expect(JSON.parse(fs.readFileSync(path.join(root, ".pacman", "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(); + mockAxiosGet( + archiveUrl(BRANCH_PACKAGE_KEY), + archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "branch" }]), + { etag: eTag("branch-revision") } + ); + + 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(), ".pacman", "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, + }); + mockAxiosGet( + archiveUrl(BRANCH_PACKAGE_KEY), + archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]), + { etag: eTag("branch-revision") } + ); + 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); + mockAxiosGet( + archiveUrl(BRANCH_PACKAGE_KEY), + archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }]), + { etag: eTag("branch-revision") } + ); + 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(), ".pacman", "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); + mockAxiosGet( + archiveUrl(BRANCH_PACKAGE_KEY), + archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]), + { etag: eTag("branch-revision") } + ); + 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("blocks pushes from detached or unmapped switched Git branches", async () => { + writeWorkspace(); + const statePath = path.join(process.cwd(), ".pacman", "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 the latest archive into a clean existing workspace", async () => { writeWorkspace(); fs.mkdirSync(path.join(process.cwd(), ".git")); @@ -211,6 +387,31 @@ describe("Workspace service", () => { }); }); + it("restores the mapped Pacman branch after an external Git restore", async () => { + writeWorkspace(); + fs.rmSync(path.join(process.cwd(), ".pacman", "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); + mockAxiosGet( + archiveUrl(BRANCH_PACKAGE_KEY), + archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "branch baseline" }]), + { etag: eTag("branch-revision") } + ); + + 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(), ".pacman", "local", "state.json"), "utf-8")) + ).toMatchObject({ + activePackageKey: BRANCH_PACKAGE_KEY, + activeBranch: BRANCH, + serverRevision: eTag("branch-revision"), + git: observation, + }); + }); + it("hydrates a Git-restored workspace with CRLF metadata ignore rules", async () => { writeWorkspace(); fs.rmSync(path.join(process.cwd(), ".pacman", "local"), { recursive: true }); @@ -239,7 +440,9 @@ describe("Workspace service", () => { expect( JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8")) ).toMatchObject({ - moveHints: { "node-1": "Pages/Guide.md" }, + moveHints: { + "node-1": { sourcePath: "Guides/Guide.md", targetPath: "Pages/Guide.md" }, + }, }); mockAxiosGet(fileUrl("Guides/Guide.md"), Buffer.from("original"), { etag: eTag("file-1") }); @@ -658,7 +861,7 @@ describe("Workspace service", () => { ]); }); - it("expands a selected directory to its changed descendants", async () => { + 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" }, @@ -683,7 +886,7 @@ describe("Workspace service", () => { { etag: eTag("revision-2") } ); - await new WorkspaceService(testContext).push(["Selected"]); + await new WorkspaceService(testContext).push(["selected"]); expect(mockedAxiosInstance.put).toHaveBeenCalledTimes(2); expect(new WorkspaceService(testContext).status()).toEqual([ @@ -860,6 +1063,8 @@ describe("Workspace service", () => { JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8")) ).toEqual({ schemaVersion: 1, + activePackageKey: PACKAGE_KEY, + activeBranch: "main", serverRevision: eTag("revision-2"), baselineDigests: { "node-1": digest("changed") }, moveHints: {}, @@ -891,6 +1096,37 @@ describe("Workspace service", () => { expect(fs.readFileSync(path.join(process.cwd(), "Pages", "Guide.md"), "utf-8")).toBe("original"); }); + 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(ARCHIVE_URL, 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(), ".pacman", "local", "state.json"), "utf-8")) + ).toMatchObject({ refreshRequired: true, moveHints: {} }); + mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "original" }]), { + etag: eTag("revision-2"), + }); + await service.pull(); + + expect(service.status()).toEqual([]); + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8")) + ).toMatchObject({ moveHints: {} }); + }); + it("preserves the metadata backup when refresh and rollback both fail", async () => { writeWorkspace(); fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "changed"); From df81624dc9c037c9fd5fdc572a7e7b22a73d5ca9 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Thu, 20 Aug 2026 11:22:30 +0200 Subject: [PATCH 24/46] Fix workspace quality gate findings Includes-AI-Code: true --- .../workspace/workspace-git.service.ts | 55 +++--- src/commands/workspace/workspace.service.ts | 178 +++++++++++------- .../workspace/workspace-git.service.spec.ts | 61 ++++-- .../workspace/workspace.service.spec.ts | 6 +- 4 files changed, 192 insertions(+), 108 deletions(-) diff --git a/src/commands/workspace/workspace-git.service.ts b/src/commands/workspace/workspace-git.service.ts index df961722..0908daeb 100644 --- a/src/commands/workspace/workspace-git.service.ts +++ b/src/commands/workspace/workspace-git.service.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; -import { execFileSync } from "node:child_process"; import * as path from "node:path"; +import simpleGit from "simple-git"; import { GracefulError } from "../../core/utils/logger"; import { WorkspaceGitObservation } from "./workspace.models"; @@ -9,35 +9,39 @@ interface GitContext extends WorkspaceGitObservation { } export class WorkspaceGitService { - public observe(workspaceRoot: string): WorkspaceGitObservation | undefined { - const context = this.context(workspaceRoot); + public async observe(workspaceRoot: string): Promise { + const context = await this.context(workspaceRoot); return context ? { branch: context.branch, head: context.head } : undefined; } - public mappedPacmanBranch(workspaceRoot: string, projectKey: string, gitBranch: string): string | undefined { - const context = this.context(workspaceRoot); + public async mappedPacmanBranch( + workspaceRoot: string, + projectKey: string, + gitBranch: string + ): Promise { + const context = await this.context(workspaceRoot); if (!context) { return undefined; } - return this.mappings(context.root, workspaceRoot, projectKey)[gitBranch]; + return (await this.mappings(context.root, workspaceRoot, projectKey))[gitBranch]; } - public link( + public async link( workspaceRoot: string, projectKey: string, gitBranch: string, pacmanBranch: string - ): WorkspaceGitObservation { - const context = this.context(workspaceRoot); + ): 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 = this.mappings(context.root, workspaceRoot, projectKey); + const mappings = await this.mappings(context.root, workspaceRoot, projectKey); mappings[gitBranch] = pacmanBranch; - this.run(context.root, [ + await this.run(context.root, [ "config", "--local", this.mappingKey(context.root, workspaceRoot, projectKey), @@ -46,21 +50,25 @@ export class WorkspaceGitService { return { branch: context.branch, head: context.head }; } - private context(workspaceRoot: string): GitContext | undefined { - const gitRoot = this.tryRun(workspaceRoot, ["rev-parse", "--show-toplevel"]); + private async context(workspaceRoot: string): Promise { + const gitRoot = await this.tryRun(workspaceRoot, ["rev-parse", "--show-toplevel"]); if (!gitRoot) { return undefined; } - const head = this.tryRun(gitRoot, ["rev-parse", "HEAD"]); + const head = await this.tryRun(gitRoot, ["rev-parse", "HEAD"]); if (!head) { return undefined; } - const branch = this.tryRun(gitRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"]); + const branch = await this.tryRun(gitRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"]); return { root: path.resolve(gitRoot), branch: branch || "", head }; } - private mappings(gitRoot: string, workspaceRoot: string, projectKey: string): Record { - const raw = this.tryRun(gitRoot, [ + private async mappings( + gitRoot: string, + workspaceRoot: string, + projectKey: string + ): Promise> { + const raw = await this.tryRun(gitRoot, [ "config", "--local", "--get", @@ -77,7 +85,7 @@ export class WorkspaceGitService { Array.isArray(parsed) || !Object.values(parsed).every(value => typeof value === "string") ) { - throw new Error(); + throw new Error("Invalid workspace mapping value."); } return parsed as Record; } catch { @@ -91,18 +99,15 @@ export class WorkspaceGitService { return `content-cli-workspace.${id}.mappings`; } - private tryRun(root: string, args: string[]): string | undefined { + private async tryRun(root: string, args: string[]): Promise { try { - return this.run(root, args); + return await this.run(root, args); } catch { return undefined; } } - private run(root: string, args: string[]): string { - return execFileSync("git", ["-C", root, ...args], { - encoding: "utf-8", - stdio: ["ignore", "pipe", "ignore"], - }).trim(); + private async run(root: string, args: string[]): Promise { + return (await simpleGit({ baseDir: root }).raw(args)).trim(); } } diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index d3a52c11..b8d9f59a 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -101,32 +101,13 @@ export class WorkspaceService { const branch = this.branch(branchValue); const hasLocalState = fs.existsSync(this.statePath(root)); const current = hasLocalState ? this.state(root) : undefined; - let packageKey = this.packageKey(projectKey, branch); - if (options.create) { - 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."); - } - packageKey = created.packageKey; - } + const packageKey = options.create + ? await this.createWorkspaceBranch(current, projectKey, branch) + : this.packageKey(projectKey, branch); const download = await this.api.download(packageKey); const temporary = this.validatedArchive(download, packageKey, projectKey); try { - if (options.create || options.linkGit) { - const observation = options.linkGit ? this.linkCurrentGitBranch(root, projectKey, branch) : undefined; - this.reconcileLocalState(root, temporary, packageKey, branch, observation); - } else { - if (!options.discard && (!current || this.snapshot(root).changes.length !== 0)) { - throw new GracefulError("Workspace has local changes. Use --discard or push them before checkout."); - } - this.replaceWorkspaceContents(root, temporary); - } + await this.applyCheckout(root, temporary, packageKey, projectKey, branch, current, options); } finally { fs.rmSync(temporary, { recursive: true, force: true }); } @@ -145,23 +126,7 @@ export class WorkspaceService { } } const localState = hasLocalState ? this.state(root) : undefined; - let restoredObservation: WorkspaceGitObservation | undefined; - let packageKey = localState?.activePackageKey || projectKey; - if (!localState) { - restoredObservation = this.gitService.observe(root); - if (restoredObservation) { - if (!restoredObservation.branch) { - throw new GracefulError("Detached Git HEAD cannot select a Pacman workspace branch."); - } - const mappedBranch = this.gitService.mappedPacmanBranch(root, projectKey, restoredObservation.branch); - if (!mappedBranch) { - throw new GracefulError( - `Git branch '${restoredObservation.branch}' is not mapped. Use workspace checkout --link-git.` - ); - } - packageKey = this.packageKey(projectKey, this.branch(mappedBranch)); - } - } + const { packageKey, restoredObservation } = await this.pullTarget(root, projectKey, localState); if (localState && !localState.refreshRequired && this.snapshot(root).changes.length !== 0) { throw new GracefulError("Workspace has local changes. Push or discard them before pull."); } @@ -947,45 +912,102 @@ export class WorkspaceService { return path.join(root, ".pacman", "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 applyCheckout( + root: string, + temporary: string, + packageKey: string, + projectKey: string, + branch: string, + current: WorkspaceState | undefined, + options: WorkspaceCheckoutOptions + ): Promise { + if (options.create || options.linkGit) { + const observation = options.linkGit ? await this.linkCurrentGitBranch(root, projectKey, branch) : undefined; + this.reconcileLocalState(root, temporary, packageKey, branch, observation); + 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."); + } + this.replaceWorkspaceContents(root, temporary); + } + + 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 = this.gitService.observe(root); + const observation = await this.gitService.observe(root); if (!state.git) { - 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; + return this.handleUnlinkedGit(observation, requirePushSafe); } if (!observation) { - if (requirePushSafe) { - throw new GracefulError("The linked Git worktree is unavailable; workspace push is blocked."); - } - logger.warn("The linked Git worktree is unavailable; using the last Pacman baseline."); - return false; + 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 && observation.head === state.git.head) { return false; } if (!observation.branch) { - if (requirePushSafe) { - throw new GracefulError("Detached Git HEAD cannot push a Pacman workspace."); - } - logger.warn("Git HEAD is detached; using the last Pacman baseline."); - return false; + 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 = this.gitService.mappedPacmanBranch(root, projectKey, observation.branch); + const mappedBranch = await this.gitService.mappedPacmanBranch(root, projectKey, observation.branch); if (!mappedBranch) { - if (requirePushSafe) { - throw new GracefulError( - `Git branch '${observation.branch}' is not mapped to a Pacman branch. Use workspace checkout --link-git.` - ); - } - logger.warn(`Git branch '${observation.branch}' is not mapped; using the last Pacman baseline.`); - return false; + 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); @@ -1004,8 +1026,28 @@ export class WorkspaceService { return true; } - private linkCurrentGitBranch(root: string, projectKey: string, pacmanBranch: string): WorkspaceGitObservation { - const observation = this.gitService.observe(root); + 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."); } diff --git a/tests/commands/workspace/workspace-git.service.spec.ts b/tests/commands/workspace/workspace-git.service.spec.ts index 709344af..c6a94df8 100644 --- a/tests/commands/workspace/workspace-git.service.spec.ts +++ b/tests/commands/workspace/workspace-git.service.spec.ts @@ -20,42 +20,79 @@ describe("Workspace Git service", () => { afterEach(() => fs.rmSync(repository, { recursive: true, force: true })); - it("observes the current Git branch and HEAD", () => { - const observation = new WorkspaceGitService().observe(workspace); + 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", () => { + it("stores explicit mappings scoped to the workspace and project", async () => { const service = new WorkspaceGitService(); - expect(service.link(workspace, "project-a", "main", "feature-a")).toEqual({ + await expect(service.link(workspace, "project-a", "main", "feature-a")).resolves.toEqual({ branch: "main", head: git("rev-parse", "HEAD"), }); - expect(service.mappedPacmanBranch(workspace, "project-a", "main")).toBe("feature-a"); - expect(service.mappedPacmanBranch(workspace, "project-b", "main")).toBeUndefined(); - expect(service.mappedPacmanBranch(repository, "project-a", "main")).toBeUndefined(); + 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", () => { + it("reports detached HEAD without changing Git state", async () => { const head = git("rev-parse", "HEAD"); git("checkout", "--detach", head); - expect(new WorkspaceGitService().observe(workspace)).toEqual({ branch: "", head }); + await expect(new WorkspaceGitService().observe(workspace)).resolves.toEqual({ branch: "", head }); }); - it("returns no observation outside a Git worktree", () => { + 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 { - expect(new WorkspaceGitService().observe(directory)).toBeUndefined(); + 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 execFileSync("git", ["-C", repository, ...args], { + 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.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index 545c7064..617b4821 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -146,9 +146,9 @@ function mockGit( mappedBranch?: string ): jest.Mocked { return { - observe: jest.fn().mockReturnValue(observation), - mappedPacmanBranch: jest.fn().mockReturnValue(mappedBranch), - link: jest.fn().mockReturnValue(observation), + observe: jest.fn().mockResolvedValue(observation), + mappedPacmanBranch: jest.fn().mockResolvedValue(mappedBranch), + link: jest.fn().mockResolvedValue(observation), } as unknown as jest.Mocked; } From f27f319929b216970f06ba0d44817762457aaa98 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Thu, 20 Aug 2026 11:34:40 +0200 Subject: [PATCH 25/46] Fix workspace synchronization edge cases Includes-AI-Code: true --- .../workspace/workspace-push.service.ts | 3 - src/commands/workspace/workspace.service.ts | 7 +- .../workspace/workspace.service.spec.ts | 75 +++++++++++++++++++ 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/commands/workspace/workspace-push.service.ts b/src/commands/workspace/workspace-push.service.ts index a35ebfc7..96419603 100644 --- a/src/commands/workspace/workspace-push.service.ts +++ b/src/commands/workspace/workspace-push.service.ts @@ -108,9 +108,6 @@ export class WorkspacePushService { : undefined; switch (change.status) { case "added": - if (expected) { - throw new GracefulError("Tracked file is missing synchronization state. Run workspace pull."); - } await this.api.putFile( snapshot.packageKey, change.path, diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index b8d9f59a..074e184b 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -139,7 +139,7 @@ export class WorkspaceService { logger.info(`Pulled ${packageKey}.`); return; } - const temporary = this.validatedArchive(download, packageKey, projectKey); + const temporary = this.validatedArchive(download, packageKey, projectKey, localState?.git); try { if (localState) { this.replaceWorkspaceContents(root, temporary); @@ -990,7 +990,10 @@ export class WorkspaceService { "The linked Git worktree is unavailable; using the last Pacman baseline." ); } - if (observation.branch === state.git.branch && observation.head === state.git.head) { + if (observation.branch === state.git.branch) { + if (observation.head !== state.git.head) { + this.writeState(root, { ...state, git: observation }); + } return false; } if (!observation.branch) { diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index 617b4821..ff63eb38 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -316,6 +316,55 @@ describe("Workspace service", () => { }); }); + it("preserves move hints when Git advances on the mapped branch", 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(), ".pacman", "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); + 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 pulling remote files after Git advances on the mapped branch", async () => { + writeWorkspace(); + const statePath = path.join(process.cwd(), ".pacman", "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) }; + mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }]), { + etag: eTag("revision-2"), + }); + + await new WorkspaceService(testContext, mockGit(observation)).pull(); + + expect(fs.readFileSync(path.join(process.cwd(), "Guides/Guide.md"), "utf-8")).toBe("remote"); + 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(), ".pacman", "local", "state.json"); @@ -894,6 +943,32 @@ describe("Workspace service", () => { ]); }); + it("pushes a metadata-backed addition without a baseline", 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(), ".pacman", "local", "state.json"); + fs.writeFileSync(statePath, JSON.stringify({ ...state([local]), baselineDigests: {} })); + mockAxiosPut(fileUrl(local.path), { + path: local.path, + nodeKey: remote.nodeKey, + assetType: "MARKDOWN_FILE", + eTag: eTag("new"), + }); + mockAxiosGet(ARCHIVE_URL, archive([remote]), { etag: eTag("revision-2") }); + + await new WorkspaceService(testContext).push(); + + expect(mockedAxiosInstance.put).toHaveBeenCalledWith( + fileUrl(local.path), + Buffer.from("new"), + expect.objectContaining({ headers: expect.objectContaining({ "If-None-Match": "*" }) }) + ); + expect(new WorkspaceService(testContext).status()).toEqual([]); + expect(fs.existsSync(path.join(process.cwd(), ".pacman/nodes/local-node.json"))).toBe(false); + expect(fs.existsSync(path.join(process.cwd(), ".pacman/nodes/server-node.json"))).toBe(true); + }); + it("retains a stale file for retry when its conditional update fails", async () => { writeWorkspace(); fs.writeFileSync(path.join(process.cwd(), "Guides/Guide.md"), "changed"); From 7041183d385160305009897a550f34f081373fc4 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Thu, 20 Aug 2026 11:51:11 +0200 Subject: [PATCH 26/46] Fix incremental workspace push retries Includes-AI-Code: true --- .../workspace/workspace-change-classifier.ts | 9 +- .../workspace/workspace-push.service.ts | 58 +++++++++-- src/commands/workspace/workspace.models.ts | 1 + src/commands/workspace/workspace.service.ts | 37 +++++-- .../workspace/workspace.service.spec.ts | 96 +++++++++++++++++++ 5 files changed, 180 insertions(+), 21 deletions(-) diff --git a/src/commands/workspace/workspace-change-classifier.ts b/src/commands/workspace/workspace-change-classifier.ts index 00d94109..3533927e 100644 --- a/src/commands/workspace/workspace-change-classifier.ts +++ b/src/commands/workspace/workspace-change-classifier.ts @@ -32,7 +32,9 @@ export function classifyWorkspaceChanges( ); newPaths.forEach(filePath => changes.push({ path: filePath, status: "added" })); - return changes.sort((left, right) => left.path.localeCompare(right.path) || left.status.localeCompare(right.status)); + return changes.sort( + (left, right) => left.path.localeCompare(right.path) || left.status.localeCompare(right.status) + ); } function classifyExpectedFile( @@ -106,13 +108,12 @@ function classifyHintedFile( ): void { const targetPath = visiblePathIndex.get(hint.toLowerCase()); const sourcePath = visiblePathIndex.get(file.path.toLowerCase()); - const sourceStillPresent = hint.toLowerCase() !== file.path.toLowerCase() && Boolean(sourcePath); - if (sourceStillPresent || !targetPath || consumedPaths.has(targetPath)) { + if (!targetPath || consumedPaths.has(targetPath)) { changes.push({ nodeKey: file.nodeKey, path: hint, status: "unresolved" }); if (targetPath) { consumedPaths.add(targetPath); } - if (sourceStillPresent && sourcePath) { + if (sourcePath) { consumedPaths.add(sourcePath); } return; diff --git a/src/commands/workspace/workspace-push.service.ts b/src/commands/workspace/workspace-push.service.ts index 96419603..76ad2e89 100644 --- a/src/commands/workspace/workspace-push.service.ts +++ b/src/commands/workspace/workspace-push.service.ts @@ -15,20 +15,33 @@ interface Selection { directory: boolean; } +interface WorkspacePushOperationError extends Error { + remoteChanged: boolean; +} + +function operationError(error: unknown, remoteChanged: boolean): WorkspacePushOperationError { + return Object.assign(new Error(error instanceof Error ? error.message : String(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[]): Promise { - const changes = this.select(root, snapshot, paths); + const changes = this.order(this.select(root, snapshot, paths)); const outcomes: WorkspacePushOutcome[] = []; for (const change of changes) { try { await this.pushChange(root, snapshot, change); - outcomes.push({ ...change, success: true }); + outcomes.push({ ...change, success: true, remoteChanged: true }); } catch (error) { outcomes.push({ ...change, success: false, + remoteChanged: isOperationError(error) && error.remoteChanged, error: error instanceof Error ? error.message : String(error), }); } @@ -63,6 +76,27 @@ export class WorkspacePushService { .map(candidate => candidate.change); } + private order(changes: ClassifiedWorkspaceChange[]): ClassifiedWorkspaceChange[] { + const priority = (change: ClassifiedWorkspaceChange): number => { + switch (change.status) { + case "deleted": + return 0; + case "moved": + case "moved, modified": + return 1; + case "modified": + return 2; + case "added": + return 3; + case "unresolved": + return 4; + } + }; + return [...changes].sort( + (left, right) => priority(left) - priority(right) || left.path.localeCompare(right.path) + ); + } + private 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("/"); @@ -136,16 +170,22 @@ export class WorkspacePushService { case "moved, modified": { const tracked = this.requireExpected(expected); 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 { + await this.api.putFile( + snapshot.packageKey, + change.path, + this.content(root, change.path), + this.contentType(change.path), + { "If-Match": eTag } + ); + } catch (error) { + throw operationError(error, moved); } - await this.api.putFile( - snapshot.packageKey, - change.path, - this.content(root, change.path), - this.contentType(change.path), - { "If-Match": eTag } - ); return; } case "deleted": { diff --git a/src/commands/workspace/workspace.models.ts b/src/commands/workspace/workspace.models.ts index 39d2c778..5dca6663 100644 --- a/src/commands/workspace/workspace.models.ts +++ b/src/commands/workspace/workspace.models.ts @@ -86,6 +86,7 @@ export interface WorkspacePushOutcome { status: WorkspaceChangeStatus; nodeKey?: string; success: boolean; + remoteChanged?: boolean; error?: string; } diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index 074e184b..e09e7427 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -207,7 +207,8 @@ export class WorkspaceService { ); const succeeded = outcomes.filter(outcome => outcome.success); const failed = outcomes.filter(outcome => !outcome.success); - if (succeeded.length === 0) { + const remoteChanged = outcomes.some(outcome => outcome.remoteChanged); + if (!remoteChanged) { this.writeState(root, snapshot.state); if (failed.length > 0) { throw new GracefulError(`Workspace push failed for ${failed.length} file(s).`); @@ -215,13 +216,7 @@ export class WorkspaceService { logger.info("Workspace is clean."); return; } - const retainedHints = Object.fromEntries( - failed - .filter( - outcome => outcome.nodeKey && (outcome.status === "moved" || outcome.status === "moved, modified") - ) - .map(outcome => [outcome.nodeKey!, snapshot.state.moveHints[outcome.nodeKey!] || outcome.path]) - ); + const retainedHints = this.retainedMoveHints(snapshot.state.moveHints, outcomes); this.writeState(root, { ...snapshot.state, moveHints: retainedHints, @@ -908,6 +903,32 @@ export class WorkspaceService { ); } + 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, ".pacman", "local", "state.json"); } diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index ff63eb38..d00a2cab 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -910,6 +910,34 @@ describe("Workspace service", () => { ]); }); + 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"), + }); + mockAxiosGet(ARCHIVE_URL, archive([{ ...original[0], path: "Pages/One.md" }, original[1]]), { + etag: eTag("revision-2"), + }); + + 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(), ".pacman/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" }, @@ -1072,6 +1100,74 @@ describe("Workspace service", () => { 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(fileUrl("Guides/Guide.md"), { + path: "Guides/Guide.md", + nodeKey: "node-2", + assetType: "MARKDOWN_FILE", + eTag: eTag("file-3"), + }); + mockAxiosGet( + ARCHIVE_URL, + archive([ + { nodeKey: "node-1", path: "Pages/Guide.md", content: "original" }, + { nodeKey: "node-2", path: "Guides/Guide.md", content: "replacement" }, + ]), + { etag: eTag("revision-2") } + ); + + await service.push(); + + 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(fileUrl("Pages/Guide.md"), 412, { message: "stale" }); + mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "original" }]), { + etag: eTag("revision-2"), + }); + + await expect(service.push()).rejects.toThrow("Workspace push failed for 1 file(s)"); + + expect(service.status()).toEqual([{ path: "Pages/Guide.md", status: "modified" }]); + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman/local/state.json"), "utf-8")) + ).toMatchObject({ + serverRevision: eTag("revision-2"), + baselineDigests: { "node-1": digest("original") }, + moveHints: {}, + }); + }); + it("rejects paths with a full push and overwrite without a full push", async () => { writeWorkspace(); From 133da0ecea4f6de34d5d0443c2132c7a4c4aede1 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Thu, 20 Aug 2026 12:16:13 +0200 Subject: [PATCH 27/46] Add incremental workspace pull Includes-AI-Code: true --- src/commands/workspace/module.ts | 11 +- src/commands/workspace/workspace-api.ts | 19 +- .../workspace/workspace-path-selection.ts | 63 ++ .../workspace/workspace-pull.service.ts | 626 ++++++++++++++++++ .../workspace/workspace-push.service.ts | 82 +-- src/commands/workspace/workspace.models.ts | 37 +- src/commands/workspace/workspace.service.ts | 265 +++++--- tests/commands/workspace/module.spec.ts | 8 +- .../workspace/workspace.service.spec.ts | 313 ++++++--- 9 files changed, 1166 insertions(+), 258 deletions(-) create mode 100644 src/commands/workspace/workspace-path-selection.ts create mode 100644 src/commands/workspace/workspace-pull.service.ts diff --git a/src/commands/workspace/module.ts b/src/commands/workspace/module.ts index 88b39df2..693674a2 100644 --- a/src/commands/workspace/module.ts +++ b/src/commands/workspace/module.ts @@ -23,7 +23,12 @@ class Module extends IModule { .option("--link-git", "Map the current Git branch", false) .action(this.checkout); - workspace.command("pull [directory]").beta().description("Pull remote changes.").action(this.pull); + workspace + .command("pull [paths...]") + .beta() + .description("Pull remote changes.") + .option("--full", "Pull and replace from the full workspace archive", false) + .action(this.pull); workspace.command("status [directory]").beta().description("Show local changes.").action(this.status); @@ -59,8 +64,8 @@ class Module extends IModule { }); } - private async pull(context: Context, command: Command): Promise { - await new WorkspaceService(context).pull(command.args[0]); + private async pull(context: Context, command: Command, options: OptionValues): Promise { + await new WorkspaceService(context).pull(command.args, { full: options.full }); } private async status(context: Context, command: Command): Promise { diff --git a/src/commands/workspace/workspace-api.ts b/src/commands/workspace/workspace-api.ts index 6480272e..69357d41 100644 --- a/src/commands/workspace/workspace-api.ts +++ b/src/commands/workspace/workspace-api.ts @@ -1,7 +1,7 @@ import * as FormData from "form-data"; import { Context } from "../../core/command/cli-context"; import { GracefulError } from "../../core/utils/logger"; -import { NodeFileWriteResponse, WorkspaceBranch } from "./workspace.models"; +import { NodeFileWriteResponse, WorkspaceBranch, WorkspaceManifest } from "./workspace.models"; export class WorkspaceApi { constructor(private readonly context: Context) {} @@ -35,6 +35,23 @@ export class WorkspaceApi { return { body: response.data, eTag }; } + public async manifest(packageKey: string): Promise<{ manifest: WorkspaceManifest; eTag: string }> { + const response = await this.context.httpClient.getFileWithHeaders( + `/pacman/api/core/staging/packages/${encodeURIComponent(packageKey)}/files` + ); + const eTag = response.headers.etag; + if (typeof eTag !== "string" || !/^"sha256:[0-9a-f]{64}"$/.test(eTag)) { + throw new GracefulError("Workspace manifest response does not contain a valid package ETag."); + } + try { + return { manifest: JSON.parse(response.data.toString("utf-8")) as WorkspaceManifest, eTag }; + } catch (error) { + const failure = new GracefulError("Workspace manifest response is invalid JSON."); + failure.cause = error; + throw failure; + } + } + public putFile( packageKey: string, filePath: string, diff --git a/src/commands/workspace/workspace-path-selection.ts b/src/commands/workspace/workspace-path-selection.ts new file mode 100644 index 00000000..63e3a9b4 --- /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 === ".pacman" || + folded.startsWith(".pacman/") || + 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..3f155fe6 --- /dev/null +++ b/src/commands/workspace/workspace-pull.service.ts @@ -0,0 +1,626 @@ +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, + WorkspaceManifest, + WorkspaceManifestNode, + WorkspaceNodeMetadata, + WorkspacePullOutcome, + WorkspacePullStatus, + WorkspaceSnapshot, + WorkspaceState, +} from "./workspace.models"; + +const FORBIDDEN_METADATA_FIELDS = [ + "configuration", + "packageKey", + "packageNodeKey", + "branchKey", + "creationDate", + "changeDate", + "revision", + "serverRevision", +]; + +interface PullOperation { + nodeKey: string; + path: string; + localPath?: string; + status: WorkspacePullStatus; + entry?: WorkspaceManifestNode; + localNode?: WorkspaceNodeMetadata; + localChange?: ClassifiedWorkspaceChange; + conflict?: string; + converged?: boolean; +} + +export interface WorkspacePullResult { + outcomes: WorkspacePullOutcome[]; + state: WorkspaceState; +} + +export class WorkspacePullService { + constructor(private readonly api: WorkspaceApi) {} + + public async pull( + root: string, + snapshot: WorkspaceSnapshot, + localNodes: WorkspaceNodeMetadata[], + paths: string[], + manifest: WorkspaceManifest + ): Promise { + this.validateManifest(manifest); + const operations = this.select(root, paths, this.operations(snapshot, localNodes, manifest)); + const state: WorkspaceState = { + ...snapshot.state, + baselineDigests: { ...snapshot.state.baselineDigests }, + moveHints: { ...snapshot.state.moveHints }, + }; + delete state.serverRevision; + delete state.refreshRequired; + const outcomes: WorkspacePullOutcome[] = []; + 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); + 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 hydrateBaseline( + state: WorkspaceState, + packageKey: string, + activeBranch: string, + manifest: WorkspaceManifest + ): WorkspaceState { + this.validateManifest(manifest); + const hydrated: WorkspaceState = { + schemaVersion: 1, + activePackageKey: packageKey, + activeBranch, + baselineDigests: Object.fromEntries( + manifest.nodes + .filter(entry => entry.kind === "file") + .map(entry => [entry.nodeKey, entry.contentDigest!]) + ), + 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 }, + moveHints: { ...state.moveHints }, + }; + delete next.serverRevision; + 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.moveHints[outcome.localNodeKey]; + } + const entry = byNodeKey.get(nodeKey); + if (!entry) { + if (outcome.status === "deleted") { + this.removeMetadata(root, nodeKey); + delete next.baselineDigests[nodeKey]; + delete next.moveHints[nodeKey]; + } + return; + } + this.writeMetadataWithAncestors(root, entry, manifest); + if (entry.kind === "file") { + next.baselineDigests[nodeKey] = entry.contentDigest!; + } + delete next.moveHints[nodeKey]; + }); + return next; + } + + private operations( + snapshot: WorkspaceSnapshot, + localNodes: WorkspaceNodeMetadata[], + manifest: WorkspaceManifest + ): PullOperation[] { + const localByKey = new Map(localNodes.map(node => [node.key, node])); + const localPaths = this.localPaths(localNodes); + 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 operations: PullOperation[] = []; + manifest.nodes.forEach(entry => { + const localNode = localByKey.get(entry.nodeKey); + const localPath = localPaths.get(entry.nodeKey); + const expected = expectedByKey.get(entry.nodeKey); + const localChange = changeByKey.get(entry.nodeKey); + if (!localNode) { + const occupied = entry.kind === "file" && this.visibleAt(snapshot, entry.path); + operations.push({ + nodeKey: entry.nodeKey, + path: entry.path, + status: "added", + entry, + conflict: occupied + ? `Remote file conflicts with an untracked local path: ${entry.path}` + : undefined, + }); + return; + } + const remoteChanged = this.remoteChanged(entry, localNode, localPath, expected); + if (!remoteChanged) { + return; + } + const operation: PullOperation = { + nodeKey: entry.nodeKey, + path: entry.path, + localPath, + status: localPath && localPath.toLowerCase() !== entry.path.toLowerCase() ? "moved" : "modified", + entry, + localNode, + localChange, + }; + if (localChange) { + const localDigest = snapshot.visibleFiles.get(localChange.path); + operation.converged = + entry.kind === "file" && + localChange.path.toLowerCase() === entry.path.toLowerCase() && + localDigest === entry.contentDigest; + if (!operation.converged) { + operation.conflict = `Local and remote changes conflict for node ${entry.nodeKey}.`; + } + } + operations.push(operation); + }); + localNodes + .filter(node => !remoteByKey.has(node.key)) + .forEach(node => { + const localPath = localPaths.get(node.key); + if (!localPath) { + return; + } + const localChange = changeByKey.get(node.key); + operations.push({ + nodeKey: node.key, + 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.key}.` + : undefined, + }); + }); + return operations; + } + + 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?.kind === "folder" && operation.status !== "deleted") { + return 0; + } + if (operation.localNode && this.isFolder(operation.localNode) && operation.status === "deleted") { + return 2; + } + return 1; + }; + return [...operations].sort( + (left, right) => priority(left) - priority(right) || left.path.localeCompare(right.path) + ); + } + + private async apply( + root: string, + packageKey: string, + operation: PullOperation, + manifest: WorkspaceManifest, + state: WorkspaceState + ): Promise { + if (operation.status === "deleted") { + this.applyDelete(root, operation); + this.removeMetadata(root, operation.nodeKey); + delete state.baselineDigests[operation.nodeKey]; + delete state.moveHints[operation.nodeKey]; + return; + } + const entry = operation.entry!; + if (entry.kind === "folder") { + this.applyFolder(root, operation, entry); + } + if (entry.kind === "file" && !operation.converged) { + await this.applyFile(root, packageKey, operation, entry); + } + this.writeMetadataWithAncestors(root, entry, manifest); + if (entry.kind === "file") { + state.baselineDigests[entry.nodeKey] = entry.contentDigest!; + } + delete state.moveHints[entry.nodeKey]; + } + + private async applyFile( + root: string, + packageKey: string, + operation: PullOperation, + entry: WorkspaceManifestNode + ): 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()); + if (moved && fs.existsSync(target)) { + if ( + source && + !fs.existsSync(source) && + fs.lstatSync(target).isFile() && + this.digest(target) === entry.contentDigest + ) { + return; + } + throw new GracefulError(`Remote file move conflicts with an existing local path: ${entry.path}`); + } + const sourceDigest = + source && fs.existsSync(source) && fs.lstatSync(source).isFile() ? this.digest(source) : undefined; + const bodyChanged = sourceDigest !== entry.contentDigest; + fs.mkdirSync(path.dirname(target), { recursive: true }); + if (bodyChanged) { + const remote = await this.api.readFile(packageKey, entry.path); + if (this.digestBuffer(remote.body) !== entry.contentDigest || remote.body.length !== entry.size) { + throw new GracefulError(`Remote file body does not match its manifest: ${entry.path}`); + } + fs.writeFileSync(target, remote.body); + if (moved && source && fs.existsSync(source)) { + fs.rmSync(source); + } + return; + } + if (moved && source) { + fs.renameSync(source, target); + } + } + + private applyFolder(root: string, operation: PullOperation, entry: WorkspaceManifestNode): void { + 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; + } + if (fs.existsSync(target)) { + if (source && !fs.existsSync(source) && fs.lstatSync(target).isDirectory()) { + return; + } + 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; + } + 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); + } + + private applyDelete(root: string, operation: PullOperation): void { + if (operation.localNode && this.isFolder(operation.localNode)) { + const metadataDirectory = path.join(root, ".pacman", "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, manifest: WorkspaceManifest): void { + this.writeDocument(root, entry, manifest); + const metadataDirectory = path.join(root, ".pacman", "nodes"); + fs.mkdirSync(metadataDirectory, { recursive: true }); + fs.writeFileSync( + path.join(metadataDirectory, `${entry.nodeKey}.json`), + `${JSON.stringify(entry.metadata, null, 2)}\n` + ); + } + + private writeDocument(root: string, entry: WorkspaceManifestNode, manifest: WorkspaceManifest): void { + const reference = entry.metadata.serializedDocumentRef; + if (!reference) { + return; + } + const match = /^\.pacman\/documents\/([0-9a-f]{64})\.bin$/.exec(reference); + if (!match) { + throw new GracefulError(`Invalid document reference for node ${entry.nodeKey}.`); + } + const digest = `sha256:${match[1]}`; + const encoded = manifest.documents[digest]; + if (typeof encoded !== "string") { + throw new GracefulError(`Workspace manifest is missing document ${digest}.`); + } + const document = Buffer.from(encoded, "base64"); + if (this.digestBuffer(document) !== digest) { + throw new GracefulError(`Workspace manifest document digest does not match ${digest}.`); + } + const target = path.join(root, reference); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, document); + } + + 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 || parent.kind !== "folder") { + throw new GracefulError(`Workspace manifest has an invalid parent for node ${entry.nodeKey}.`); + } + this.writeMetadataWithAncestors(root, parent, manifest, visited); + } + this.writeMetadata(root, entry, manifest); + visited.delete(entry.nodeKey); + } + + private removeMetadata(root: string, nodeKey: string): void { + fs.rmSync(path.join(root, ".pacman", "nodes", `${nodeKey}.json`), { force: true }); + } + + private remoteChanged( + entry: WorkspaceManifestNode, + localNode: WorkspaceNodeMetadata, + localPath: string | undefined, + expected: ExpectedWorkspaceFile | undefined + ): boolean { + if (entry.path.toLowerCase() !== localPath?.toLowerCase() || !this.sameMetadata(entry.metadata, localNode)) { + return true; + } + return entry.kind === "file" && entry.contentDigest !== expected?.digest; + } + + private localPaths(nodes: WorkspaceNodeMetadata[]): Map { + const byKey = new Map(nodes.map(node => [node.key, node])); + const paths = new Map(); + const resolving = new Set(); + const resolve = (node: WorkspaceNodeMetadata): string => { + const cached = paths.get(node.key); + if (cached) { + return cached; + } + if (resolving.has(node.key)) { + throw new GracefulError(`Circular node hierarchy at ${node.key}.`); + } + resolving.add(node.key); + const segment = this.filesystemName(node); + const parent = node.parentNodeKey ? byKey.get(node.parentNodeKey) : undefined; + const value = parent ? `${resolve(parent)}/${segment}` : segment; + resolving.delete(node.key); + paths.set(node.key, value); + return value; + }; + nodes.forEach(resolve); + return paths; + } + + private validateManifest(manifest: WorkspaceManifest): void { + if (!manifest || !Array.isArray(manifest.nodes) || !manifest.documents || Array.isArray(manifest.documents)) { + 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; + if ( + !entry.nodeKey || + !this.validManifestPath(entry.path) || + !entry.metadata || + entry.metadata.key !== entry.nodeKey || + FORBIDDEN_METADATA_FIELDS.some(field => field in metadata) || + "body" in (entry as unknown as Record) || + (entry.kind !== "file" && entry.kind !== "folder") || + this.isFolder(entry.metadata) !== (entry.kind === "folder") || + keys.has(entry.nodeKey) || + paths.has(foldedPath) || + (entry.kind === "file" && + (!entry.assetType || + !entry.mediaType || + !/^sha256:[0-9a-f]{64}$/.test(entry.contentDigest || "") || + typeof entry.size !== "number" || + !Number.isSafeInteger(entry.size) || + entry.size < 0 || + !/^"sha256:[0-9a-f]{64}"$/.test(entry.eTag || ""))) + ) { + 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.add(entry.nodeKey)) { + throw new GracefulError("Workspace manifest contains a circular hierarchy."); + } + const parentKey = entry.metadata.parentNodeKey; + if (parentKey) { + const parent = byKey.get(parentKey); + if (!parent || parent.kind !== "folder") { + 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()); + this.validateDocument(entry, manifest); + }); + if (!Object.values(manifest.documents).every(value => typeof value === "string")) { + throw new GracefulError("Unsupported workspace manifest."); + } + } + + private validateDocument(entry: WorkspaceManifestNode, manifest: WorkspaceManifest): void { + const reference = entry.metadata.serializedDocumentRef; + if (!reference) { + return; + } + const match = /^\.pacman\/documents\/([0-9a-f]{64})\.bin$/.exec(reference); + const digest = match ? `sha256:${match[1]}` : undefined; + const encoded = digest ? manifest.documents[digest] : undefined; + if (!digest || typeof encoded !== "string" || this.digestBuffer(Buffer.from(encoded, "base64")) !== digest) { + throw new GracefulError(`Workspace manifest has an invalid document for node ${entry.nodeKey}.`); + } + } + + 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 !== ".pacman" && + 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 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 filesystemName(node: WorkspaceNodeMetadata): string { + const value = node.filesystemName || node.metadata?.filesystemName || node.additionalFields?.filesystemName; + if (typeof value !== "string" || !value || value.includes("/") || value.includes("\\")) { + throw new GracefulError(`Invalid filesystem name for node ${node.key}.`); + } + return value; + } + + 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 index 76ad2e89..215c72b3 100644 --- a/src/commands/workspace/workspace-push.service.ts +++ b/src/commands/workspace/workspace-push.service.ts @@ -3,6 +3,7 @@ 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, @@ -10,11 +11,6 @@ import { WorkspaceSnapshot, } from "./workspace.models"; -interface Selection { - path: string; - directory: boolean; -} - interface WorkspacePushOperationError extends Error { remoteChanged: boolean; } @@ -35,8 +31,14 @@ export class WorkspacePushService { const outcomes: WorkspacePushOutcome[] = []; for (const change of changes) { try { - await this.pushChange(root, snapshot, change); - outcomes.push({ ...change, success: true, remoteChanged: true }); + const result = await this.pushChange(root, snapshot, change); + 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, @@ -50,30 +52,14 @@ export class WorkspacePushService { } private select(root: string, snapshot: WorkspaceSnapshot, paths: string[]): ClassifiedWorkspaceChange[] { - if (paths.length === 0) { - return snapshot.changes; - } const expectedByNodeKey = new Map(snapshot.expectedFiles.map(file => [file.nodeKey, file])); const candidates = snapshot.changes.map(change => ({ - change, + value: change, paths: [change.path, change.nodeKey ? expectedByNodeKey.get(change.nodeKey)?.path : undefined].filter( (value): value is string => Boolean(value) ), })); - const selections = paths.map(value => - this.selection( - root, - value, - candidates.flatMap(candidate => candidate.paths) - ) - ); - return candidates - .filter(candidate => - selections.some(selection => - candidate.paths.some(candidatePath => this.matches(selection, candidatePath)) - ) - ) - .map(candidate => candidate.change); + return selectWorkspaceCandidates(root, paths, candidates); } private order(changes: ClassifiedWorkspaceChange[]): ClassifiedWorkspaceChange[] { @@ -97,43 +83,11 @@ export class WorkspacePushService { ); } - private 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("/"); - if ( - relative === ".." || - relative.startsWith("../") || - path.isAbsolute(relative) || - relative.toLowerCase() === ".pacman" || - relative.toLowerCase().startsWith(".pacman/") || - relative.toLowerCase() === ".git" || - relative.toLowerCase().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(`${relative.toLowerCase()}/`)); - return { path: relative, directory }; - } - - private 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; - } - private async pushChange( root: string, snapshot: WorkspaceSnapshot, change: ClassifiedWorkspaceChange - ): Promise { + ): Promise { if (change.status === "unresolved") { throw new GracefulError("File identity is unresolved. Record the intended move before pushing."); } @@ -142,30 +96,27 @@ export class WorkspacePushService { : undefined; switch (change.status) { case "added": - await this.api.putFile( + return this.api.putFile( snapshot.packageKey, change.path, this.content(root, change.path), this.contentType(change.path), { "If-None-Match": "*" } ); - return; case "modified": { const eTag = await this.currentETag(snapshot.packageKey, this.requireExpected(expected), change.path); - await this.api.putFile( + return this.api.putFile( snapshot.packageKey, change.path, this.content(root, change.path), this.contentType(change.path), { "If-Match": eTag } ); - return; } case "moved": { const tracked = this.requireExpected(expected); const eTag = await this.currentETag(snapshot.packageKey, tracked, tracked.path); - await this.api.moveFile(snapshot.packageKey, tracked.path, change.path, eTag); - return; + return this.api.moveFile(snapshot.packageKey, tracked.path, change.path, eTag); } case "moved, modified": { const tracked = this.requireExpected(expected); @@ -176,7 +127,7 @@ export class WorkspacePushService { moved = true; } try { - await this.api.putFile( + return await this.api.putFile( snapshot.packageKey, change.path, this.content(root, change.path), @@ -186,7 +137,6 @@ export class WorkspacePushService { } catch (error) { throw operationError(error, moved); } - return; } case "deleted": { const tracked = this.requireExpected(expected); diff --git a/src/commands/workspace/workspace.models.ts b/src/commands/workspace/workspace.models.ts index 5dca6663..56d1ab0b 100644 --- a/src/commands/workspace/workspace.models.ts +++ b/src/commands/workspace/workspace.models.ts @@ -2,7 +2,7 @@ export interface WorkspaceState { schemaVersion: number; activePackageKey: string; activeBranch: string; - serverRevision: string; + serverRevision?: string; baselineDigests: Record; moveHints: Record; git?: WorkspaceGitObservation; @@ -46,6 +46,9 @@ export interface WorkspaceNodeMetadata { type: string; parentNodeKey?: string | null; filesystemName?: string; + schemaVersion?: number; + serializedDocumentRef?: string; + dependenciesConfiguration?: unknown; metadata?: Record; additionalFields?: Record; } @@ -81,10 +84,42 @@ export interface WorkspacePushOptions { overwrite?: boolean; } +export interface WorkspacePullOptions { + full?: boolean; +} + +export interface WorkspaceManifest { + nodes: WorkspaceManifestNode[]; + documents: Record; +} + +export interface WorkspaceManifestNode { + nodeKey: string; + path: string; + kind: "file" | "folder"; + assetType?: string | null; + mediaType?: string | null; + size?: number | null; + contentDigest?: string | null; + eTag?: string | null; + 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; success: boolean; remoteChanged?: boolean; error?: string; diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index e09e7427..d8b74d34 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -10,6 +10,7 @@ import { BranchUtils } from "../../core/utils/branches"; import { WorkspaceApi } from "./workspace-api"; import { classifyWorkspaceChanges } from "./workspace-change-classifier"; import { WorkspaceGitService } from "./workspace-git.service"; +import { WorkspacePullService } from "./workspace-pull.service"; import { WorkspacePushService } from "./workspace-push.service"; import { ExpectedWorkspaceFile, @@ -17,9 +18,11 @@ import { WorkspaceCheckoutOptions, WorkspaceCloneOptions, WorkspaceGitObservation, + WorkspaceManifest, WorkspaceMoveHint, WorkspaceNodeMetadata, WorkspacePackageIdentity, + WorkspacePullOptions, WorkspacePushOptions, WorkspacePushOutcome, WorkspaceSnapshot, @@ -104,6 +107,19 @@ export class WorkspaceService { 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: {}, + moveHints: {}, + }; + await this.hydrateGitBaseline(root, { ...base, git: observation }, packageKey, branch, observation); + logger.info(`${options.create ? "Created and selected" : "Selected"} ${packageKey}.`); + return; + } const download = await this.api.download(packageKey); const temporary = this.validatedArchive(download, packageKey, projectKey); try { @@ -114,8 +130,15 @@ export class WorkspaceService { logger.info(`${options.create ? "Created and selected" : "Selected"} ${packageKey}.`); } - public async pull(directory?: string): Promise { - const root = this.root(directory); + public async pull(paths: string[] = [], options: WorkspacePullOptions = {}): Promise { + if (options.full) { + if (paths.length > 0) { + throw new GracefulError("Workspace paths cannot be combined with --full."); + } + await this.pullFull(); + return; + } + const root = this.root(); const projectKey = this.packageIdentity(root).projectKey; const hasLocalState = fs.existsSync(this.statePath(root)); if (hasLocalState) { @@ -125,37 +148,77 @@ export class WorkspaceService { return; } } - const localState = hasLocalState ? this.state(root) : undefined; - const { packageKey, restoredObservation } = await this.pullTarget(root, projectKey, localState); - if (localState && !localState.refreshRequired && this.snapshot(root).changes.length !== 0) { - throw new GracefulError("Workspace has local changes. Push or discard them before pull."); - } - const download = await this.api.download(packageKey); + let localState = hasLocalState ? this.state(root) : undefined; if (localState?.refreshRequired) { - const moveHints = localState.moveHints; - this.refreshMetadata(root, download, packageKey, projectKey, localState.git); - const refreshed = this.state(root); - this.writeState(root, { ...refreshed, moveHints: this.reconciledMoveHints(root, moveHints) }); + localState = { ...localState }; + delete localState.refreshRequired; + this.writeState(root, localState); + } + if (localState?.serverRevision) { + localState = { ...localState }; + delete localState.serverRevision; + this.writeState(root, localState); + } + const { packageKey, restoredObservation } = await this.pullTarget(root, projectKey, localState); + const remote = await this.api.manifest(packageKey); + const pullService = new WorkspacePullService(this.api); + if (!localState) { + const initial: WorkspaceState = { + schemaVersion: 1, + activePackageKey: packageKey, + activeBranch: this.branchFromPackageKey(projectKey, packageKey), + baselineDigests: {}, + moveHints: {}, + }; + if (restoredObservation) { + initial.git = restoredObservation; + } + this.writeState( + root, + this.withRemotePathHints( + root, + pullService.hydrateBaseline(initial, packageKey, initial.activeBranch, remote.manifest), + remote.manifest + ) + ); logger.info(`Pulled ${packageKey}.`); return; } - const temporary = this.validatedArchive(download, packageKey, projectKey, localState?.git); + const result = await pullService.pull(root, this.snapshot(root), this.nodes(root), paths, remote.manifest); + this.writeState(root, result.state); + result.outcomes.forEach(outcome => + logger.info( + `${outcome.success ? "succeeded" : "failed"}: ${outcome.status} ${outcome.path}` + + (outcome.error ? ` (${outcome.error})` : "") + ) + ); + const failed = result.outcomes.filter(outcome => !outcome.success); + if (failed.length > 0) { + throw new GracefulError(`Workspace pull failed for ${failed.length} node(s).`); + } + logger.info(`Pulled ${packageKey}.`); + } + + private async pullFull(): Promise { + const root = this.root(); + await this.synchronizeGitTarget(root, false); + const state = this.state(root); + const projectKey = this.packageIdentity(root).projectKey; + if (!state.refreshRequired && this.snapshot(root).changes.length !== 0) { + throw new GracefulError("Workspace has local changes. Push or discard them before full pull."); + } + const temporary = this.validatedArchive( + await this.api.download(state.activePackageKey), + state.activePackageKey, + projectKey, + state.git + ); try { - if (localState) { - this.replaceWorkspaceContents(root, temporary); - } else { - this.reconcileLocalState( - root, - temporary, - packageKey, - this.branchFromPackageKey(projectKey, packageKey), - restoredObservation - ); - } + this.replaceWorkspaceContents(root, temporary); } finally { fs.rmSync(temporary, { recursive: true, force: true }); } - logger.info(`Pulled ${packageKey}.`); + logger.info(`Pulled ${state.activePackageKey}.`); } public status(directory?: string): WorkspaceChange[] { @@ -191,14 +254,10 @@ export class WorkspaceService { const root = this.root(); await this.synchronizeGitTarget(root, true); const snapshot = this.snapshot(root); - this.writeState(root, { ...snapshot.state, refreshRequired: true }); - let outcomes: WorkspacePushOutcome[]; - try { - outcomes = await new WorkspacePushService(this.api).push(root, snapshot, paths); - } catch (error) { - this.writeState(root, snapshot.state); - throw error; - } + const invalidatedBeforePush: WorkspaceState = { ...snapshot.state }; + delete invalidatedBeforePush.serverRevision; + this.writeState(root, invalidatedBeforePush); + const outcomes: WorkspacePushOutcome[] = await new WorkspacePushService(this.api).push(root, snapshot, paths); outcomes.forEach(outcome => logger.info( `${outcome.success ? "succeeded" : "failed"}: ${outcome.status} ${outcome.path}` + @@ -208,34 +267,29 @@ export class WorkspaceService { const succeeded = outcomes.filter(outcome => outcome.success); 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.serverRevision; + delete invalidatedState.refreshRequired; if (!remoteChanged) { - this.writeState(root, snapshot.state); + this.writeState(root, invalidatedState); if (failed.length > 0) { throw new GracefulError(`Workspace push failed for ${failed.length} file(s).`); } logger.info("Workspace is clean."); return; } - const retainedHints = this.retainedMoveHints(snapshot.state.moveHints, outcomes); - this.writeState(root, { - ...snapshot.state, - moveHints: retainedHints, - refreshRequired: true, - }); try { - this.refreshMetadata( + const remote = await this.api.manifest(snapshot.packageKey); + this.writeState( root, - await this.api.download(snapshot.packageKey), - snapshot.packageKey, - snapshot.projectKey, - snapshot.state.git + new WorkspacePullService(this.api).applyPushResults(root, invalidatedState, remote.manifest, outcomes) ); - const refreshed = this.state(root); - this.writeState(root, { - ...refreshed, - moveHints: this.reconciledMoveHints(root, retainedHints), - }); } 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." ); @@ -265,6 +319,9 @@ export class WorkspaceService { ); }); try { + if (!snapshot.state.serverRevision) { + throw new GracefulError("A full push requires a revision from workspace pull --full."); + } const form = new FormData(); form.append("packageFile", fs.createReadStream(zipPath), { filename: "workspace.zip" }); const moves = Object.fromEntries( @@ -280,7 +337,9 @@ export class WorkspaceService { form.append("moveMappings", JSON.stringify({ moves }), { contentType: "application/json" }); } await this.api.pushArchive(snapshot.packageKey, form, overwrite, snapshot.state.serverRevision); - this.writeState(root, { ...snapshot.state, refreshRequired: true }); + const pendingRefresh: WorkspaceState = { ...snapshot.state, refreshRequired: true }; + delete pendingRefresh.serverRevision; + this.writeState(root, pendingRefresh); try { const refreshedArchive = await this.api.download(snapshot.packageKey); this.refreshMetadata( @@ -782,8 +841,7 @@ export class WorkspaceService { parsed.schemaVersion !== 1 || !parsed.activePackageKey || !parsed.activeBranch || - !parsed.serverRevision || - !/^"sha256:[0-9a-f]{64}"$/.test(parsed.serverRevision) || + (parsed.serverRevision !== undefined && !/^"sha256:[0-9a-f]{64}"$/.test(parsed.serverRevision)) || !parsed.baselineDigests || typeof parsed.baselineDigests !== "object" || Array.isArray(parsed.baselineDigests) || @@ -811,10 +869,12 @@ export class WorkspaceService { schemaVersion: parsed.schemaVersion, activePackageKey: parsed.activePackageKey, activeBranch: parsed.activeBranch, - serverRevision: parsed.serverRevision, baselineDigests: parsed.baselineDigests, moveHints: parsed.moveHints || {}, }; + if (parsed.serverRevision) { + state.serverRevision = parsed.serverRevision; + } if (parsed.refreshRequired) { state.refreshRequired = true; } @@ -876,33 +936,6 @@ export class WorkspaceService { ); } - private reconciledMoveHints( - root: string, - hints: Record - ): Record { - const refreshed = this.state(root); - const remotePathByNodeKey = new Map( - this.expectedFiles(root, refreshed, refreshed.activePackageKey).map(file => [file.nodeKey, file.path]) - ); - return Object.fromEntries( - Object.entries(hints).flatMap(([nodeKey, hint]) => { - const remotePath = remotePathByNodeKey.get(nodeKey); - const targetPath = this.moveHintTarget(hint); - if (!remotePath || !targetPath || remotePath.toLowerCase() === targetPath.toLowerCase()) { - return []; - } - return [ - [ - nodeKey, - this.isStructuredMoveHint(hint) - ? { sourcePath: remotePath, targetPath: hint.targetPath } - : hint, - ], - ]; - }) - ); - } - private retainedMoveHints( hints: Record, outcomes: WorkspacePushOutcome[] @@ -1013,7 +1046,15 @@ export class WorkspaceService { } if (observation.branch === state.git.branch) { if (observation.head !== state.git.head) { - this.writeState(root, { ...state, git: observation }); + await this.hydrateGitBaseline( + root, + { ...state, git: observation }, + state.activePackageKey, + state.activeBranch, + observation + ); + logger.info(`Reconciled Git branch '${observation.branch}' with ${state.activePackageKey}.`); + return true; } return false; } @@ -1035,21 +1076,59 @@ export class WorkspaceService { } const branch = this.branch(mappedBranch); const packageKey = this.packageKey(projectKey, branch); - const temporary = this.validatedArchive( - await this.api.download(packageKey), - packageKey, - projectKey, - observation - ); - try { - this.reconcileLocalState(root, temporary, packageKey, branch, observation); - } finally { - fs.rmSync(temporary, { recursive: true, force: true }); - } + 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 { + const remote = await this.api.manifest(packageKey); + const localByKey = new Map(this.nodes(root).map(node => [node.key, node])); + remote.manifest.nodes.forEach(entry => { + const local = localByKey.get(entry.nodeKey); + if (local && this.isFolder(local) !== (entry.kind === "folder")) { + throw new GracefulError(`Node metadata type conflicts with the server for ${entry.nodeKey}.`); + } + }); + this.writeState( + root, + this.withRemotePathHints( + root, + new WorkspacePullService(this.api).hydrateBaseline( + { ...state, git: observation }, + packageKey, + branch, + remote.manifest + ), + remote.manifest + ) + ); + } + + private withRemotePathHints(root: string, state: WorkspaceState, manifest: WorkspaceManifest): WorkspaceState { + const remotePathByNodeKey = new Map( + manifest.nodes.filter(entry => entry.kind === "file").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"; diff --git a/tests/commands/workspace/module.spec.ts b/tests/commands/workspace/module.spec.ts index 5e27bffb..01877654 100644 --- a/tests/commands/workspace/module.spec.ts +++ b/tests/commands/workspace/module.spec.ts @@ -14,7 +14,7 @@ describe("Workspace module", () => { expect(configurator.command).toHaveBeenCalledWith("workspace"); expect(configurator.command).toHaveBeenCalledWith("clone [directory]"); expect(configurator.command).toHaveBeenCalledWith("checkout [branch]"); - expect(configurator.command).toHaveBeenCalledWith("pull [directory]"); + expect(configurator.command).toHaveBeenCalledWith("pull [paths...]"); expect(configurator.command).toHaveBeenCalledWith("status [directory]"); expect(configurator.command).toHaveBeenCalledWith("push [paths...]"); expect(configurator.command).toHaveBeenCalledWith("move "); @@ -39,7 +39,8 @@ describe("Workspace module", () => { 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"); + await execute("workspace", "pull", "target", "other.md"); + await execute("workspace", "pull", "--full"); await execute("workspace", "status", "target"); await execute("workspace", "push", "target", "other.md"); await execute("workspace", "move", "old.md", "new.md", "--record"); @@ -55,7 +56,8 @@ describe("Workspace module", () => { discard: false, linkGit: false, }); - expect(pull).toHaveBeenCalledWith("target"); + expect(pull).toHaveBeenNthCalledWith(1, ["target", "other.md"], { full: false }); + expect(pull).toHaveBeenNthCalledWith(2, [], { full: true }); expect(status).toHaveBeenCalledWith("target"); expect(push).toHaveBeenCalledWith(["target", "other.md"], { full: false, overwrite: false }); expect(move).toHaveBeenCalledWith("old.md", "new.md", true); diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index d00a2cab..6aded4bf 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -31,6 +31,10 @@ function fileUrl(filePath: string): string { return `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${PACKAGE_KEY}/files/${filePath}`; } +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; @@ -55,7 +59,10 @@ function metadata(files: TestFile[]): Record { const folderPath = segments.slice(0, index + 1).join("/"); let folderKey = folders.get(folderPath); if (!folderKey) { - folderKey = `folder-${folders.size + 1}`; + folderKey = + folderPath === "Guides" + ? "folder-1" + : `folder-${createHash("sha256").update(folderPath).digest("hex").slice(0, 12)}`; folders.set(folderPath, folderKey); nodes[folderKey] = { key: folderKey, @@ -105,6 +112,51 @@ function archive(files: TestFile[]): Buffer { return zip.toBuffer(); } +function manifest(files: TestFile[]): Buffer { + const nodes = metadata(files) as Record< + string, + { key: string; type: string; parentNodeKey?: string | null; filesystemName: string } + >; + const byKey = new Map(Object.entries(nodes)); + const paths = new Map(); + const resolvePath = (node: { key: string; parentNodeKey?: string | null }): string => { + const cached = paths.get(node.key); + if (cached) { + return cached; + } + const metadataNode = nodes[node.key]; + const parent = metadataNode.parentNodeKey ? byKey.get(metadataNode.parentNodeKey) : undefined; + const filePath = parent ? `${resolvePath(parent)}/${metadataNode.filesystemName}` : metadataNode.filesystemName; + paths.set(node.key, filePath); + return filePath; + }; + return Buffer.from( + JSON.stringify({ + nodes: Object.values(nodes).map(node => { + const file = files.find(candidate => candidate.nodeKey === node.key); + return file + ? { + nodeKey: node.key, + path: resolvePath(node), + kind: "file", + assetType: node.type, + mediaType: "text/markdown", + size: Buffer.byteLength(file.content), + contentDigest: digest(file.content), + eTag: eTag(file.content), + metadata: node, + } + : { nodeKey: node.key, path: resolvePath(node), kind: "folder", metadata: node }; + }), + documents: {}, + }) + ); +} + +function mockManifest(files: TestFile[], packageKey: string = PACKAGE_KEY): void { + mockAxiosGet(manifestUrl(packageKey), manifest(files), { etag: eTag("manifest") }); +} + function writeWorkspace( files: TestFile[] = [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }] ): void { @@ -134,6 +186,9 @@ function removeWorkspace(): void { "Other", "New", "Bulk", + "Added", + "Deleted", + "Old", "Selected", "Unselected", PACKAGE_KEY, @@ -271,11 +326,7 @@ describe("Workspace service", () => { fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "git content"); const observation = { branch: "git-feature", head: "a".repeat(40) }; const git = mockGit(observation); - mockAxiosGet( - archiveUrl(BRANCH_PACKAGE_KEY), - archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }]), - { etag: eTag("branch-revision") } - ); + mockManifest([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }], BRANCH_PACKAGE_KEY); const service = new WorkspaceService(testContext, git); await service.checkout(BRANCH, { linkGit: true }); @@ -298,11 +349,7 @@ describe("Workspace service", () => { ); const observation = { branch: "git-feature", head: "b".repeat(40) }; const git = mockGit(observation, BRANCH); - mockAxiosGet( - archiveUrl(BRANCH_PACKAGE_KEY), - archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]), - { etag: eTag("branch-revision") } - ); + mockManifest([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }], BRANCH_PACKAGE_KEY); const service = new WorkspaceService(testContext, git); await expect(service.statusWithGit()).resolves.toEqual([]); @@ -316,7 +363,7 @@ describe("Workspace service", () => { }); }); - it("preserves move hints when Git advances on the mapped branch", async () => { + it("rehydrates the baseline when Git advances on the mapped branch", 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")); @@ -331,20 +378,21 @@ describe("Workspace service", () => { ); 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" }, + moveHints: {}, git: observation, }); expect(git.mappedPacmanBranch).not.toHaveBeenCalled(); - expect(mockedAxiosInstance.get).not.toHaveBeenCalled(); + expect(mockedAxiosInstance.get).toHaveBeenCalledWith(manifestUrl(), expect.anything()); }); - it("continues pulling remote files after Git advances on the mapped branch", async () => { + it("rehydrates without overwriting files after Git advances on the mapped branch", async () => { writeWorkspace(); const statePath = path.join(process.cwd(), ".pacman", "local", "state.json"); fs.writeFileSync( @@ -355,13 +403,14 @@ describe("Workspace service", () => { }) ); const observation = { branch: "main", head: "b".repeat(40) }; - mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }]), { - etag: eTag("revision-2"), - }); + mockManifest([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }]); await new WorkspaceService(testContext, mockGit(observation)).pull(); - expect(fs.readFileSync(path.join(process.cwd(), "Guides/Guide.md"), "utf-8")).toBe("remote"); + expect(fs.readFileSync(path.join(process.cwd(), "Guides/Guide.md"), "utf-8")).toBe("original"); + expect(new WorkspaceService(testContext, mockGit(observation)).status()).toEqual([ + { path: "Guides/Guide.md", status: "modified" }, + ]); expect(JSON.parse(fs.readFileSync(statePath, "utf-8"))).toMatchObject({ git: observation }); }); @@ -392,14 +441,13 @@ describe("Workspace service", () => { ).rejects.toThrow("is not linked to a Pacman branch"); }); - it("pulls the latest archive into a clean existing workspace", async () => { + 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; - mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }]), { - etag: eTag("revision-2"), - }); + mockManifest([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }]); + mockAxiosGet(fileUrl("Guides/Guide.md"), Buffer.from("remote"), { etag: eTag("remote") }); await new WorkspaceService(testContext).pull(); @@ -410,19 +458,134 @@ describe("Workspace service", () => { expect( JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8")) ).toMatchObject({ - serverRevision: eTag("revision-2"), baselineDigests: { "node-1": digest("remote") }, moveHints: {}, }); + expect( + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8")) + ).not.toHaveProperty("serverRevision"); + }); + + 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("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(), ".pacman/local/state.json"), "utf-8")); + expect(localState.baselineDigests).toEqual({ + "node-1": digest("one"), + "node-2": digest("two remote"), + }); + expect(localState).not.toHaveProperty("serverRevision"); + }); + + it("rejects paths with a full pull", async () => { + writeWorkspace(); + + await expect(new WorkspaceService(testContext).pull(["Guides"], { full: true })).rejects.toThrow( + "Workspace paths cannot be combined with --full" + ); + expect(mockedAxiosInstance.get).not.toHaveBeenCalled(); }); it("hydrates local state after an external Git restore without overwriting files", async () => { writeWorkspace(); fs.rmSync(path.join(process.cwd(), ".pacman", "local"), { recursive: true }); fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "git change"); - mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }]), { - etag: eTag("revision-2"), - }); + mockManifest([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }]); await new WorkspaceService(testContext).pull(); @@ -430,10 +593,7 @@ describe("Workspace service", () => { expect(new WorkspaceService(testContext).status()).toEqual([{ path: "Guides/Guide.md", status: "modified" }]); expect( JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8")) - ).toMatchObject({ - serverRevision: eTag("revision-2"), - baselineDigests: { "node-1": digest("remote") }, - }); + ).toMatchObject({ baselineDigests: { "node-1": digest("remote") } }); }); it("restores the mapped Pacman branch after an external Git restore", async () => { @@ -442,11 +602,7 @@ describe("Workspace service", () => { 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); - mockAxiosGet( - archiveUrl(BRANCH_PACKAGE_KEY), - archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "branch baseline" }]), - { etag: eTag("branch-revision") } - ); + mockManifest([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "branch baseline" }], BRANCH_PACKAGE_KEY); await new WorkspaceService(testContext, git).pull(); @@ -456,7 +612,6 @@ describe("Workspace service", () => { ).toMatchObject({ activePackageKey: BRANCH_PACKAGE_KEY, activeBranch: BRANCH, - serverRevision: eTag("branch-revision"), git: observation, }); }); @@ -465,9 +620,7 @@ describe("Workspace service", () => { writeWorkspace(); fs.rmSync(path.join(process.cwd(), ".pacman", "local"), { recursive: true }); fs.writeFileSync(path.join(process.cwd(), ".pacman", ".gitignore"), "local/\r\n"); - mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]), { - etag: eTag("revision-2"), - }); + mockManifest([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]); const service = new WorkspaceService(testContext); await service.pull(); @@ -478,9 +631,7 @@ describe("Workspace service", () => { 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(), ".pacman", "local"), { recursive: true }); - mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]), { - etag: eTag("revision-2"), - }); + mockManifest([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]); const service = new WorkspaceService(testContext); await service.pull(); @@ -501,9 +652,7 @@ describe("Workspace service", () => { assetType: "MARKDOWN_FILE", eTag: eTag("file-2"), }); - mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "original" }]), { - etag: eTag("revision-3"), - }); + mockManifest([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "original" }]); await service.push(); expect(mockedAxiosInstance.patch).toHaveBeenCalledWith( @@ -514,12 +663,13 @@ describe("Workspace service", () => { expect(service.status()).toEqual([]); }); - it("refuses to pull over local changes", async () => { + it("preserves local-only changes during an incremental pull", async () => { writeWorkspace(); fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "local"); - await expect(new WorkspaceService(testContext).pull()).rejects.toThrow("Workspace has local changes"); - expect(mockedAxiosInstance.get).not.toHaveBeenCalled(); + 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"); }); @@ -538,7 +688,7 @@ describe("Workspace service", () => { }); try { - await expect(new WorkspaceService(testContext).pull()).rejects.toThrow("apply failed"); + await expect(new WorkspaceService(testContext).pull([], { full: true })).rejects.toThrow("apply failed"); } finally { rename.mockRestore(); } @@ -562,7 +712,7 @@ describe("Workspace service", () => { let backup: string | undefined; try { - await new WorkspaceService(testContext).pull(); + await new WorkspaceService(testContext).pull([], { full: true }); } catch (error) { expect(error).toBeInstanceOf(Error); const match = (error as Error).message.match(/backup remains at (.+)\.$/); @@ -869,7 +1019,7 @@ describe("Workspace service", () => { ...file, content: changedIndexes.has(index) ? `changed-${index}` : file.content, })); - mockAxiosGet(ARCHIVE_URL, archive(remote), { etag: eTag("revision-2") }); + mockManifest(remote); await new WorkspaceService(testContext).push(); @@ -877,6 +1027,9 @@ describe("Workspace service", () => { 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(), ".pacman/local/state.json"), "utf-8")) + ).not.toHaveProperty("serverRevision"); }); it("limits a path-selected push and does not treat omitted paths as deletions", async () => { @@ -896,9 +1049,7 @@ describe("Workspace service", () => { assetType: "MARKDOWN_FILE", eTag: eTag("one changed"), }); - mockAxiosGet(ARCHIVE_URL, archive([{ ...original[0], content: "one changed" }, original[1], original[2]]), { - etag: eTag("revision-2"), - }); + mockManifest([{ ...original[0], content: "one changed" }, original[1], original[2]]); await new WorkspaceService(testContext).push(["Selected/One.md"]); @@ -926,9 +1077,7 @@ describe("Workspace service", () => { assetType: "MARKDOWN_FILE", eTag: eTag("one-moved"), }); - mockAxiosGet(ARCHIVE_URL, archive([{ ...original[0], path: "Pages/One.md" }, original[1]]), { - etag: eTag("revision-2"), - }); + mockManifest([{ ...original[0], path: "Pages/One.md" }, original[1]]); await service.push(["Pages/One.md"]); @@ -955,12 +1104,8 @@ describe("Workspace service", () => { eTag: eTag(`${file.nodeKey}-changed`), }); }); - mockAxiosGet( - ARCHIVE_URL, - archive( - original.map((file, index) => (index < 2 ? { ...file, content: `${file.content} changed` } : file)) - ), - { etag: eTag("revision-2") } + mockManifest( + original.map((file, index) => (index < 2 ? { ...file, content: `${file.content} changed` } : file)) ); await new WorkspaceService(testContext).push(["selected"]); @@ -983,7 +1128,7 @@ describe("Workspace service", () => { assetType: "MARKDOWN_FILE", eTag: eTag("new"), }); - mockAxiosGet(ARCHIVE_URL, archive([remote]), { etag: eTag("revision-2") }); + mockManifest([remote]); await new WorkspaceService(testContext).push(); @@ -1027,9 +1172,7 @@ describe("Workspace service", () => { eTag: eTag("one changed"), }); mockAxiosPutError(fileUrl("Guides/Two.md"), 412, { message: "stale" }); - mockAxiosGet(ARCHIVE_URL, archive([{ ...original[0], content: "one changed" }, original[1]]), { - etag: eTag("revision-2"), - }); + mockManifest([{ ...original[0], content: "one changed" }, original[1]]); await expect(new WorkspaceService(testContext).push()).rejects.toThrow("Workspace push failed for 1 file(s)"); @@ -1037,7 +1180,6 @@ describe("Workspace service", () => { expect( JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman/local/state.json"), "utf-8")) ).toMatchObject({ - serverRevision: eTag("revision-2"), baselineDigests: { "node-1": digest("one changed"), "node-2": digest("two") }, }); }); @@ -1047,7 +1189,7 @@ describe("Workspace service", () => { 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")); - mockAxiosGet(ARCHIVE_URL, archive([]), { etag: eTag("revision-2") }); + mockManifest([]); await new WorkspaceService(testContext).push(); @@ -1078,9 +1220,7 @@ describe("Workspace service", () => { assetType: "MARKDOWN_FILE", eTag: eTag("file-3"), }); - mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "changed" }]), { - etag: eTag("revision-2"), - }); + mockManifest([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "changed" }]); await service.push(); @@ -1122,14 +1262,10 @@ describe("Workspace service", () => { assetType: "MARKDOWN_FILE", eTag: eTag("file-3"), }); - mockAxiosGet( - ARCHIVE_URL, - archive([ - { nodeKey: "node-1", path: "Pages/Guide.md", content: "original" }, - { nodeKey: "node-2", path: "Guides/Guide.md", content: "replacement" }, - ]), - { etag: eTag("revision-2") } - ); + mockManifest([ + { nodeKey: "node-1", path: "Pages/Guide.md", content: "original" }, + { nodeKey: "node-2", path: "Guides/Guide.md", content: "replacement" }, + ]); await service.push(); @@ -1152,9 +1288,7 @@ describe("Workspace service", () => { eTag: eTag("file-2"), }); mockAxiosPutError(fileUrl("Pages/Guide.md"), 412, { message: "stale" }); - mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "original" }]), { - etag: eTag("revision-2"), - }); + mockManifest([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "original" }]); await expect(service.push()).rejects.toThrow("Workspace push failed for 1 file(s)"); @@ -1162,7 +1296,6 @@ describe("Workspace service", () => { expect( JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman/local/state.json"), "utf-8")) ).toMatchObject({ - serverRevision: eTag("revision-2"), baselineDigests: { "node-1": digest("original") }, moveHints: {}, }); @@ -1242,7 +1375,7 @@ describe("Workspace service", () => { }); }); - it("replaces the workspace on pull when post-push refresh fails", async () => { + it("replaces the workspace on full pull when post-push refresh fails", 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")); @@ -1261,7 +1394,7 @@ describe("Workspace service", () => { mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "original" }]), { etag: eTag("revision-2"), }); - await service.pull(); + await service.pull([], { full: true }); expect(service.status()).toEqual([]); expect(fs.existsSync(path.join(process.cwd(), "Guides", "Guide.md"))).toBe(false); expect(fs.readFileSync(path.join(process.cwd(), "Pages", "Guide.md"), "utf-8")).toBe("original"); @@ -1280,16 +1413,14 @@ describe("Workspace service", () => { assetType: "MARKDOWN_FILE", eTag: eTag("file-2"), }); - mockAxiosGetError(ARCHIVE_URL, 503, { message: "unavailable" }); + 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(), ".pacman", "local", "state.json"), "utf-8")) ).toMatchObject({ refreshRequired: true, moveHints: {} }); - mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "original" }]), { - etag: eTag("revision-2"), - }); + mockManifest([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "original" }]); await service.pull(); expect(service.status()).toEqual([]); From b9e0daa8b73180a3eab981ca88090501a3f4711e Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Thu, 20 Aug 2026 12:20:29 +0200 Subject: [PATCH 28/46] Clarify empty path-selected pushes Includes-AI-Code: true --- src/commands/workspace/workspace.service.ts | 2 +- .../workspace/workspace.service.spec.ts | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index d8b74d34..7beb8b7c 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -279,7 +279,7 @@ export class WorkspaceService { if (failed.length > 0) { throw new GracefulError(`Workspace push failed for ${failed.length} file(s).`); } - logger.info("Workspace is clean."); + logger.info(paths.length > 0 ? "Selected paths have no changes." : "Workspace is clean."); return; } try { diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index 6aded4bf..2d790a1c 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -5,6 +5,7 @@ import AdmZip = require("adm-zip"); import { WorkspaceService } from "../../../src/commands/workspace/workspace.service"; import { WorkspaceGitService } from "../../../src/commands/workspace/workspace-git.service"; import { fileService } from "../../../src/core/utils/file-service"; +import { logger } from "../../../src/core/utils/logger"; import { testContext } from "../../utls/test-context"; import { mockAxiosDelete, @@ -1061,6 +1062,25 @@ describe("Workspace service", () => { ]); }); + 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" }, From cd37475af832d9aea0e501a70c7a9dc348e88e07 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Thu, 20 Aug 2026 12:24:31 +0200 Subject: [PATCH 29/46] Resolve workspace static analysis findings Includes-AI-Code: true --- .../workspace/workspace-pull.service.ts | 54 ++++++++++--------- .../workspace/workspace-push.service.ts | 18 ++++++- src/commands/workspace/workspace.service.ts | 1 - 3 files changed, 45 insertions(+), 28 deletions(-) diff --git a/src/commands/workspace/workspace-pull.service.ts b/src/commands/workspace/workspace-pull.service.ts index 3f155fe6..3cab2cb9 100644 --- a/src/commands/workspace/workspace-pull.service.ts +++ b/src/commands/workspace/workspace-pull.service.ts @@ -306,36 +306,41 @@ export class WorkspacePullService { const source = operation.localPath ? this.resolve(root, operation.localPath) : undefined; const moved = Boolean(source && source.toLowerCase() !== target.toLowerCase()); if (moved && fs.existsSync(target)) { - if ( - source && - !fs.existsSync(source) && - fs.lstatSync(target).isFile() && - this.digest(target) === entry.contentDigest - ) { - return; + if (!this.movedTargetMatches(source, target, entry)) { + throw new GracefulError(`Remote file move conflicts with an existing local path: ${entry.path}`); } - throw new GracefulError(`Remote file move conflicts with an existing local path: ${entry.path}`); + return; } - const sourceDigest = - source && fs.existsSync(source) && fs.lstatSync(source).isFile() ? this.digest(source) : undefined; - const bodyChanged = sourceDigest !== entry.contentDigest; fs.mkdirSync(path.dirname(target), { recursive: true }); - if (bodyChanged) { - const remote = await this.api.readFile(packageKey, entry.path); - if (this.digestBuffer(remote.body) !== entry.contentDigest || remote.body.length !== entry.size) { - throw new GracefulError(`Remote file body does not match its manifest: ${entry.path}`); - } - fs.writeFileSync(target, remote.body); - if (moved && source && fs.existsSync(source)) { - fs.rmSync(source); + if (this.localFileDigest(source) === entry.contentDigest) { + if (moved && source) { + fs.renameSync(source, target); } return; } - if (moved && source) { - fs.renameSync(source, target); + const remote = await this.api.readFile(packageKey, entry.path); + if (this.digestBuffer(remote.body) !== entry.contentDigest || remote.body.length !== entry.size) { + throw new GracefulError(`Remote file body does not match its manifest: ${entry.path}`); + } + fs.writeFileSync(target, remote.body); + if (moved && source && fs.existsSync(source)) { + fs.rmSync(source); } } + private movedTargetMatches(source: string | undefined, target: string, entry: WorkspaceManifestNode): boolean { + return Boolean( + source && + !fs.existsSync(source) && + fs.lstatSync(target).isFile() && + this.digest(target) === entry.contentDigest + ); + } + + private localFileDigest(source: string | undefined): string | undefined { + return source && fs.existsSync(source) && fs.lstatSync(source).isFile() ? this.digest(source) : undefined; + } + private applyFolder(root: string, operation: PullOperation, entry: WorkspaceManifestNode): void { const target = this.resolve(root, entry.path); const source = operation.localPath ? this.resolve(root, operation.localPath) : undefined; @@ -436,7 +441,7 @@ export class WorkspacePullService { const parentKey = entry.metadata.parentNodeKey; if (parentKey) { const parent = manifest.nodes.find(candidate => candidate.nodeKey === parentKey); - if (!parent || parent.kind !== "folder") { + if (parent?.kind !== "folder") { throw new GracefulError(`Workspace manifest has an invalid parent for node ${entry.nodeKey}.`); } this.writeMetadataWithAncestors(root, parent, manifest, visited); @@ -497,8 +502,7 @@ export class WorkspacePullService { if ( !entry.nodeKey || !this.validManifestPath(entry.path) || - !entry.metadata || - entry.metadata.key !== entry.nodeKey || + entry.metadata?.key !== entry.nodeKey || FORBIDDEN_METADATA_FIELDS.some(field => field in metadata) || "body" in (entry as unknown as Record) || (entry.kind !== "file" && entry.kind !== "folder") || @@ -531,7 +535,7 @@ export class WorkspacePullService { const parentKey = entry.metadata.parentNodeKey; if (parentKey) { const parent = byKey.get(parentKey); - if (!parent || parent.kind !== "folder") { + if (parent?.kind !== "folder") { throw new GracefulError("Workspace manifest contains an invalid parent relationship."); } resolve(parent, resolving); diff --git a/src/commands/workspace/workspace-push.service.ts b/src/commands/workspace/workspace-push.service.ts index 215c72b3..a25f7f3a 100644 --- a/src/commands/workspace/workspace-push.service.ts +++ b/src/commands/workspace/workspace-push.service.ts @@ -15,8 +15,22 @@ 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(error instanceof Error ? error.message : String(error)), { remoteChanged }); + return Object.assign(new Error(errorMessage(error)), { remoteChanged }); } function isOperationError(error: unknown): error is WorkspacePushOperationError { @@ -44,7 +58,7 @@ export class WorkspacePushService { ...change, success: false, remoteChanged: isOperationError(error) && error.remoteChanged, - error: error instanceof Error ? error.message : String(error), + error: errorMessage(error), }); } } diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index 7beb8b7c..de90cc46 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -264,7 +264,6 @@ export class WorkspaceService { (outcome.error ? ` (${outcome.error})` : "") ) ); - const succeeded = outcomes.filter(outcome => outcome.success); const failed = outcomes.filter(outcome => !outcome.success); const remoteChanged = outcomes.some(outcome => outcome.remoteChanged); const retainedHints = this.retainedMoveHints(snapshot.state.moveHints, outcomes); From 9bd53f8090113ee87146fa5459e0a253ad487915 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Thu, 20 Aug 2026 12:44:22 +0200 Subject: [PATCH 30/46] Derive workspace paths from node metadata Includes-AI-Code: true --- src/commands/workspace/module.ts | 2 +- .../workspace/workspace-change-classifier.ts | 20 +- .../workspace/workspace-path-projector.ts | 179 ++++++++++++++++++ .../workspace/workspace-pull.service.ts | 45 ++--- .../workspace/workspace-push.service.ts | 8 + src/commands/workspace/workspace.models.ts | 1 - src/commands/workspace/workspace.service.ts | 72 +++---- .../workspace-change-classifier.spec.ts | 14 +- .../workspace-path-projector.spec.ts | 53 ++++++ .../workspace/workspace.service.spec.ts | 78 +++++++- 10 files changed, 392 insertions(+), 80 deletions(-) create mode 100644 src/commands/workspace/workspace-path-projector.ts create mode 100644 tests/commands/workspace/workspace-path-projector.spec.ts diff --git a/src/commands/workspace/module.ts b/src/commands/workspace/module.ts index 693674a2..f8348c34 100644 --- a/src/commands/workspace/module.ts +++ b/src/commands/workspace/module.ts @@ -43,7 +43,7 @@ class Module extends IModule { workspace .command("move ") .beta() - .description("Move a tracked file.") + .description("Move a tracked file to another parent.") .option("--record", "Record an existing move", false) .action(this.move); } diff --git a/src/commands/workspace/workspace-change-classifier.ts b/src/commands/workspace/workspace-change-classifier.ts index 3533927e..7d59ec72 100644 --- a/src/commands/workspace/workspace-change-classifier.ts +++ b/src/commands/workspace/workspace-change-classifier.ts @@ -63,7 +63,11 @@ function classifyExpectedFile( consumedPaths.add(currentPath); const changed = visibleFiles.get(currentPath) !== file.digest; if (currentPath !== file.path) { - changes.push({ nodeKey: file.nodeKey, path: currentPath, status: changed ? "moved, modified" : "moved" }); + changes.push({ + nodeKey: file.nodeKey, + path: currentPath, + status: sameLeaf(file.path, currentPath) ? (changed ? "moved, modified" : "moved") : "unresolved", + }); } else if (changed) { changes.push({ nodeKey: file.nodeKey, path: currentPath, status: "modified" }); } @@ -84,7 +88,7 @@ function classifyNewFile( 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 || !targetPath || consumedPaths.has(targetPath)) { + if (sourceStillPresent || !sameLeaf(file.path, target) || !targetPath || consumedPaths.has(targetPath)) { changes.push({ nodeKey: file.nodeKey, path: target, status: "unresolved" }); if (targetPath) { consumedPaths.add(targetPath); @@ -108,7 +112,7 @@ function classifyHintedFile( ): void { const targetPath = visiblePathIndex.get(hint.toLowerCase()); const sourcePath = visiblePathIndex.get(file.path.toLowerCase()); - if (!targetPath || consumedPaths.has(targetPath)) { + if (!sameLeaf(file.path, hint) || !targetPath || consumedPaths.has(targetPath)) { changes.push({ nodeKey: file.nodeKey, path: hint, status: "unresolved" }); if (targetPath) { consumedPaths.add(targetPath); @@ -141,7 +145,11 @@ function resolveDigestMoves( .map(([filePath]) => filePath); if (files.length === 1 && candidates.length === 1) { consumedPaths.add(candidates[0]); - changes.push({ nodeKey: files[0].nodeKey, path: candidates[0], status: "moved" }); + 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; } @@ -181,6 +189,10 @@ function resolveMovedAndEdited( }); } +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 => { diff --git a/src/commands/workspace/workspace-path-projector.ts b/src/commands/workspace/workspace-path-projector.ts new file mode 100644 index 00000000..bb781507 --- /dev/null +++ b/src/commands/workspace/workspace-path-projector.ts @@ -0,0 +1,179 @@ +import { createHash } from "node:crypto"; +import * as path from "node:path"; +import { GracefulError } from "../../core/utils/logger"; +import { WorkspaceNodeMetadata } from "./workspace.models"; + +const ROOT = "\u0000root"; + +export function projectWorkspacePaths(nodes: WorkspaceNodeMetadata[], packageKey?: string): Map { + const byKey = new Map(); + nodes.forEach(node => { + if (byKey.has(node.key)) { + throw new GracefulError(`Duplicate node metadata key: ${node.key}.`); + } + byKey.set(node.key, node); + }); + const candidates = new Map(nodes.map(node => [node.key, candidateSegment(node)])); + const projected = projectedSegments(nodes, candidates, packageKey); + const paths = new Map(); + const resolving = new Set(); + const resolve = (node: WorkspaceNodeMetadata): string => { + const cached = paths.get(node.key); + if (cached) { + return cached; + } + if (resolving.has(node.key)) { + throw new GracefulError(`Circular node hierarchy at ${node.key}.`); + } + resolving.add(node.key); + 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.key}.`); + } + const segment = projected.get(node.key)!; + const value = parent ? `${resolve(parent)}/${segment}` : segment; + validateVisiblePath(value); + resolving.delete(node.key); + paths.set(node.key, value); + return value; + }; + nodes.forEach(resolve); + return paths; +} + +export function projectedLeafAfterMove( + nodes: WorkspaceNodeMetadata[], + nodeKey: string, + targetParentKey: string | undefined, + packageKey?: string +): string { + const source = nodes.find(node => node.key === nodeKey); + if (!source) { + throw new GracefulError(`Tracked node metadata is missing: ${nodeKey}.`); + } + const moved = nodes.map(node => + node.key === nodeKey ? { ...node, parentNodeKey: targetParentKey || null } : node + ); + const candidates = new Map(moved.map(node => [node.key, candidateSegment(node)])); + return projectedSegments(moved, candidates, packageKey).get(nodeKey)!; +} + +function projectedSegments( + nodes: WorkspaceNodeMetadata[], + 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.key)!.toLowerCase()).forEach(group => { + if (group.length > 1) { + disambiguate(group, siblings, candidates, projected); + } + }); + }); + return projected; +} + +function disambiguate( + group: WorkspaceNodeMetadata[], + siblings: WorkspaceNodeMetadata[], + candidates: Map, + projected: Map +): void { + const hashes = new Map(group.map(node => [node.key, sha256(node.key)])); + const groupKeys = new Set(group.map(node => node.key)); + const occupied = new Set( + siblings.filter(node => !groupKeys.has(node.key)).map(node => candidates.get(node.key)!.toLowerCase()) + ); + let length = 12; + while (length < 64) { + const prefixes = new Set(); + const uniquePrefixes = group.every(node => prefixes.add(hashes.get(node.key)!.slice(0, length))); + const available = group.every( + node => + !occupied.has( + addSuffix( + candidates.get(node.key)!, + isFolder(node), + hashes.get(node.key)!.slice(0, length) + ).toLowerCase() + ) + ); + if (uniquePrefixes && available) { + break; + } + length = Math.min(64, length + 4); + } + group.forEach(node => + projected.set( + node.key, + addSuffix(candidates.get(node.key)!, isFolder(node), hashes.get(node.key)!.slice(0, length)) + ) + ); +} + +function candidateSegment(node: WorkspaceNodeMetadata): string { + const extension = isFolder(node) ? "" : `.${fileExtension(node.type)}`; + const segment = `${node.name}${extension}`; + if ( + !segment || + segment === "." || + segment === ".." || + segment.includes("/") || + segment.includes("\\") || + [...segment].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127) + ) { + throw new GracefulError(`Invalid derived filesystem name for node ${node.key}.`); + } + return segment; +} + +function fileExtension(assetType: string): string { + switch (assetType.toUpperCase()) { + 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 === ".pacman" || 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: WorkspaceNodeMetadata): 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-pull.service.ts b/src/commands/workspace/workspace-pull.service.ts index 3cab2cb9..57f0444b 100644 --- a/src/commands/workspace/workspace-pull.service.ts +++ b/src/commands/workspace/workspace-pull.service.ts @@ -4,6 +4,7 @@ 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, @@ -25,6 +26,7 @@ const FORBIDDEN_METADATA_FIELDS = [ "changeDate", "revision", "serverRevision", + "filesystemName", ]; interface PullOperation { @@ -467,27 +469,7 @@ export class WorkspacePullService { } private localPaths(nodes: WorkspaceNodeMetadata[]): Map { - const byKey = new Map(nodes.map(node => [node.key, node])); - const paths = new Map(); - const resolving = new Set(); - const resolve = (node: WorkspaceNodeMetadata): string => { - const cached = paths.get(node.key); - if (cached) { - return cached; - } - if (resolving.has(node.key)) { - throw new GracefulError(`Circular node hierarchy at ${node.key}.`); - } - resolving.add(node.key); - const segment = this.filesystemName(node); - const parent = node.parentNodeKey ? byKey.get(node.parentNodeKey) : undefined; - const value = parent ? `${resolve(parent)}/${segment}` : segment; - resolving.delete(node.key); - paths.set(node.key, value); - return value; - }; - nodes.forEach(resolve); - return paths; + return projectWorkspacePaths(nodes); } private validateManifest(manifest: WorkspaceManifest): void { @@ -504,6 +486,7 @@ export class WorkspacePullService { !this.validManifestPath(entry.path) || entry.metadata?.key !== entry.nodeKey || FORBIDDEN_METADATA_FIELDS.some(field => field in metadata) || + this.hasLegacyFilesystemName(entry.metadata) || "body" in (entry as unknown as Record) || (entry.kind !== "file" && entry.kind !== "folder") || this.isFolder(entry.metadata) !== (entry.kind === "folder") || @@ -529,9 +512,10 @@ export class WorkspacePullService { if (resolved.has(entry.nodeKey)) { return; } - if (!resolving.add(entry.nodeKey)) { + 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); @@ -547,6 +531,10 @@ export class WorkspacePullService { resolve(entry, new Set()); this.validateDocument(entry, manifest); }); + const projectedPaths = projectWorkspacePaths(manifest.nodes.map(entry => 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."); + } if (!Object.values(manifest.documents).every(value => typeof value === "string")) { throw new GracefulError("Unsupported workspace manifest."); } @@ -600,12 +588,13 @@ export class WorkspacePullService { return value; } - private filesystemName(node: WorkspaceNodeMetadata): string { - const value = node.filesystemName || node.metadata?.filesystemName || node.additionalFields?.filesystemName; - if (typeof value !== "string" || !value || value.includes("/") || value.includes("\\")) { - throw new GracefulError(`Invalid filesystem name for node ${node.key}.`); - } - 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 { diff --git a/src/commands/workspace/workspace-push.service.ts b/src/commands/workspace/workspace-push.service.ts index a25f7f3a..63d9c869 100644 --- a/src/commands/workspace/workspace-push.service.ts +++ b/src/commands/workspace/workspace-push.service.ts @@ -129,11 +129,13 @@ export class WorkspacePushService { } 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) { @@ -179,6 +181,12 @@ export class WorkspacePushService { 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()) { diff --git a/src/commands/workspace/workspace.models.ts b/src/commands/workspace/workspace.models.ts index 56d1ab0b..9fd9a65f 100644 --- a/src/commands/workspace/workspace.models.ts +++ b/src/commands/workspace/workspace.models.ts @@ -45,7 +45,6 @@ export interface WorkspaceNodeMetadata { name: string; type: string; parentNodeKey?: string | null; - filesystemName?: string; schemaVersion?: number; serializedDocumentRef?: string; dependenciesConfiguration?: unknown; diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index de90cc46..0691b69e 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -10,6 +10,7 @@ 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 { @@ -54,6 +55,7 @@ const NON_SEMANTIC_NODE_FIELDS = [ "lastModified", "lastModifiedAt", "lastModifiedBy", + "filesystemName", ]; export { WorkspaceChange } from "./workspace.models"; @@ -377,6 +379,7 @@ export class WorkspaceService { 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) { @@ -425,30 +428,7 @@ export class WorkspaceService { private expectedFiles(root: string, state: WorkspaceState, packageKey: string): ExpectedWorkspaceFile[] { const nodes = this.nodes(root); const byKey = new Map(nodes.map(node => [node.key, node])); - const pathByKey = new Map(); - const resolving = new Set(); - const resolvePath = (node: WorkspaceNodeMetadata): string => { - const cached = pathByKey.get(node.key); - if (cached) { - return cached; - } - if (resolving.has(node.key)) { - throw new GracefulError(`Circular node hierarchy at ${node.key}.`); - } - resolving.add(node.key); - const segment = this.filesystemName(node); - let filePath = segment; - if (node.parentNodeKey && node.parentNodeKey !== packageKey) { - const parent = byKey.get(node.parentNodeKey); - if (!parent || !this.isFolder(parent)) { - throw new GracefulError(`Invalid parent metadata for node ${node.key}.`); - } - filePath = `${resolvePath(parent)}/${segment}`; - } - resolving.delete(node.key); - pathByKey.set(node.key, filePath); - return filePath; - }; + const pathByKey = projectWorkspacePaths(nodes, packageKey); const expected = nodes .filter(node => !this.isFolder(node)) .map(node => { @@ -456,7 +436,7 @@ export class WorkspaceService { if (baseline && !/^sha256:[0-9a-f]{64}$/.test(baseline)) { throw new GracefulError(`Invalid baseline digest for node ${node.key}.`); } - const metadataPath = resolvePath(node); + const metadataPath = pathByKey.get(node.key)!; const hint = state.moveHints[node.key]; const sourcePath = this.isStructuredMoveHint(hint) ? this.validateRelative(hint.sourcePath) @@ -506,7 +486,8 @@ export class WorkspaceService { !node.name || !node.type || `${node.key}.json` !== entry.name || - NON_SEMANTIC_NODE_FIELDS.some(field => field in fields) + NON_SEMANTIC_NODE_FIELDS.some(field => field in fields) || + this.hasLegacyFilesystemName(node) ) { throw new GracefulError(`Invalid node metadata file: ${entry.name}`); } @@ -514,14 +495,6 @@ export class WorkspaceService { }); } - private filesystemName(node: WorkspaceNodeMetadata): string { - const value = node.filesystemName || node.metadata?.filesystemName || node.additionalFields?.filesystemName; - if (typeof value !== "string" || !value || value.includes("/") || value.includes("\\")) { - throw new GracefulError(`Invalid filesystem name for node ${node.key}.`); - } - return this.validateRelative(value); - } - private visibleFiles(root: string): Map { const files = new Map(); const foldedPaths = new Set(); @@ -581,6 +554,37 @@ export class WorkspaceService { 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.key) === targetParentPath); + const targetParentKey = targetParentPath + ? targetParent?.key || `__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 refreshMetadata( root: string, download: { archive: Buffer; eTag: string }, diff --git a/tests/commands/workspace/workspace-change-classifier.spec.ts b/tests/commands/workspace/workspace-change-classifier.spec.ts index 1260364c..400b4fca 100644 --- a/tests/commands/workspace/workspace-change-classifier.spec.ts +++ b/tests/commands/workspace/workspace-change-classifier.spec.ts @@ -21,6 +21,16 @@ describe("Workspace change classifier", () => { ).toEqual([{ nodeKey: "node-1", path: "Pages/Guide.md", status: "moved" }]); }); + it("keeps a uniquely matching filename rename unresolved", () => { + expect( + classifyWorkspaceChanges( + [{ nodeKey: "node-1", path: "Guides/Guide.md", digest: "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( @@ -41,14 +51,14 @@ describe("Workspace change classifier", () => { ).toEqual([{ nodeKey: "node-1", path: "Pages/Guide.md", status: "moved" }]); }); - it("uses a recorded case-only move on a case-insensitive path", () => { + it("keeps a recorded case-only filename rename unresolved", () => { expect( classifyWorkspaceChanges( [{ nodeKey: "node-1", path: "Guides/Guide.md", digest: "sha256:one" }], new Map([["Guides/Guide.md", "sha256:one"]]), { "node-1": "Guides/guide.md" } ) - ).toEqual([{ nodeKey: "node-1", path: "Guides/guide.md", status: "moved" }]); + ).toEqual([{ nodeKey: "node-1", path: "Guides/guide.md", status: "unresolved" }]); }); it("keeps a distinct deletion and addition separate", () => { 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..b794622f --- /dev/null +++ b/tests/commands/workspace/workspace-path-projector.spec.ts @@ -0,0 +1,53 @@ +import { createHash } from "node:crypto"; +import { + projectedLeafAfterMove, + projectWorkspacePaths, +} from "../../../src/commands/workspace/workspace-path-projector"; +import { WorkspaceNodeMetadata } from "../../../src/commands/workspace/workspace.models"; + +describe("Workspace path projector", () => { + it("derives registered and fallback extensions from Asset Type", () => { + const nodes: WorkspaceNodeMetadata[] = [ + { key: "folder", name: "Guides", type: "FOLDER" }, + { key: "markdown", name: "Guide", type: "MARKDOWN_FILE", parentNodeKey: "folder" }, + { key: "html", name: "Landing", type: "HTML_CANVAS" }, + { key: "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("adds stable Node-key suffixes to every colliding sibling", () => { + const nodes: WorkspaceNodeMetadata[] = [ + { key: "folder", name: "Guides", type: "FOLDER" }, + { key: "node-1", name: "Guide", type: "MARKDOWN_FILE", parentNodeKey: "folder" }, + { key: "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: WorkspaceNodeMetadata[] = [ + { key: "guides", name: "Guides", type: "FOLDER" }, + { key: "pages", name: "Pages", type: "FOLDER" }, + { key: "source", name: "Guide", type: "MARKDOWN_FILE", parentNodeKey: "guides" }, + { key: "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 index 2d790a1c..009d893b 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -70,7 +70,6 @@ function metadata(files: TestFile[]): Record { name: segments[index], type: "FOLDER", parentNodeKey, - filesystemName: segments[index], }; } parentNodeKey = folderKey; @@ -80,7 +79,6 @@ function metadata(files: TestFile[]): Record { name: path.posix.basename(file.path, path.posix.extname(file.path)), type: "MARKDOWN_FILE", parentNodeKey, - filesystemName: segments[segments.length - 1], }; }); return nodes; @@ -116,7 +114,7 @@ function archive(files: TestFile[]): Buffer { function manifest(files: TestFile[]): Buffer { const nodes = metadata(files) as Record< string, - { key: string; type: string; parentNodeKey?: string | null; filesystemName: string } + { key: string; name: string; type: string; parentNodeKey?: string | null } >; const byKey = new Map(Object.entries(nodes)); const paths = new Map(); @@ -127,7 +125,8 @@ function manifest(files: TestFile[]): Buffer { } const metadataNode = nodes[node.key]; const parent = metadataNode.parentNodeKey ? byKey.get(metadataNode.parentNodeKey) : undefined; - const filePath = parent ? `${resolvePath(parent)}/${metadataNode.filesystemName}` : metadataNode.filesystemName; + const segment = `${metadataNode.name}${metadataNode.type === "FOLDER" ? "" : ".md"}`; + const filePath = parent ? `${resolvePath(parent)}/${segment}` : segment; paths.set(node.key, filePath); return filePath; }; @@ -674,6 +673,32 @@ describe("Workspace service", () => { 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 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("restores the existing workspace when applying a pull fails", async () => { writeWorkspace(); mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }]), { @@ -821,6 +846,24 @@ describe("Workspace service", () => { 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"); @@ -902,17 +945,31 @@ describe("Workspace service", () => { expect(() => new WorkspaceService(testContext).status()).toThrow("Invalid node metadata file"); }); - it("rejects duplicate case-insensitive paths derived from node metadata", () => { + 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(), ".pacman", "nodes", "node-2.json"); const second = JSON.parse(fs.readFileSync(secondPath, "utf-8")); - second.filesystemName = "one.md"; + 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()).toThrow("Duplicate workspace path"); + expect(new WorkspaceService(testContext).status()).toEqual([]); + }); + + it("rejects legacy filesystem names in stable Node metadata", () => { + writeWorkspace(); + const nodePath = path.join(process.cwd(), ".pacman", "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", () => { @@ -944,7 +1001,7 @@ describe("Workspace service", () => { } }); - it("supports a case-only move when the target resolves to the source file", () => { + 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"); @@ -959,8 +1016,9 @@ describe("Workspace service", () => { try { const service = new WorkspaceService(testContext); - service.move("Guides/Guide.md", "Guides/guide.md"); - expect(service.status()).toEqual([{ path: "Guides/guide.md", status: "moved" }]); + expect(() => service.move("Guides/Guide.md", "Guides/guide.md")).toThrow( + "Filename-only rename is not supported" + ); } finally { lstat.mockRestore(); exists.mockRestore(); From ad805a1f4668d54b3c60b4a87a2243a69cfbf6cd Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Thu, 20 Aug 2026 12:54:39 +0200 Subject: [PATCH 31/46] Harden workspace synchronization recovery Includes-AI-Code: true --- .../workspace/workspace-pull.service.ts | 68 +++++++++++++++++-- src/commands/workspace/workspace.service.ts | 23 +++++-- .../workspace/workspace.service.spec.ts | 42 ++++++++++++ 3 files changed, 120 insertions(+), 13 deletions(-) diff --git a/src/commands/workspace/workspace-pull.service.ts b/src/commands/workspace/workspace-pull.service.ts index 57f0444b..ec6759ed 100644 --- a/src/commands/workspace/workspace-pull.service.ts +++ b/src/commands/workspace/workspace-pull.service.ts @@ -37,6 +37,7 @@ interface PullOperation { entry?: WorkspaceManifestNode; localNode?: WorkspaceNodeMetadata; localChange?: ClassifiedWorkspaceChange; + replacedNodeKey?: string; conflict?: string; converged?: boolean; } @@ -54,10 +55,15 @@ export class WorkspacePullService { snapshot: WorkspaceSnapshot, localNodes: WorkspaceNodeMetadata[], paths: string[], - manifest: WorkspaceManifest + manifest: WorkspaceManifest, + recoveringCreateKeys: boolean = false ): Promise { this.validateManifest(manifest); - const operations = this.select(root, paths, this.operations(snapshot, localNodes, manifest)); + const operations = this.select( + root, + paths, + this.operations(snapshot, localNodes, manifest, recoveringCreateKeys) + ); const state: WorkspaceState = { ...snapshot.state, baselineDigests: { ...snapshot.state.baselineDigests }, @@ -166,15 +172,20 @@ export class WorkspacePullService { private operations( snapshot: WorkspaceSnapshot, localNodes: WorkspaceNodeMetadata[], - manifest: WorkspaceManifest + manifest: WorkspaceManifest, + recoveringCreateKeys: boolean ): PullOperation[] { const localByKey = new Map(localNodes.map(node => [node.key, node])); const localPaths = this.localPaths(localNodes); + 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 operations: PullOperation[] = []; manifest.nodes.forEach(entry => { const localNode = localByKey.get(entry.nodeKey); @@ -182,6 +193,33 @@ export class WorkspacePullService { const expected = expectedByKey.get(entry.nodeKey); const localChange = changeByKey.get(entry.nodeKey); if (!localNode) { + const provisional = recoveringCreateKeys ? localByPath.get(entry.path.toLowerCase()) : undefined; + const provisionalChange = provisional ? changeByKey.get(provisional.key) : undefined; + const provisionalPath = provisional ? localPaths.get(provisional.key) : undefined; + if ( + entry.kind === "file" && + provisional && + !this.isFolder(provisional) && + provisional.type.toUpperCase() === entry.assetType?.toUpperCase() && + provisionalChange?.status === "added" && + !expectedByKey.get(provisional.key)?.digest && + provisionalPath + ) { + const localDigest = snapshot.visibleFiles.get(provisionalPath); + operations.push({ + nodeKey: entry.nodeKey, + path: entry.path, + localPath: provisionalPath, + status: localDigest === entry.contentDigest ? "added" : "modified", + entry, + localNode: provisional, + localChange: provisionalChange, + replacedNodeKey: provisional.key, + converged: true, + }); + replacedLocalKeys.add(provisional.key); + return; + } const occupied = entry.kind === "file" && this.visibleAt(snapshot, entry.path); operations.push({ nodeKey: entry.nodeKey, @@ -220,7 +258,7 @@ export class WorkspacePullService { operations.push(operation); }); localNodes - .filter(node => !remoteByKey.has(node.key)) + .filter(node => !remoteByKey.has(node.key) && !replacedLocalKeys.has(node.key)) .forEach(node => { const localPath = localPaths.get(node.key); if (!localPath) { @@ -265,9 +303,20 @@ export class WorkspacePullService { } return 1; }; - return [...operations].sort( - (left, right) => priority(left) - priority(right) || left.path.localeCompare(right.path) - ); + 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( @@ -291,6 +340,11 @@ export class WorkspacePullService { if (entry.kind === "file" && !operation.converged) { await this.applyFile(root, packageKey, operation, entry); } + if (operation.replacedNodeKey) { + this.removeMetadata(root, operation.replacedNodeKey); + delete state.baselineDigests[operation.replacedNodeKey]; + delete state.moveHints[operation.replacedNodeKey]; + } this.writeMetadataWithAncestors(root, entry, manifest); if (entry.kind === "file") { state.baselineDigests[entry.nodeKey] = entry.contentDigest!; diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index 0691b69e..d0da1494 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -151,11 +151,7 @@ export class WorkspaceService { } } let localState = hasLocalState ? this.state(root) : undefined; - if (localState?.refreshRequired) { - localState = { ...localState }; - delete localState.refreshRequired; - this.writeState(root, localState); - } + const recoveringCreateKeys = Boolean(localState?.refreshRequired); if (localState?.serverRevision) { localState = { ...localState }; delete localState.serverRevision; @@ -163,6 +159,11 @@ export class WorkspaceService { } const { packageKey, restoredObservation } = await this.pullTarget(root, projectKey, localState); const remote = await this.api.manifest(packageKey); + if (localState?.refreshRequired) { + localState = { ...localState }; + delete localState.refreshRequired; + this.writeState(root, localState); + } const pullService = new WorkspacePullService(this.api); if (!localState) { const initial: WorkspaceState = { @@ -186,7 +187,17 @@ export class WorkspaceService { logger.info(`Pulled ${packageKey}.`); return; } - const result = await pullService.pull(root, this.snapshot(root), this.nodes(root), paths, remote.manifest); + const result = await pullService.pull( + root, + this.snapshot(root), + this.nodes(root), + paths, + remote.manifest, + recoveringCreateKeys + ); + if (recoveringCreateKeys && paths.length > 0) { + result.state.refreshRequired = true; + } this.writeState(root, result.state); result.outcomes.forEach(outcome => logger.info( diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index 009d893b..2ea4aaec 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -546,6 +546,18 @@ describe("Workspace service", () => { expect(new WorkspaceService(testContext).status()).toEqual([]); }); + 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(), ".pacman", "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" }, @@ -1220,6 +1232,36 @@ describe("Workspace service", () => { expect(fs.existsSync(path.join(process.cwd(), ".pacman/nodes/server-node.json"))).toBe(true); }); + it("recovers a server-assigned node key after post-create refresh fails", 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(), ".pacman", "local", "state.json"); + fs.writeFileSync(statePath, JSON.stringify({ ...state([local]), baselineDigests: {} })); + mockAxiosPut(fileUrl(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]); + + await service.pull(); + + expect(fs.readFileSync(path.join(process.cwd(), local.path), "utf-8")).toBe("newer local edit"); + expect(fs.existsSync(path.join(process.cwd(), ".pacman/nodes/local-node.json"))).toBe(false); + expect(fs.existsSync(path.join(process.cwd(), ".pacman/nodes/server-node.json"))).toBe(true); + expect(service.status()).toEqual([{ path: local.path, status: "modified" }]); + expect(JSON.parse(fs.readFileSync(statePath, "utf-8"))).toMatchObject({ + baselineDigests: { "server-node": digest("new") }, + }); + }); + it("retains a stale file for retry when its conditional update fails", async () => { writeWorkspace(); fs.writeFileSync(path.join(process.cwd(), "Guides/Guide.md"), "changed"); From 5b70975def64e19381fd4c3b0b5e4548531be6c7 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Thu, 20 Aug 2026 12:58:33 +0200 Subject: [PATCH 32/46] Fix package-root path projection Includes-AI-Code: true --- .../workspace/workspace-pull.service.ts | 6 +++--- .../workspace/workspace.service.spec.ts | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/commands/workspace/workspace-pull.service.ts b/src/commands/workspace/workspace-pull.service.ts index ec6759ed..b58d91bd 100644 --- a/src/commands/workspace/workspace-pull.service.ts +++ b/src/commands/workspace/workspace-pull.service.ts @@ -176,7 +176,7 @@ export class WorkspacePullService { recoveringCreateKeys: boolean ): PullOperation[] { const localByKey = new Map(localNodes.map(node => [node.key, node])); - const localPaths = this.localPaths(localNodes); + const localPaths = this.localPaths(localNodes, snapshot.packageKey); const localByPath = new Map( [...localPaths].map(([nodeKey, localPath]) => [localPath.toLowerCase(), localByKey.get(nodeKey)!]) ); @@ -522,8 +522,8 @@ export class WorkspacePullService { return entry.kind === "file" && entry.contentDigest !== expected?.digest; } - private localPaths(nodes: WorkspaceNodeMetadata[]): Map { - return projectWorkspacePaths(nodes); + private localPaths(nodes: WorkspaceNodeMetadata[], packageKey: string): Map { + return projectWorkspacePaths(nodes, packageKey); } private validateManifest(manifest: WorkspaceManifest): void { diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index 2ea4aaec..47e2cd4c 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -546,6 +546,23 @@ describe("Workspace service", () => { 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(), ".pacman", "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({ + key: "folder-1", + 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); From c43514e6d2ffbc47a25003eb89a9c911f8f017cb Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Thu, 20 Aug 2026 13:01:52 +0200 Subject: [PATCH 33/46] Continue pull after Git advances Includes-AI-Code: true --- src/commands/workspace/workspace.service.ts | 11 ++--------- .../commands/workspace/workspace.service.spec.ts | 15 +++++++-------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index d0da1494..b21dce9c 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -1060,15 +1060,8 @@ export class WorkspaceService { } if (observation.branch === state.git.branch) { if (observation.head !== state.git.head) { - await this.hydrateGitBaseline( - root, - { ...state, git: observation }, - state.activePackageKey, - state.activeBranch, - observation - ); - logger.info(`Reconciled Git branch '${observation.branch}' with ${state.activePackageKey}.`); - return true; + this.writeState(root, { ...state, git: observation }); + logger.info(`Observed Git branch '${observation.branch}' at ${observation.head}.`); } return false; } diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index 47e2cd4c..877f3e25 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -363,7 +363,7 @@ describe("Workspace service", () => { }); }); - it("rehydrates the baseline when Git advances on the mapped branch", async () => { + 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")); @@ -385,14 +385,14 @@ describe("Workspace service", () => { expect(JSON.parse(fs.readFileSync(statePath, "utf-8"))).toMatchObject({ activePackageKey: PACKAGE_KEY, - moveHints: {}, + moveHints: { "node-1": "Pages/Guide.md" }, git: observation, }); expect(git.mappedPacmanBranch).not.toHaveBeenCalled(); - expect(mockedAxiosInstance.get).toHaveBeenCalledWith(manifestUrl(), expect.anything()); + expect(mockedAxiosInstance.get).not.toHaveBeenCalled(); }); - it("rehydrates without overwriting files after Git advances on the mapped branch", async () => { + it("continues an incremental pull after Git advances on the mapped branch", async () => { writeWorkspace(); const statePath = path.join(process.cwd(), ".pacman", "local", "state.json"); fs.writeFileSync( @@ -404,13 +404,12 @@ describe("Workspace service", () => { ); const observation = { branch: "main", head: "b".repeat(40) }; mockManifest([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }]); + 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("original"); - expect(new WorkspaceService(testContext, mockGit(observation)).status()).toEqual([ - { path: "Guides/Guide.md", status: "modified" }, - ]); + 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 }); }); From 1ed72db6a513c82a2204f87dc0220b8b20121665 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Thu, 20 Aug 2026 13:12:43 +0200 Subject: [PATCH 34/46] Handle pull updates below moved folders Includes-AI-Code: true --- .../workspace/workspace-change-classifier.ts | 32 ++- .../workspace/workspace-path-projector.ts | 8 +- .../workspace/workspace-pull.service.ts | 265 ++++++++++++------ .../workspace/workspace.service.spec.ts | 22 ++ 4 files changed, 225 insertions(+), 102 deletions(-) diff --git a/src/commands/workspace/workspace-change-classifier.ts b/src/commands/workspace/workspace-change-classifier.ts index 7d59ec72..63c970d0 100644 --- a/src/commands/workspace/workspace-change-classifier.ts +++ b/src/commands/workspace/workspace-change-classifier.ts @@ -60,20 +60,32 @@ function classifyExpectedFile( return; } if (currentPath) { - consumedPaths.add(currentPath); - const changed = visibleFiles.get(currentPath) !== file.digest; - if (currentPath !== file.path) { - changes.push({ - nodeKey: file.nodeKey, - path: currentPath, - status: sameLeaf(file.path, currentPath) ? (changed ? "moved, modified" : "moved") : "unresolved", - }); - } else if (changed) { + 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; } - missing.push(file); + 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( diff --git a/src/commands/workspace/workspace-path-projector.ts b/src/commands/workspace/workspace-path-projector.ts index bb781507..14a94bc7 100644 --- a/src/commands/workspace/workspace-path-projector.ts +++ b/src/commands/workspace/workspace-path-projector.ts @@ -48,8 +48,7 @@ export function projectedLeafAfterMove( targetParentKey: string | undefined, packageKey?: string ): string { - const source = nodes.find(node => node.key === nodeKey); - if (!source) { + if (!nodes.some(node => node.key === nodeKey)) { throw new GracefulError(`Tracked node metadata is missing: ${nodeKey}.`); } const moved = nodes.map(node => @@ -123,7 +122,10 @@ function candidateSegment(node: WorkspaceNodeMetadata): string { segment === ".." || segment.includes("/") || segment.includes("\\") || - [...segment].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127) + [...segment].some(character => { + const codePoint = character.codePointAt(0)!; + return codePoint < 32 || codePoint === 127; + }) ) { throw new GracefulError(`Invalid derived filesystem name for node ${node.key}.`); } diff --git a/src/commands/workspace/workspace-pull.service.ts b/src/commands/workspace/workspace-pull.service.ts index b58d91bd..deb8bb76 100644 --- a/src/commands/workspace/workspace-pull.service.ts +++ b/src/commands/workspace/workspace-pull.service.ts @@ -42,6 +42,22 @@ interface PullOperation { converged?: 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; @@ -72,12 +88,13 @@ export class WorkspacePullService { delete state.serverRevision; 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); + await this.apply(root, snapshot.packageKey, operation, manifest, state, appliedFolderMoves); outcomes.push({ path: operation.path, status: operation.status, @@ -186,86 +203,122 @@ export class WorkspacePullService { ); const remoteByKey = new Map(manifest.nodes.map(entry => [entry.nodeKey, entry])); const replacedLocalKeys = new Set(); - const operations: PullOperation[] = []; - manifest.nodes.forEach(entry => { - const localNode = localByKey.get(entry.nodeKey); - const localPath = localPaths.get(entry.nodeKey); - const expected = expectedByKey.get(entry.nodeKey); - const localChange = changeByKey.get(entry.nodeKey); - if (!localNode) { - const provisional = recoveringCreateKeys ? localByPath.get(entry.path.toLowerCase()) : undefined; - const provisionalChange = provisional ? changeByKey.get(provisional.key) : undefined; - const provisionalPath = provisional ? localPaths.get(provisional.key) : undefined; - if ( - entry.kind === "file" && - provisional && - !this.isFolder(provisional) && - provisional.type.toUpperCase() === entry.assetType?.toUpperCase() && - provisionalChange?.status === "added" && - !expectedByKey.get(provisional.key)?.digest && - provisionalPath - ) { - const localDigest = snapshot.visibleFiles.get(provisionalPath); - operations.push({ - nodeKey: entry.nodeKey, - path: entry.path, - localPath: provisionalPath, - status: localDigest === entry.contentDigest ? "added" : "modified", - entry, - localNode: provisional, - localChange: provisionalChange, - replacedNodeKey: provisional.key, - converged: true, - }); - replacedLocalKeys.add(provisional.key); - return; - } - const occupied = entry.kind === "file" && this.visibleAt(snapshot, entry.path); - operations.push({ - nodeKey: entry.nodeKey, - path: entry.path, - status: "added", - entry, - conflict: occupied - ? `Remote file conflicts with an untracked local path: ${entry.path}` - : undefined, - }); - return; - } - const remoteChanged = this.remoteChanged(entry, localNode, localPath, expected); - if (!remoteChanged) { - return; - } - const operation: PullOperation = { + 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); + const expected = context.expectedByKey.get(entry.nodeKey); + if (!this.remoteChanged(entry, localNode, localPath, expected)) { + 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; + const provisionalChange = provisional ? context.changeByKey.get(provisional.key) : undefined; + const provisionalPath = provisional ? context.localPaths.get(provisional.key) : undefined; + if ( + entry.kind === "file" && + provisional && + !this.isFolder(provisional) && + provisional.type.toUpperCase() === entry.assetType?.toUpperCase() && + provisionalChange?.status === "added" && + !context.expectedByKey.get(provisional.key)?.digest && + provisionalPath + ) { + context.replacedLocalKeys.add(provisional.key); + return { nodeKey: entry.nodeKey, path: entry.path, - localPath, - status: localPath && localPath.toLowerCase() !== entry.path.toLowerCase() ? "moved" : "modified", + localPath: provisionalPath, + status: + context.snapshot.visibleFiles.get(provisionalPath) === entry.contentDigest ? "added" : "modified", entry, - localNode, - localChange, + localNode: provisional, + localChange: provisionalChange, + replacedNodeKey: provisional.key, + converged: true, }; - if (localChange) { - const localDigest = snapshot.visibleFiles.get(localChange.path); - operation.converged = - entry.kind === "file" && - localChange.path.toLowerCase() === entry.path.toLowerCase() && - localDigest === entry.contentDigest; - if (!operation.converged) { - operation.conflict = `Local and remote changes conflict for node ${entry.nodeKey}.`; - } + } + const occupied = entry.kind === "file" && this.visibleAt(context.snapshot, entry.path); + return { + nodeKey: entry.nodeKey, + path: entry.path, + status: "added", + entry, + conflict: occupied ? `Remote file conflicts with an untracked local path: ${entry.path}` : undefined, + }; + } + + private changedRemoteOperation( + entry: WorkspaceManifestNode, + localNode: WorkspaceNodeMetadata, + 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, + }; + if (!localChange) { + return operation; + } + const localDigest = context.snapshot.visibleFiles.get(localChange.path); + operation.converged = + entry.kind === "file" && + localChange.path.toLowerCase() === entry.path.toLowerCase() && + localDigest === entry.contentDigest; + if (!operation.converged) { + operation.conflict = `Local and remote changes conflict for node ${entry.nodeKey}.`; + } + return operation; + } + + private deletedOperations( + localNodes: WorkspaceNodeMetadata[], + remoteByKey: Map, + context: PullOperationContext + ): PullOperation[] { + return localNodes.flatMap(node => { + if (remoteByKey.has(node.key) || context.replacedLocalKeys.has(node.key)) { + return []; } - operations.push(operation); - }); - localNodes - .filter(node => !remoteByKey.has(node.key) && !replacedLocalKeys.has(node.key)) - .forEach(node => { - const localPath = localPaths.get(node.key); - if (!localPath) { - return; - } - const localChange = changeByKey.get(node.key); - operations.push({ + const localPath = context.localPaths.get(node.key); + if (!localPath) { + return []; + } + const localChange = context.changeByKey.get(node.key); + return [ + { nodeKey: node.key, path: localPath, localPath, @@ -277,9 +330,9 @@ export class WorkspacePullService { localChange && localChange.status !== "deleted" ? `Remote deletion conflicts with local changes for node ${node.key}.` : undefined, - }); - }); - return operations; + }, + ]; + }); } private select(root: string, paths: string[], operations: PullOperation[]): PullOperation[] { @@ -324,7 +377,8 @@ export class WorkspacePullService { packageKey: string, operation: PullOperation, manifest: WorkspaceManifest, - state: WorkspaceState + state: WorkspaceState, + appliedFolderMoves: AppliedFolderMove[] ): Promise { if (operation.status === "deleted") { this.applyDelete(root, operation); @@ -335,10 +389,13 @@ export class WorkspacePullService { } const entry = operation.entry!; if (entry.kind === "folder") { - this.applyFolder(root, operation, entry); + const appliedMove = this.applyFolder(root, operation, entry); + if (appliedMove) { + appliedFolderMoves.push(appliedMove); + } } if (entry.kind === "file" && !operation.converged) { - await this.applyFile(root, packageKey, operation, entry); + await this.applyFile(root, packageKey, operation, entry, appliedFolderMoves); } if (operation.replacedNodeKey) { this.removeMetadata(root, operation.replacedNodeKey); @@ -356,16 +413,23 @@ export class WorkspacePullService { root: string, packageKey: string, operation: PullOperation, - entry: WorkspaceManifestNode + 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()); if (moved && fs.existsSync(target)) { - if (!this.movedTargetMatches(source, target, entry)) { + if (this.movedTargetMatches(source, target, entry)) { + return; + } + 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; } fs.mkdirSync(path.dirname(target), { recursive: true }); if (this.localFileDigest(source) === entry.contentDigest) { @@ -397,7 +461,28 @@ export class WorkspacePullService { return source && fs.existsSync(source) && fs.lstatSync(source).isFile() ? this.digest(source) : undefined; } - private applyFolder(root: string, operation: PullOperation, entry: WorkspaceManifestNode): void { + 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()); @@ -406,23 +491,25 @@ export class WorkspacePullService { throw new GracefulError(`Remote folder conflicts with an existing local path: ${entry.path}`); } fs.mkdirSync(target, { recursive: true }); - return; + return undefined; } + const appliedMove = { sourcePath: operation.localPath!, targetPath: entry.path }; if (fs.existsSync(target)) { if (source && !fs.existsSync(source) && fs.lstatSync(target).isDirectory()) { - return; + 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; + 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 { diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index 877f3e25..8e2c37ed 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -190,6 +190,8 @@ function removeWorkspace(): void { "Deleted", "Old", "Selected", + "Source", + "Target", "Unselected", PACKAGE_KEY, "branch-workspace", @@ -545,6 +547,26 @@ describe("Workspace service", () => { 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; + sharedFolder.metadata.key = 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); From 07c91dfa240e4fba98c849554c4f95bc2d46e0d1 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Thu, 20 Aug 2026 16:06:36 +0200 Subject: [PATCH 35/46] Rename workspace metadata root to .package Includes-AI-Code: true --- .../workspace/workspace-path-projector.ts | 2 +- .../workspace/workspace-path-selection.ts | 4 +- .../workspace/workspace-pull.service.ts | 12 +- src/commands/workspace/workspace.service.ts | 36 ++--- .../workspace/workspace.service.spec.ts | 128 +++++++++--------- tests/core/utils/file.service.spec.ts | 8 +- 6 files changed, 95 insertions(+), 95 deletions(-) diff --git a/src/commands/workspace/workspace-path-projector.ts b/src/commands/workspace/workspace-path-projector.ts index 14a94bc7..e419fd20 100644 --- a/src/commands/workspace/workspace-path-projector.ts +++ b/src/commands/workspace/workspace-path-projector.ts @@ -154,7 +154,7 @@ function addSuffix(segment: string, folder: boolean, suffix: string): string { function validateVisiblePath(value: string): void { const segments = value.split("/"); const first = segments[0].toLowerCase(); - if (first === ".pacman" || first === ".git") { + if (first === ".package" || first === ".git") { throw new GracefulError(`Invalid derived workspace path: ${value}.`); } } diff --git a/src/commands/workspace/workspace-path-selection.ts b/src/commands/workspace/workspace-path-selection.ts index 63e3a9b4..3da4d886 100644 --- a/src/commands/workspace/workspace-path-selection.ts +++ b/src/commands/workspace/workspace-path-selection.ts @@ -37,8 +37,8 @@ function selection(root: string, value: string, candidatePaths: string[]): Selec relative === ".." || relative.startsWith("../") || path.isAbsolute(relative) || - folded === ".pacman" || - folded.startsWith(".pacman/") || + folded === ".package" || + folded.startsWith(".package/") || folded === ".git" || folded.startsWith(".git/") ) { diff --git a/src/commands/workspace/workspace-pull.service.ts b/src/commands/workspace/workspace-pull.service.ts index deb8bb76..cb427e8b 100644 --- a/src/commands/workspace/workspace-pull.service.ts +++ b/src/commands/workspace/workspace-pull.service.ts @@ -514,7 +514,7 @@ export class WorkspacePullService { private applyDelete(root: string, operation: PullOperation): void { if (operation.localNode && this.isFolder(operation.localNode)) { - const metadataDirectory = path.join(root, ".pacman", "nodes"); + const metadataDirectory = path.join(root, ".package", "nodes"); const hasChildren = fs .readdirSync(metadataDirectory) .filter(file => file.endsWith(".json") && file !== `${operation.nodeKey}.json`) @@ -541,7 +541,7 @@ export class WorkspacePullService { private writeMetadata(root: string, entry: WorkspaceManifestNode, manifest: WorkspaceManifest): void { this.writeDocument(root, entry, manifest); - const metadataDirectory = path.join(root, ".pacman", "nodes"); + const metadataDirectory = path.join(root, ".package", "nodes"); fs.mkdirSync(metadataDirectory, { recursive: true }); fs.writeFileSync( path.join(metadataDirectory, `${entry.nodeKey}.json`), @@ -554,7 +554,7 @@ export class WorkspacePullService { if (!reference) { return; } - const match = /^\.pacman\/documents\/([0-9a-f]{64})\.bin$/.exec(reference); + const match = /^\.package\/documents\/([0-9a-f]{64})\.bin$/.exec(reference); if (!match) { throw new GracefulError(`Invalid document reference for node ${entry.nodeKey}.`); } @@ -594,7 +594,7 @@ export class WorkspacePullService { } private removeMetadata(root: string, nodeKey: string): void { - fs.rmSync(path.join(root, ".pacman", "nodes", `${nodeKey}.json`), { force: true }); + fs.rmSync(path.join(root, ".package", "nodes", `${nodeKey}.json`), { force: true }); } private remoteChanged( @@ -686,7 +686,7 @@ export class WorkspacePullService { if (!reference) { return; } - const match = /^\.pacman\/documents\/([0-9a-f]{64})\.bin$/.exec(reference); + const match = /^\.package\/documents\/([0-9a-f]{64})\.bin$/.exec(reference); const digest = match ? `sha256:${match[1]}` : undefined; const encoded = digest ? manifest.documents[digest] : undefined; if (!digest || typeof encoded !== "string" || this.digestBuffer(Buffer.from(encoded, "base64")) !== digest) { @@ -701,7 +701,7 @@ export class WorkspacePullService { const segments = value.split("/"); const first = segments[0].toLowerCase(); return ( - first !== ".pacman" && + first !== ".package" && first !== ".git" && segments.every(segment => Boolean(segment) && segment !== "." && segment !== "..") ); diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index b21dce9c..0a3ea1b1 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -82,7 +82,7 @@ export class WorkspaceService { let staging: string | undefined; try { fs.mkdirSync(parent, { recursive: true }); - staging = fs.mkdtempSync(path.join(parent, ".pacman-clone-")); + staging = fs.mkdtempSync(path.join(parent, ".package-clone-")); fs.rmSync(staging, { recursive: true }); fs.cpSync(temporary, staging, { recursive: true, force: false, errorOnExist: true }); if (fs.existsSync(target)) { @@ -326,8 +326,8 @@ export class WorkspaceService { return ( folded !== ".git" && !folded.startsWith(".git/") && - folded !== ".pacman/local" && - !folded.startsWith(".pacman/local/") + folded !== ".package/local" && + !folded.startsWith(".package/local/") ); }); try { @@ -479,9 +479,9 @@ export class WorkspaceService { } private nodes(root: string): WorkspaceNodeMetadata[] { - const directory = path.join(root, ".pacman", "nodes"); + const directory = path.join(root, ".package", "nodes"); if (!fs.existsSync(directory)) { - throw new GracefulError("Workspace does not contain .pacman/nodes metadata."); + throw new GracefulError("Workspace does not contain .package/nodes metadata."); } return fs .readdirSync(directory, { withFileTypes: true }) @@ -515,7 +515,7 @@ export class WorkspaceService { .forEach(entry => { if ( !relativeDirectory && - (entry.name.toLowerCase() === ".pacman" || entry.name.toLowerCase() === ".git") + (entry.name.toLowerCase() === ".package" || entry.name.toLowerCase() === ".git") ) { return; } @@ -605,7 +605,7 @@ export class WorkspaceService { ): void { const extracted = this.validatedArchive(download, packageKey, projectKey, observation); try { - this.replaceMetadataDirectory(root, path.join(extracted, ".pacman")); + this.replaceMetadataDirectory(root, path.join(extracted, ".package")); } finally { fs.rmSync(extracted, { recursive: true, force: true }); } @@ -615,7 +615,7 @@ export class WorkspaceService { const refreshRoot = fs.mkdtempSync(path.join(path.dirname(root), `.${path.basename(root)}-pacman-refresh-`)); const stagedMetadata = path.join(refreshRoot, "metadata"); const previousMetadata = path.join(refreshRoot, "previous"); - const metadata = path.join(root, ".pacman"); + const metadata = path.join(root, ".package"); let preserveBackup = false; try { fs.cpSync(sourceMetadata, stagedMetadata, { recursive: true }); @@ -649,13 +649,13 @@ export class WorkspaceService { observation?: WorkspaceGitObservation ): string { const zip = new AdmZip(download.archive); - if (!zip.getEntry(".pacman/package.json") || !zip.getEntry(".pacman/.gitignore")) { + if (!zip.getEntry(".package/package.json") || !zip.getEntry(".package/.gitignore")) { throw new GracefulError("Archive does not contain Pacman package metadata."); } if ( zip.getEntries().some(entry => { const folded = entry.entryName.toLowerCase(); - return folded === ".pacman/local" || folded.startsWith(".pacman/local/"); + return folded === ".package/local" || folded.startsWith(".package/local/"); }) ) { throw new GracefulError("Archive contains local Pacman workspace state."); @@ -774,8 +774,8 @@ export class WorkspaceService { throw new GracefulError("Cannot pull into the filesystem root."); } const parent = path.dirname(root); - const backup = fs.mkdtempSync(path.join(parent, ".pacman-pull-backup-")); - const staging = fs.mkdtempSync(path.join(parent, ".pacman-pull-")); + 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 { @@ -916,8 +916,8 @@ export class WorkspaceService { path.isAbsolute(value) || normalized === "." || normalized === ".." || - folded === ".pacman" || - folded.startsWith(".pacman/") || + folded === ".package" || + folded.startsWith(".package/") || folded === ".git" || folded.startsWith(".git/") || normalized.startsWith("../") || @@ -977,7 +977,7 @@ export class WorkspaceService { } private statePath(root: string): string { - return path.join(root, ".pacman", "local", "state.json"); + return path.join(root, ".package", "local", "state.json"); } private async createWorkspaceBranch( @@ -1206,14 +1206,14 @@ export class WorkspaceService { } private packageIdentityPath(root: string): string { - return path.join(root, ".pacman", "package.json"); + return path.join(root, ".package", "package.json"); } private validateGitignore(root: string): void { - const gitignore = path.join(root, ".pacman", ".gitignore"); + 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 .pacman/.gitignore must contain local/."); + throw new GracefulError("Workspace .package/.gitignore must contain local/."); } } diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index 8e2c37ed..b5b7256d 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -101,11 +101,11 @@ function state( function archive(files: TestFile[]): Buffer { const zip = new AdmZip(); - zip.addFile(".pacman/.gitignore", Buffer.from("local/\n")); - zip.addFile(".pacman/package.json", Buffer.from(JSON.stringify({ schemaVersion: 1, projectKey: PACKAGE_KEY }))); - zip.addFile(".pacman/nodes/", Buffer.alloc(0)); + zip.addFile(".package/.gitignore", Buffer.from("local/\n")); + zip.addFile(".package/package.json", Buffer.from(JSON.stringify({ schemaVersion: 1, projectKey: PACKAGE_KEY }))); + zip.addFile(".package/nodes/", Buffer.alloc(0)); Object.entries(metadata(files)).forEach(([nodeKey, node]) => { - zip.addFile(`.pacman/nodes/${nodeKey}.json`, Buffer.from(JSON.stringify(node))); + zip.addFile(`.package/nodes/${nodeKey}.json`, Buffer.from(JSON.stringify(node))); }); files.forEach(file => zip.addFile(file.path, Buffer.from(file.content))); return zip.toBuffer(); @@ -160,16 +160,16 @@ function mockManifest(files: TestFile[], packageKey: string = PACKAGE_KEY): void function writeWorkspace( files: TestFile[] = [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }] ): void { - fs.mkdirSync(path.join(process.cwd(), ".pacman", "nodes"), { recursive: true }); - fs.mkdirSync(path.join(process.cwd(), ".pacman", "local"), { recursive: true }); - fs.writeFileSync(path.join(process.cwd(), ".pacman", ".gitignore"), "local/\n"); + 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(), ".pacman", "package.json"), + path.join(process.cwd(), ".package", "package.json"), JSON.stringify({ schemaVersion: 1, projectKey: PACKAGE_KEY }) ); - fs.writeFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), JSON.stringify(state(files))); + 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(), ".pacman", "nodes", `${nodeKey}.json`), JSON.stringify(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 }); @@ -180,7 +180,7 @@ function writeWorkspace( function removeWorkspace(): void { [ ".git", - ".pacman", + ".package", "Guides", "Pages", "Other", @@ -225,12 +225,12 @@ describe("Workspace service", () => { 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, ".pacman", ".gitignore"), "utf-8")).toBe( + 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, ".pacman", "local", "state.json"), "utf-8") + fs.readFileSync(path.join(process.cwd(), PACKAGE_KEY, ".package", "local", "state.json"), "utf-8") ) ).toEqual({ schemaVersion: 1, @@ -266,11 +266,11 @@ describe("Workspace service", () => { 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, ".pacman", "package.json"), "utf-8"))).toEqual({ + 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, ".pacman", "local", "state.json"), "utf-8"))).toMatchObject({ + expect(JSON.parse(fs.readFileSync(path.join(root, ".package", "local", "state.json"), "utf-8"))).toMatchObject({ activePackageKey: BRANCH_PACKAGE_KEY, activeBranch: BRANCH, }); @@ -289,7 +289,7 @@ describe("Workspace service", () => { expect(fs.readFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "utf-8")).toBe("branch"); expect( - JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8")) + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package", "local", "state.json"), "utf-8")) ).toMatchObject({ activePackageKey: BRANCH_PACKAGE_KEY, activeBranch: BRANCH, @@ -340,7 +340,7 @@ describe("Workspace service", () => { it("rehydrates a mapped Pacman baseline after an external Git branch switch", async () => { writeWorkspace(); - const statePath = path.join(process.cwd(), ".pacman", "local", "state.json"); + const statePath = path.join(process.cwd(), ".package", "local", "state.json"); fs.writeFileSync( statePath, JSON.stringify({ @@ -369,7 +369,7 @@ describe("Workspace service", () => { 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(), ".pacman", "local", "state.json"); + const statePath = path.join(process.cwd(), ".package", "local", "state.json"); fs.writeFileSync( statePath, JSON.stringify({ @@ -396,7 +396,7 @@ describe("Workspace service", () => { it("continues an incremental pull after Git advances on the mapped branch", async () => { writeWorkspace(); - const statePath = path.join(process.cwd(), ".pacman", "local", "state.json"); + const statePath = path.join(process.cwd(), ".package", "local", "state.json"); fs.writeFileSync( statePath, JSON.stringify({ @@ -417,7 +417,7 @@ describe("Workspace service", () => { it("blocks pushes from detached or unmapped switched Git branches", async () => { writeWorkspace(); - const statePath = path.join(process.cwd(), ".pacman", "local", "state.json"); + const statePath = path.join(process.cwd(), ".package", "local", "state.json"); fs.writeFileSync( statePath, JSON.stringify({ @@ -457,13 +457,13 @@ describe("Workspace service", () => { 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(), ".pacman", "local", "state.json"), "utf-8")) + 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(), ".pacman", "local", "state.json"), "utf-8")) + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package", "local", "state.json"), "utf-8")) ).not.toHaveProperty("serverRevision"); }); @@ -570,7 +570,7 @@ describe("Workspace service", () => { 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(), ".pacman", "nodes", "folder-1.json"); + 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); @@ -592,7 +592,7 @@ describe("Workspace service", () => { await new WorkspaceService(testContext).pull(); expect(fs.existsSync(path.join(process.cwd(), "Deleted"))).toBe(false); - expect(fs.readdirSync(path.join(process.cwd(), ".pacman", "nodes"))).toEqual([]); + expect(fs.readdirSync(path.join(process.cwd(), ".package", "nodes"))).toEqual([]); expect(new WorkspaceService(testContext).status()).toEqual([]); }); @@ -614,7 +614,7 @@ describe("Workspace service", () => { 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(), ".pacman/local/state.json"), "utf-8")); + 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"), @@ -633,7 +633,7 @@ describe("Workspace service", () => { it("hydrates local state after an external Git restore without overwriting files", async () => { writeWorkspace(); - fs.rmSync(path.join(process.cwd(), ".pacman", "local"), { recursive: true }); + 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" }]); @@ -642,13 +642,13 @@ describe("Workspace service", () => { 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(), ".pacman", "local", "state.json"), "utf-8")) + 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(), ".pacman", "local"), { recursive: true }); + 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); @@ -658,7 +658,7 @@ describe("Workspace service", () => { 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(), ".pacman", "local", "state.json"), "utf-8")) + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package", "local", "state.json"), "utf-8")) ).toMatchObject({ activePackageKey: BRANCH_PACKAGE_KEY, activeBranch: BRANCH, @@ -668,8 +668,8 @@ describe("Workspace service", () => { it("hydrates a Git-restored workspace with CRLF metadata ignore rules", async () => { writeWorkspace(); - fs.rmSync(path.join(process.cwd(), ".pacman", "local"), { recursive: true }); - fs.writeFileSync(path.join(process.cwd(), ".pacman", ".gitignore"), "local/\r\n"); + 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" }]); const service = new WorkspaceService(testContext); @@ -680,7 +680,7 @@ describe("Workspace service", () => { 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(), ".pacman", "local"), { recursive: true }); + fs.rmSync(path.join(process.cwd(), ".package", "local"), { recursive: true }); mockManifest([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]); const service = new WorkspaceService(testContext); @@ -688,7 +688,7 @@ describe("Workspace service", () => { expect(service.status()).toEqual([{ path: "Pages/Guide.md", status: "moved" }]); expect( - JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8")) + 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" }, @@ -757,7 +757,7 @@ describe("Workspace service", () => { const originalRename = fs.renameSync; const rename = jest.spyOn(fs, "renameSync").mockImplementation((source, target) => { const sourceParent = path.basename(path.dirname(source.toString())); - if (sourceParent.startsWith(".pacman-pull-") && !sourceParent.startsWith(".pacman-pull-backup-")) { + if (sourceParent.startsWith(".package-pull-") && !sourceParent.startsWith(".package-pull-backup-")) { throw new Error("apply failed"); } originalRename(source, target); @@ -780,7 +780,7 @@ describe("Workspace service", () => { const originalRename = fs.renameSync; const rename = jest.spyOn(fs, "renameSync").mockImplementation((source, target) => { const sourceParent = path.basename(path.dirname(source.toString())); - if (sourceParent.startsWith(".pacman-pull-")) { + if (sourceParent.startsWith(".package-pull-")) { throw new Error("rename failed"); } originalRename(source, target); @@ -813,7 +813,7 @@ describe("Workspace service", () => { expect(new WorkspaceService(testContext).status()).toEqual([{ path: "Pages/Guide.md", status: "moved" }]); const workspaceState = JSON.parse( - fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8") + fs.readFileSync(path.join(process.cwd(), ".package", "local", "state.json"), "utf-8") ); expect(workspaceState).not.toHaveProperty("files"); expect(workspaceState).not.toHaveProperty("basePath"); @@ -834,7 +834,7 @@ describe("Workspace service", () => { expect(service.status()).toEqual([{ path: "Pages/Guide.md", status: "moved, modified" }]); expect( - JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8")) + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package", "local", "state.json"), "utf-8")) ).toMatchObject({ moveHints: { "node-1": "Pages/Guide.md" }, }); @@ -850,7 +850,7 @@ describe("Workspace service", () => { expect(service.status()).toEqual([{ path: "Pages/Guide.md", status: "moved" }]); expect( - JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8")) + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package", "local", "state.json"), "utf-8")) ).toMatchObject({ moveHints: { "node-1": "Pages/Guide.md" } }); }); @@ -923,7 +923,7 @@ describe("Workspace service", () => { expect(service.status()).toEqual([{ path: "Pages/Guide.md", status: "moved, modified" }]); expect( - JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8")) + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package", "local", "state.json"), "utf-8")) ).toMatchObject({ moveHints: { "node-1": "Pages/Guide.md" }, }); @@ -977,7 +977,7 @@ describe("Workspace service", () => { it("rejects circular node metadata", () => { writeWorkspace(); - const folderPath = path.join(process.cwd(), ".pacman", "nodes", "folder-1.json"); + 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)); @@ -987,7 +987,7 @@ describe("Workspace service", () => { it("rejects volatile fields in stable node metadata", () => { writeWorkspace(); - const nodePath = path.join(process.cwd(), ".pacman", "nodes", "node-1.json"); + 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)); @@ -1000,7 +1000,7 @@ describe("Workspace service", () => { { nodeKey: "node-1", path: "Guides/One.md", content: "one" }, { nodeKey: "node-2", path: "Guides/Two.md", content: "two" }, ]); - const secondPath = path.join(process.cwd(), ".pacman", "nodes", "node-2.json"); + 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)); @@ -1014,7 +1014,7 @@ describe("Workspace service", () => { it("rejects legacy filesystem names in stable Node metadata", () => { writeWorkspace(); - const nodePath = path.join(process.cwd(), ".pacman", "nodes", "node-1.json"); + 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)); @@ -1095,7 +1095,7 @@ describe("Workspace service", () => { it("rejects downloaded archives containing local workspace state", async () => { const zip = new AdmZip(archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }])); - zip.addFile(".pacman/local/state.json", Buffer.from("{}")); + zip.addFile(".package/local/state.json", Buffer.from("{}")); mockAxiosGet(ARCHIVE_URL, zip.toBuffer(), { etag: eTag("revision-1") }); await expect(new WorkspaceService(testContext).clone(PACKAGE_KEY)).rejects.toThrow( @@ -1137,7 +1137,7 @@ describe("Workspace service", () => { expect(mockedAxiosInstance.delete).not.toHaveBeenCalled(); expect(new WorkspaceService(testContext).status()).toEqual([]); expect( - JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman/local/state.json"), "utf-8")) + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package/local/state.json"), "utf-8")) ).not.toHaveProperty("serverRevision"); }); @@ -1211,7 +1211,7 @@ describe("Workspace service", () => { expect(service.status()).toEqual([{ path: "Pages/Two.md", status: "moved" }]); expect( - JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman/local/state.json"), "utf-8")) + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package/local/state.json"), "utf-8")) ).toMatchObject({ moveHints: { "node-2": "Pages/Two.md" } }); }); @@ -1248,7 +1248,7 @@ describe("Workspace service", () => { 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(), ".pacman", "local", "state.json"); + const statePath = path.join(process.cwd(), ".package", "local", "state.json"); fs.writeFileSync(statePath, JSON.stringify({ ...state([local]), baselineDigests: {} })); mockAxiosPut(fileUrl(local.path), { path: local.path, @@ -1266,15 +1266,15 @@ describe("Workspace service", () => { expect.objectContaining({ headers: expect.objectContaining({ "If-None-Match": "*" }) }) ); expect(new WorkspaceService(testContext).status()).toEqual([]); - expect(fs.existsSync(path.join(process.cwd(), ".pacman/nodes/local-node.json"))).toBe(false); - expect(fs.existsSync(path.join(process.cwd(), ".pacman/nodes/server-node.json"))).toBe(true); + 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("recovers a server-assigned node key after post-create refresh fails", 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(), ".pacman", "local", "state.json"); + const statePath = path.join(process.cwd(), ".package", "local", "state.json"); fs.writeFileSync(statePath, JSON.stringify({ ...state([local]), baselineDigests: {} })); mockAxiosPut(fileUrl(local.path), { path: local.path, @@ -1292,8 +1292,8 @@ describe("Workspace service", () => { await service.pull(); expect(fs.readFileSync(path.join(process.cwd(), local.path), "utf-8")).toBe("newer local edit"); - expect(fs.existsSync(path.join(process.cwd(), ".pacman/nodes/local-node.json"))).toBe(false); - expect(fs.existsSync(path.join(process.cwd(), ".pacman/nodes/server-node.json"))).toBe(true); + 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([{ path: local.path, status: "modified" }]); expect(JSON.parse(fs.readFileSync(statePath, "utf-8"))).toMatchObject({ baselineDigests: { "server-node": digest("new") }, @@ -1310,7 +1310,7 @@ describe("Workspace service", () => { expect(new WorkspaceService(testContext).status()).toEqual([{ path: "Guides/Guide.md", status: "modified" }]); expect( - JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman/local/state.json"), "utf-8")) + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package/local/state.json"), "utf-8")) ).toMatchObject({ baselineDigests: { "node-1": digest("original") } }); }); @@ -1336,7 +1336,7 @@ describe("Workspace service", () => { expect(new WorkspaceService(testContext).status()).toEqual([{ path: "Guides/Two.md", status: "modified" }]); expect( - JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman/local/state.json"), "utf-8")) + 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") }, }); @@ -1452,7 +1452,7 @@ describe("Workspace service", () => { expect(service.status()).toEqual([{ path: "Pages/Guide.md", status: "modified" }]); expect( - JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman/local/state.json"), "utf-8")) + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package/local/state.json"), "utf-8")) ).toMatchObject({ baselineDigests: { "node-1": digest("original") }, moveHints: {}, @@ -1516,13 +1516,13 @@ describe("Workspace service", () => { }) ); const include = zip.mock.calls[0][1]!; - expect(include(".pacman/local/state.json")).toBe(false); - expect(include(".pacman/nodes/node-1.json")).toBe(true); + expect(include(".package/local/state.json")).toBe(false); + expect(include(".package/nodes/node-1.json")).toBe(true); const form = (mockedAxiosInstance.post as jest.Mock).mock.calls[0][1] as { _streams: unknown[] }; expect(form._streams).toContain(JSON.stringify({ moves: { "node-1": "Pages/Guide.md" } })); expect(service.status()).toEqual([]); expect( - JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8")) + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package", "local", "state.json"), "utf-8")) ).toEqual({ schemaVersion: 1, activePackageKey: PACKAGE_KEY, @@ -1545,7 +1545,7 @@ describe("Workspace service", () => { "Push succeeded, but local state refresh failed" ); expect( - JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8")) + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package", "local", "state.json"), "utf-8")) ).toMatchObject({ refreshRequired: true }); expect(() => service.status()).toThrow("Workspace synchronization state needs refresh"); @@ -1576,14 +1576,14 @@ describe("Workspace service", () => { await expect(service.push()).rejects.toThrow("local synchronization state could not be refreshed"); expect( - JSON.parse(fs.readFileSync(path.join(process.cwd(), ".pacman", "local", "state.json"), "utf-8")) + 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(), ".pacman", "local", "state.json"), "utf-8")) + JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package", "local", "state.json"), "utf-8")) ).toMatchObject({ moveHints: {} }); }); @@ -1596,7 +1596,7 @@ describe("Workspace service", () => { }); const originalRename = fs.renameSync; const rename = jest.spyOn(fs, "renameSync").mockImplementation((source, target) => { - if (target.toString() === path.join(process.cwd(), ".pacman")) { + if (target.toString() === path.join(process.cwd(), ".package")) { throw new Error("rename failed"); } originalRename(source, target); @@ -1618,7 +1618,7 @@ describe("Workspace service", () => { expect(backup).toBeDefined(); expect(fs.existsSync(backup!)).toBe(true); expect(backup!.startsWith(`${process.cwd()}${path.sep}`)).toBe(false); - originalRename(backup!, path.join(process.cwd(), ".pacman")); + originalRename(backup!, path.join(process.cwd(), ".package")); fs.rmSync(path.dirname(backup!), { recursive: true, force: true }); }); @@ -1635,7 +1635,7 @@ describe("Workspace service", () => { it("reserves the metadata directory case-insensitively", () => { writeWorkspace(); - expect(() => new WorkspaceService(testContext).move("Guides/Guide.md", ".PACMAN/Guide.md")).toThrow( + 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 d96b1bf8..9e25f707 100644 --- a/tests/core/utils/file.service.spec.ts +++ b/tests/core/utils/file.service.spec.ts @@ -100,19 +100,19 @@ describe("FileService", () => { }); test("Should exclude filtered workspace paths", () => { - const metadata = path.join(tempDir, ".pacman"); + 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(".pacman/local") + relativePath => !relativePath.startsWith(".package/local") ); const entries = new AdmZip(zipPath).getEntries().map(entry => entry.entryName); - expect(entries).toContain(".pacman/package.json"); - expect(entries).not.toContain(".pacman/local/state.json"); + expect(entries).toContain(".package/package.json"); + expect(entries).not.toContain(".package/local/state.json"); }); }); From bcfce5bf1d0f1d5ff33a6dfbf7da5b12e24d63f3 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Thu, 20 Aug 2026 17:00:07 +0200 Subject: [PATCH 36/46] Clarify workspace extension ownership Includes-AI-Code: true --- tests/commands/workspace/workspace.service.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index b5b7256d..22752b20 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -1244,7 +1244,7 @@ describe("Workspace service", () => { ]); }); - it("pushes a metadata-backed addition without a baseline", async () => { + it("lets Pacman select the Asset Type for a registered-extension addition", async () => { const local = { nodeKey: "local-node", path: "Guides/New.md", content: "new" }; const remote = { ...local, nodeKey: "server-node" }; writeWorkspace([local]); @@ -1265,6 +1265,7 @@ describe("Workspace service", () => { Buffer.from("new"), expect.objectContaining({ headers: expect.objectContaining({ "If-None-Match": "*" }) }) ); + expect((mockedAxiosInstance.put as jest.Mock).mock.calls[0][0]).not.toContain("assetType="); 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); From fbc541e9bbab3121ac22b27a80040e12d3e13953 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Thu, 20 Aug 2026 18:33:05 +0200 Subject: [PATCH 37/46] Harden beta workspace synchronization Includes-AI-Code: true --- src/commands/workspace/module.ts | 49 +++++--- .../workspace/workspace-pull.service.ts | 86 ++++++++----- src/commands/workspace/workspace.service.ts | 116 +++++++++--------- tests/commands/workspace/module.spec.ts | 16 +++ .../workspace/workspace.service.spec.ts | 109 ++++++++++++---- 5 files changed, 254 insertions(+), 122 deletions(-) diff --git a/src/commands/workspace/module.ts b/src/commands/workspace/module.ts index f8348c34..117a18a2 100644 --- a/src/commands/workspace/module.ts +++ b/src/commands/workspace/module.ts @@ -3,6 +3,15 @@ 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."); @@ -49,38 +58,46 @@ class Module extends IModule { } private async clone(context: Context, command: Command, options: OptionValues): Promise { - await new WorkspaceService(context).clone(command.args[0], command.args[1], { branch: options.branch }); + 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 { - 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, + 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, options: OptionValues): Promise { - await new WorkspaceService(context).pull(command.args, { full: options.full }); + await runWorkspaceCommand(() => new WorkspaceService(context).pull(command.args, { full: options.full })); } private async status(context: Context, command: Command): Promise { - await new WorkspaceService(context).statusWithGit(command.args[0]); + await runWorkspaceCommand(() => new WorkspaceService(context).statusWithGit(command.args[0])); } private async push(context: Context, command: Command, options: OptionValues): Promise { - await new WorkspaceService(context).push(command.args, { - full: options.full, - overwrite: options.overwrite, - }); + await runWorkspaceCommand(() => + new WorkspaceService(context).push(command.args, { + full: options.full, + overwrite: options.overwrite, + }) + ); } private async move(context: Context, command: Command, options: OptionValues): Promise { - new WorkspaceService(context).move(command.args[0], command.args[1], options.record); + await runWorkspaceCommand(() => + new WorkspaceService(context).move(command.args[0], command.args[1], options.record) + ); } } diff --git a/src/commands/workspace/workspace-pull.service.ts b/src/commands/workspace/workspace-pull.service.ts index cb427e8b..7a589162 100644 --- a/src/commands/workspace/workspace-pull.service.ts +++ b/src/commands/workspace/workspace-pull.service.ts @@ -22,6 +22,7 @@ const FORBIDDEN_METADATA_FIELDS = [ "packageKey", "packageNodeKey", "branchKey", + "spaceId", "creationDate", "changeDate", "revision", @@ -127,8 +128,8 @@ export class WorkspacePullService { activeBranch, baselineDigests: Object.fromEntries( manifest.nodes - .filter(entry => entry.kind === "file") - .map(entry => [entry.nodeKey, entry.contentDigest!]) + .filter((entry) => entry.kind === "file") + .map((entry) => [entry.nodeKey, entry.contentDigest!]) ), moveHints: {}, }; @@ -151,7 +152,7 @@ export class WorkspacePullService { }> ): WorkspaceState { this.validateManifest(manifest); - const byNodeKey = new Map(manifest.nodes.map(entry => [entry.nodeKey, entry])); + const byNodeKey = new Map(manifest.nodes.map((entry) => [entry.nodeKey, entry])); const next: WorkspaceState = { ...state, baselineDigests: { ...state.baselineDigests }, @@ -160,8 +161,8 @@ export class WorkspacePullService { delete next.serverRevision; delete next.refreshRequired; outcomes - .filter(outcome => (outcome.success || outcome.remoteChanged) && outcome.nodeKey) - .forEach(outcome => { + .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); @@ -192,16 +193,16 @@ export class WorkspacePullService { manifest: WorkspaceManifest, recoveringCreateKeys: boolean ): PullOperation[] { - const localByKey = new Map(localNodes.map(node => [node.key, node])); + const localByKey = new Map(localNodes.map((node) => [node.key, 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 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] : [])) + snapshot.changes.flatMap((change) => (change.nodeKey ? [[change.nodeKey, change] as const] : [])) ); - const remoteByKey = new Map(manifest.nodes.map(entry => [entry.nodeKey, entry])); + const remoteByKey = new Map(manifest.nodes.map((entry) => [entry.nodeKey, entry])); const replacedLocalKeys = new Set(); const context: PullOperationContext = { snapshot, @@ -213,7 +214,7 @@ export class WorkspacePullService { replacedLocalKeys, recoveringCreateKeys, }; - const operations = manifest.nodes.flatMap(entry => { + const operations = manifest.nodes.flatMap((entry) => { const operation = this.remoteOperation(entry, context); return operation ? [operation] : []; }); @@ -238,6 +239,23 @@ export class WorkspacePullService { const provisional = context.recoveringCreateKeys ? context.localByPath.get(entry.path.toLowerCase()) : undefined; + const visibleDigest = [...context.snapshot.visibleFiles.entries()].find( + ([filePath]) => filePath.toLowerCase() === entry.path.toLowerCase() + )?.[1]; + if ( + context.recoveringCreateKeys && + entry.kind === "file" && + !provisional && + visibleDigest === entry.contentDigest + ) { + return { + nodeKey: entry.nodeKey, + path: entry.path, + status: "added", + entry, + converged: true, + }; + } const provisionalChange = provisional ? context.changeByKey.get(provisional.key) : undefined; const provisionalPath = provisional ? context.localPaths.get(provisional.key) : undefined; if ( @@ -249,13 +267,25 @@ export class WorkspacePullService { !context.expectedByKey.get(provisional.key)?.digest && provisionalPath ) { + if (visibleDigest !== entry.contentDigest) { + context.replacedLocalKeys.add(provisional.key); + return { + nodeKey: entry.nodeKey, + path: entry.path, + localPath: provisionalPath, + status: "modified", + entry, + localNode: provisional, + localChange: provisionalChange, + conflict: `Server-created file no longer matches the local workspace: ${entry.path}`, + }; + } context.replacedLocalKeys.add(provisional.key); return { nodeKey: entry.nodeKey, path: entry.path, localPath: provisionalPath, - status: - context.snapshot.visibleFiles.get(provisionalPath) === entry.contentDigest ? "added" : "modified", + status: "added", entry, localNode: provisional, localChange: provisionalChange, @@ -308,7 +338,7 @@ export class WorkspacePullService { remoteByKey: Map, context: PullOperationContext ): PullOperation[] { - return localNodes.flatMap(node => { + return localNodes.flatMap((node) => { if (remoteByKey.has(node.key) || context.replacedLocalKeys.has(node.key)) { return []; } @@ -339,7 +369,7 @@ export class WorkspacePullService { return selectWorkspaceCandidates( root, paths, - operations.map(operation => ({ + operations.map((operation) => ({ value: operation, paths: [operation.path, operation.localPath].filter((value): value is string => Boolean(value)), })) @@ -468,7 +498,7 @@ export class WorkspacePullService { ): boolean { const source = sourcePath.toLowerCase(); const target = targetPath.toLowerCase(); - return appliedFolderMoves.some(move => { + return appliedFolderMoves.some((move) => { const sourceRoot = move.sourcePath.toLowerCase(); if (!source.startsWith(`${sourceRoot}/`)) { return false; @@ -517,8 +547,8 @@ export class WorkspacePullService { const metadataDirectory = path.join(root, ".package", "nodes"); const hasChildren = fs .readdirSync(metadataDirectory) - .filter(file => file.endsWith(".json") && file !== `${operation.nodeKey}.json`) - .some(file => { + .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; @@ -583,7 +613,7 @@ export class WorkspacePullService { } const parentKey = entry.metadata.parentNodeKey; if (parentKey) { - const parent = manifest.nodes.find(candidate => candidate.nodeKey === parentKey); + const parent = manifest.nodes.find((candidate) => candidate.nodeKey === parentKey); if (parent?.kind !== "folder") { throw new GracefulError(`Workspace manifest has an invalid parent for node ${entry.nodeKey}.`); } @@ -619,14 +649,14 @@ export class WorkspacePullService { } const keys = new Set(); const paths = new Set(); - manifest.nodes.forEach(entry => { + manifest.nodes.forEach((entry) => { const foldedPath = entry.path?.toLowerCase(); const metadata = entry.metadata as unknown as Record; if ( !entry.nodeKey || !this.validManifestPath(entry.path) || entry.metadata?.key !== entry.nodeKey || - FORBIDDEN_METADATA_FIELDS.some(field => field in metadata) || + FORBIDDEN_METADATA_FIELDS.some((field) => field in metadata) || this.hasLegacyFilesystemName(entry.metadata) || "body" in (entry as unknown as Record) || (entry.kind !== "file" && entry.kind !== "folder") || @@ -647,7 +677,7 @@ export class WorkspacePullService { keys.add(entry.nodeKey); paths.add(foldedPath); }); - const byKey = new Map(manifest.nodes.map(entry => [entry.nodeKey, entry])); + 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)) { @@ -668,15 +698,15 @@ export class WorkspacePullService { resolving.delete(entry.nodeKey); resolved.add(entry.nodeKey); }; - manifest.nodes.forEach(entry => { + manifest.nodes.forEach((entry) => { resolve(entry, new Set()); this.validateDocument(entry, manifest); }); - const projectedPaths = projectWorkspacePaths(manifest.nodes.map(entry => entry.metadata)); - if (manifest.nodes.some(entry => projectedPaths.get(entry.nodeKey) !== entry.path)) { + const projectedPaths = projectWorkspacePaths(manifest.nodes.map((entry) => 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."); } - if (!Object.values(manifest.documents).every(value => typeof value === "string")) { + if (!Object.values(manifest.documents).every((value) => typeof value === "string")) { throw new GracefulError("Unsupported workspace manifest."); } } @@ -703,12 +733,12 @@ export class WorkspacePullService { return ( first !== ".package" && first !== ".git" && - segments.every(segment => Boolean(segment) && segment !== "." && segment !== "..") + segments.every((segment) => Boolean(segment) && segment !== "." && segment !== "..") ); } private visibleAt(snapshot: WorkspaceSnapshot, filePath: string): boolean { - return [...snapshot.visibleFiles.keys()].some(value => value.toLowerCase() === filePath.toLowerCase()); + return [...snapshot.visibleFiles.keys()].some((value) => value.toLowerCase() === filePath.toLowerCase()); } private sameMetadata(left: WorkspaceNodeMetadata, right: WorkspaceNodeMetadata): boolean { @@ -717,7 +747,7 @@ export class WorkspacePullService { private sorted(value: unknown): unknown { if (Array.isArray(value)) { - return value.map(item => this.sorted(item)); + return value.map((item) => this.sorted(item)); } if (value && typeof value === "object") { return Object.fromEntries( diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index 0a3ea1b1..77846afb 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -41,6 +41,10 @@ const NON_SEMANTIC_NODE_FIELDS = [ "prodDraftId", "archivedDraftId", "packageNodeId", + "packageKey", + "packageNodeKey", + "branchKey", + "spaceId", "creationDate", "changeDate", "deletedAt", @@ -159,13 +163,11 @@ export class WorkspaceService { } const { packageKey, restoredObservation } = await this.pullTarget(root, projectKey, localState); const remote = await this.api.manifest(packageKey); - if (localState?.refreshRequired) { - localState = { ...localState }; - delete localState.refreshRequired; - this.writeState(root, localState); - } const pullService = new WorkspacePullService(this.api); if (!localState) { + if (remote.manifest.nodes.length === 0) { + fs.mkdirSync(path.join(root, ".package", "nodes"), { recursive: true }); + } const initial: WorkspaceState = { schemaVersion: 1, activePackageKey: packageKey, @@ -189,23 +191,23 @@ export class WorkspaceService { } const result = await pullService.pull( root, - this.snapshot(root), + this.snapshot(root, recoveringCreateKeys), this.nodes(root), paths, remote.manifest, recoveringCreateKeys ); - if (recoveringCreateKeys && paths.length > 0) { + const failed = result.outcomes.filter((outcome) => !outcome.success); + if (recoveringCreateKeys && (paths.length > 0 || failed.length > 0)) { result.state.refreshRequired = true; } this.writeState(root, result.state); - result.outcomes.forEach(outcome => + result.outcomes.forEach((outcome) => logger.info( `${outcome.success ? "succeeded" : "failed"}: ${outcome.status} ${outcome.path}` + (outcome.error ? ` (${outcome.error})` : "") ) ); - const failed = result.outcomes.filter(outcome => !outcome.success); if (failed.length > 0) { throw new GracefulError(`Workspace pull failed for ${failed.length} node(s).`); } @@ -242,7 +244,7 @@ export class WorkspaceService { if (changes.length === 0) { logger.info("Workspace is clean."); } else { - changes.forEach(change => logger.info(`${change.status}: ${change.path}`)); + changes.forEach((change) => logger.info(`${change.status}: ${change.path}`)); } return changes; } @@ -271,14 +273,14 @@ export class WorkspaceService { delete invalidatedBeforePush.serverRevision; this.writeState(root, invalidatedBeforePush); const outcomes: WorkspacePushOutcome[] = await new WorkspacePushService(this.api).push(root, snapshot, paths); - outcomes.forEach(outcome => + 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 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, @@ -318,10 +320,10 @@ export class WorkspaceService { const root = this.root(); await this.synchronizeGitTarget(root, true); const snapshot = this.snapshot(root); - if (snapshot.changes.some(change => change.status === "unresolved")) { + if (snapshot.changes.some((change) => change.status === "unresolved")) { throw new GracefulError("Workspace has unresolved file identities. Record the intended moves before push."); } - const zipPath = fileService.zipDirectoryAsSinglePackage(root, filePath => { + const zipPath = fileService.zipDirectoryAsSinglePackage(root, (filePath) => { const folded = filePath.toLowerCase(); return ( folded !== ".git" && @@ -339,11 +341,11 @@ export class WorkspaceService { const moves = Object.fromEntries( snapshot.changes .filter( - change => + (change) => Boolean(change.nodeKey) && (change.status === "moved" || change.status === "moved, modified") ) - .map(change => [change.nodeKey!, change.path]) + .map((change) => [change.nodeKey!, change.path]) ); if (Object.keys(moves).length > 0) { form.append("moveMappings", JSON.stringify({ moves }), { contentType: "application/json" }); @@ -385,7 +387,7 @@ export class WorkspaceService { throw new GracefulError(`Tracked file not found: ${sourcePath}`); } const targetOwned = snapshot.expectedFiles.some( - file => file.nodeKey !== tracked.nodeKey && file.path.toLowerCase() === targetPath.toLowerCase() + (file) => file.nodeKey !== tracked.nodeKey && file.path.toLowerCase() === targetPath.toLowerCase() ); if (targetOwned) { throw new GracefulError(`Target path is already tracked: ${targetPath}`); @@ -415,10 +417,10 @@ export class WorkspaceService { logger.info(`Moved: ${sourcePath} -> ${targetPath}`); } - private snapshot(root: string): WorkspaceSnapshot { + private snapshot(root: string, allowRefreshRequired: boolean = false): WorkspaceSnapshot { const projectKey = this.packageIdentity(root).projectKey; const state = this.state(root); - if (state.refreshRequired) { + if (state.refreshRequired && !allowRefreshRequired) { throw new GracefulError("Workspace synchronization state needs refresh. Run workspace pull."); } if (BranchUtils.extractProjectKey(state.activePackageKey) !== projectKey) { @@ -438,11 +440,11 @@ export class WorkspaceService { private expectedFiles(root: string, state: WorkspaceState, packageKey: string): ExpectedWorkspaceFile[] { const nodes = this.nodes(root); - const byKey = new Map(nodes.map(node => [node.key, node])); + const byKey = new Map(nodes.map((node) => [node.key, node])); const pathByKey = projectWorkspacePaths(nodes, packageKey); const expected = nodes - .filter(node => !this.isFolder(node)) - .map(node => { + .filter((node) => !this.isFolder(node)) + .map((node) => { const baseline = state.baselineDigests[node.key]; if (baseline && !/^sha256:[0-9a-f]{64}$/.test(baseline)) { throw new GracefulError(`Invalid baseline digest for node ${node.key}.`); @@ -455,7 +457,7 @@ export class WorkspaceService { return { nodeKey: node.key, path: sourcePath, digest: baseline }; }); const foldedPaths = new Set(); - expected.forEach(file => { + expected.forEach((file) => { const foldedPath = file.path.toLowerCase(); if (foldedPaths.has(foldedPath)) { throw new GracefulError(`Duplicate workspace path in node metadata: ${file.path}`); @@ -485,9 +487,9 @@ export class WorkspaceService { } return fs .readdirSync(directory, { withFileTypes: true }) - .filter(entry => entry.isFile() && entry.name.endsWith(".json")) + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) .sort((left, right) => left.name.localeCompare(right.name)) - .map(entry => { + .map((entry) => { const node = JSON.parse( fs.readFileSync(path.join(directory, entry.name), "utf-8") ) as WorkspaceNodeMetadata; @@ -497,7 +499,7 @@ export class WorkspaceService { !node.name || !node.type || `${node.key}.json` !== entry.name || - NON_SEMANTIC_NODE_FIELDS.some(field => field in fields) || + NON_SEMANTIC_NODE_FIELDS.some((field) => field in fields) || this.hasLegacyFilesystemName(node) ) { throw new GracefulError(`Invalid node metadata file: ${entry.name}`); @@ -512,7 +514,7 @@ export class WorkspaceService { const visit = (directory: string, relativeDirectory: string): void => { fs.readdirSync(directory, { withFileTypes: true }) .sort((left, right) => left.name.localeCompare(right.name)) - .forEach(entry => { + .forEach((entry) => { if ( !relativeDirectory && (entry.name.toLowerCase() === ".package" || entry.name.toLowerCase() === ".git") @@ -546,23 +548,23 @@ export class WorkspaceService { private trackedFile(snapshot: WorkspaceSnapshot, sourcePath: string): ExpectedWorkspaceFile | undefined { const foldedSourcePath = sourcePath.toLowerCase(); - const expected = snapshot.expectedFiles.find(file => file.path.toLowerCase() === foldedSourcePath); + 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 + (file) => this.moveHintTarget(snapshot.state.moveHints[file.nodeKey])?.toLowerCase() === foldedSourcePath ); if (hinted) { return hinted; } const classified = snapshot.changes.find( - change => + (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; + return classified ? snapshot.expectedFiles.find((file) => file.nodeKey === classified.nodeKey) : undefined; } private validateParentMove( @@ -578,7 +580,7 @@ export class WorkspaceService { 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.key) === targetParentPath); + const targetParent = nodes.find((node) => this.isFolder(node) && paths.get(node.key) === targetParentPath); const targetParentKey = targetParentPath ? targetParent?.key || `__workspace_target__:${targetParentPath}` : undefined; @@ -653,7 +655,7 @@ export class WorkspaceService { throw new GracefulError("Archive does not contain Pacman package metadata."); } if ( - zip.getEntries().some(entry => { + zip.getEntries().some((entry) => { const folded = entry.entryName.toLowerCase(); return folded === ".package/local" || folded.startsWith(".package/local/"); }) @@ -666,6 +668,7 @@ export class WorkspaceService { throw new GracefulError("Archive project key does not match the requested project."); } this.validateGitignore(temporary); + fs.mkdirSync(path.join(temporary, ".package", "nodes"), { recursive: true }); this.hydrateState(temporary, download.eTag, packageKey, observation); const snapshot = this.snapshot(temporary); if (snapshot.changes.length !== 0) { @@ -700,11 +703,11 @@ export class WorkspaceService { moveHints: {}, }; const remoteFiles = new Map( - this.expectedFiles(remoteRoot, emptyState, packageKey).map(file => [file.nodeKey, file]) + this.expectedFiles(remoteRoot, emptyState, packageKey).map((file) => [file.nodeKey, file]) ); const localFiles = this.expectedFiles(root, emptyState, packageKey); - const remoteNodes = new Map(this.nodes(remoteRoot).map(node => [node.key, node])); - this.nodes(root).forEach(node => { + const remoteNodes = new Map(this.nodes(remoteRoot).map((node) => [node.key, node])); + this.nodes(root).forEach((node) => { const remote = remoteNodes.get(node.key); if (remote && this.isFolder(remote) !== this.isFolder(node)) { throw new GracefulError(`Node metadata type conflicts with the server for ${node.key}.`); @@ -712,8 +715,11 @@ export class WorkspaceService { }); const moveHints: Record = Object.fromEntries( localFiles - .filter(file => remoteFiles.has(file.nodeKey) && remoteFiles.get(file.nodeKey)!.path !== file.path) - .map(file => [file.nodeKey, { sourcePath: remoteFiles.get(file.nodeKey)!.path, targetPath: file.path }]) + .filter((file) => remoteFiles.has(file.nodeKey) && remoteFiles.get(file.nodeKey)!.path !== file.path) + .map((file) => [ + file.nodeKey, + { sourcePath: remoteFiles.get(file.nodeKey)!.path, targetPath: file.path }, + ]) ); const reconciledState: WorkspaceState = { ...remoteState, @@ -753,7 +759,7 @@ export class WorkspaceService { moveHints: {}, }; const baselineDigests = Object.fromEntries( - this.expectedFiles(root, emptyState, packageKey).map(file => { + this.expectedFiles(root, emptyState, packageKey).map((file) => { const absolute = this.resolveVisiblePath(root, file.path); if (!fs.existsSync(absolute) || !fs.lstatSync(absolute).isFile()) { throw new GracefulError(`Archive is missing visible content for node ${file.nodeKey}.`); @@ -798,8 +804,8 @@ export class WorkspaceService { } catch (error) { try { fs.readdirSync(root) - .filter(entry => entry !== ".git") - .forEach(entry => fs.rmSync(path.join(root, entry), { recursive: true, force: true })); + .filter((entry) => entry !== ".git") + .forEach((entry) => fs.rmSync(path.join(root, entry), { recursive: true, force: true })); this.moveEntries(backup, root); } catch (restoreError) { preserveBackup = true; @@ -819,9 +825,9 @@ export class WorkspaceService { private moveEntries(source: string, target: string, excluded: Set = new Set()): void { fs.readdirSync(source) - .filter(entry => !excluded.has(entry)) + .filter((entry) => !excluded.has(entry)) .sort((left, right) => left.localeCompare(right)) - .forEach(entry => fs.renameSync(path.join(source, entry), path.join(target, entry))); + .forEach((entry) => fs.renameSync(path.join(source, entry), path.join(target, entry))); } private sameFile(source: string, target: string): boolean { @@ -860,14 +866,14 @@ export class WorkspaceService { typeof parsed.baselineDigests !== "object" || Array.isArray(parsed.baselineDigests) || !Object.values(parsed.baselineDigests).every( - value => typeof value === "string" && /^sha256:[0-9a-f]{64}$/.test(value) + (value) => typeof value === "string" && /^sha256:[0-9a-f]{64}$/.test(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) + (value) => typeof value === "string" || this.isStructuredMoveHint(value) ))) || (parsed.git !== undefined && (!parsed.git || @@ -956,21 +962,21 @@ export class WorkspaceService { ): Record { const completedNodeKeys = new Set( outcomes - .filter(outcome => outcome.success || outcome.remoteChanged) - .flatMap(outcome => (outcome.nodeKey ? [outcome.nodeKey] : [])) + .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) => !outcome.success && !outcome.remoteChanged && outcome.nodeKey && (outcome.status === "moved" || outcome.status === "moved, modified") ) - .forEach(outcome => { + .forEach((outcome) => { retained[outcome.nodeKey!] ||= hints[outcome.nodeKey!] || outcome.path; }); return retained; @@ -1096,8 +1102,8 @@ export class WorkspaceService { observation: WorkspaceGitObservation ): Promise { const remote = await this.api.manifest(packageKey); - const localByKey = new Map(this.nodes(root).map(node => [node.key, node])); - remote.manifest.nodes.forEach(entry => { + const localByKey = new Map(this.nodes(root).map((node) => [node.key, node])); + remote.manifest.nodes.forEach((entry) => { const local = localByKey.get(entry.nodeKey); if (local && this.isFolder(local) !== (entry.kind === "folder")) { throw new GracefulError(`Node metadata type conflicts with the server for ${entry.nodeKey}.`); @@ -1120,15 +1126,15 @@ export class WorkspaceService { private withRemotePathHints(root: string, state: WorkspaceState, manifest: WorkspaceManifest): WorkspaceState { const remotePathByNodeKey = new Map( - manifest.nodes.filter(entry => entry.kind === "file").map(entry => [entry.nodeKey, entry.path]) + manifest.nodes.filter((entry) => entry.kind === "file").map((entry) => [entry.nodeKey, entry.path]) ); const moveHints: Record = Object.fromEntries( this.expectedFiles(root, state, state.activePackageKey) - .filter(file => { + .filter((file) => { const remotePath = remotePathByNodeKey.get(file.nodeKey); return remotePath && remotePath.toLowerCase() !== file.path.toLowerCase(); }) - .map(file => [ + .map((file) => [ file.nodeKey, { sourcePath: remotePathByNodeKey.get(file.nodeKey)!, targetPath: file.path }, ]) diff --git a/tests/commands/workspace/module.spec.ts b/tests/commands/workspace/module.spec.ts index 01877654..2b9726eb 100644 --- a/tests/commands/workspace/module.spec.ts +++ b/tests/commands/workspace/module.spec.ts @@ -2,6 +2,7 @@ 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"; @@ -62,4 +63,19 @@ describe("Workspace module", () => { expect(push).toHaveBeenCalledWith(["target", "other.md"], { full: false, overwrite: false }); 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.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index 22752b20..11064b5b 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -53,7 +53,7 @@ function eTag(value: string): string { function metadata(files: TestFile[]): Record { const nodes: Record = {}; const folders = new Map(); - files.forEach(file => { + files.forEach((file) => { const segments = file.path.split("/"); let parentNodeKey: string | null = null; for (let index = 0; index < segments.length - 1; index += 1) { @@ -94,7 +94,7 @@ function state( activePackageKey: PACKAGE_KEY, activeBranch: "main", serverRevision, - baselineDigests: Object.fromEntries(files.map(file => [file.nodeKey, digest(file.content)])), + baselineDigests: Object.fromEntries(files.map((file) => [file.nodeKey, digest(file.content)])), moveHints, }; } @@ -107,7 +107,14 @@ function archive(files: TestFile[]): Buffer { Object.entries(metadata(files)).forEach(([nodeKey, node]) => { zip.addFile(`.package/nodes/${nodeKey}.json`, Buffer.from(JSON.stringify(node))); }); - files.forEach(file => zip.addFile(file.path, Buffer.from(file.content))); + files.forEach((file) => zip.addFile(file.path, Buffer.from(file.content))); + return zip.toBuffer(); +} + +function emptyArchiveWithoutNodeMetadata(): Buffer { + const zip = new AdmZip(); + zip.addFile(".package/.gitignore", Buffer.from("local/\n")); + zip.addFile(".package/package.json", Buffer.from(JSON.stringify({ schemaVersion: 1, projectKey: PACKAGE_KEY }))); return zip.toBuffer(); } @@ -132,8 +139,8 @@ function manifest(files: TestFile[]): Buffer { }; return Buffer.from( JSON.stringify({ - nodes: Object.values(nodes).map(node => { - const file = files.find(candidate => candidate.nodeKey === node.key); + nodes: Object.values(nodes).map((node) => { + const file = files.find((candidate) => candidate.nodeKey === node.key); return file ? { nodeKey: node.key, @@ -171,7 +178,7 @@ function writeWorkspace( Object.entries(metadata(files)).forEach(([nodeKey, node]) => { fs.writeFileSync(path.join(process.cwd(), ".package", "nodes", `${nodeKey}.json`), JSON.stringify(node)); }); - files.forEach(file => { + 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); }); @@ -185,6 +192,7 @@ function removeWorkspace(): void { "Pages", "Other", "New", + "New.md", "Bulk", "Added", "Deleted", @@ -195,7 +203,8 @@ function removeWorkspace(): void { "Unselected", PACKAGE_KEY, "branch-workspace", - ].forEach(entry => fs.rmSync(path.join(process.cwd(), entry), { recursive: true, force: true })); + "empty-workspace", + ].forEach((entry) => fs.rmSync(path.join(process.cwd(), entry), { recursive: true, force: true })); } function mockGit( @@ -240,7 +249,7 @@ describe("Workspace service", () => { baselineDigests: { "node-1": digest("original") }, moveHints: {}, }); - const cloneRename = rename.mock.calls.find(call => call[1] === path.join(process.cwd(), PACKAGE_KEY)); + 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 { @@ -256,6 +265,23 @@ describe("Workspace service", () => { ); }); + it("clones an empty package and hydrates an empty nodes directory", async () => { + mockAxiosGet(ARCHIVE_URL, emptyArchiveWithoutNodeMetadata(), { etag: eTag("empty-revision") }); + + 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", + serverRevision: eTag("empty-revision"), + baselineDigests: {}, + moveHints: {}, + }); + }); + it("clones a selected branch while keeping stable project identity", async () => { mockAxiosGet( archiveUrl(BRANCH_PACKAGE_KEY), @@ -489,7 +515,7 @@ describe("Workspace service", () => { const bodyReads = (mockedAxiosInstance.get as jest.Mock).mock.calls.filter(([url]) => url !== manifestUrl()); expect(bodyReads).toHaveLength(3); - changedIndexes.forEach(index => { + changedIndexes.forEach((index) => { expect(fs.readFileSync(path.join(process.cwd(), remote[index].path), "utf-8")).toBe(`remote-${index}`); }); }); @@ -500,10 +526,10 @@ describe("Workspace service", () => { { 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` })); + const remote = original.map((file) => ({ ...file, content: `${file.content} remote` })); writeWorkspace(original); mockManifest(remote); - remote.slice(0, 2).forEach(file => { + remote.slice(0, 2).forEach((file) => { mockAxiosGet(fileUrl(file.path), Buffer.from(file.content), { etag: eTag(file.content) }); }); @@ -553,10 +579,10 @@ describe("Workspace service", () => { 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"); + const sharedFolder = remoteManifest.nodes.find((node) => node.path === "Target/Shared"); sharedFolder.nodeKey = sharedKey; sharedFolder.metadata.key = sharedKey; - remoteManifest.nodes.find(node => node.nodeKey === "node-1").metadata.parentNodeKey = 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) }); @@ -799,7 +825,7 @@ describe("Workspace service", () => { expect(backup).toBeDefined(); expect(fs.existsSync(backup!)).toBe(true); - fs.readdirSync(backup!).forEach(entry => { + fs.readdirSync(backup!).forEach((entry) => { originalRename(path.join(backup!, entry), path.join(process.cwd(), entry)); }); fs.rmSync(backup!, { recursive: true, force: true }); @@ -866,7 +892,7 @@ describe("Workspace service", () => { const changes = new WorkspaceService(testContext).status(); expect(changes).toHaveLength(4); - expect(changes.every(change => change.status === "unresolved")).toBe(true); + expect(changes.every((change) => change.status === "unresolved")).toBe(true); }); it("reports clean, modified, added, and deleted files", () => { @@ -1057,10 +1083,10 @@ describe("Workspace service", () => { 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 => { + const exists = jest.spyOn(fs, "existsSync").mockImplementation((candidate) => { return candidate.toString() === target || existsSync(candidate); }); - const lstat = jest.spyOn(fs, "lstatSync").mockImplementation(candidate => { + const lstat = jest.spyOn(fs, "lstatSync").mockImplementation((candidate) => { return candidate.toString() === target ? lstatSync(source) : lstatSync(candidate); }); @@ -1222,8 +1248,8 @@ describe("Workspace service", () => { { 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 => { + 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(fileUrl(file.path), { path: file.path, @@ -1271,7 +1297,7 @@ describe("Workspace service", () => { expect(fs.existsSync(path.join(process.cwd(), ".package/nodes/server-node.json"))).toBe(true); }); - it("recovers a server-assigned node key after post-create refresh fails", async () => { + 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]); @@ -1290,15 +1316,52 @@ describe("Workspace service", () => { fs.writeFileSync(path.join(process.cwd(), local.path), "newer local edit"); mockManifest([remote]); - await service.pull(); + 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([{ path: local.path, status: "modified" }]); + 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" }; + writeWorkspace([]); + fs.writeFileSync(path.join(process.cwd(), remote.path), remote.content); + mockAxiosPut(fileUrl(remote.path), { + 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()).rejects.toThrow("local synchronization state could not be refreshed"); + mockManifest([remote]); + 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 () => { @@ -1321,7 +1384,7 @@ describe("Workspace service", () => { { 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`)); + 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(fileUrl("Guides/One.md"), { From 2b424e4b7f0a7826e22f2b2380c166c611ed77c2 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Thu, 20 Aug 2026 19:28:25 +0200 Subject: [PATCH 38/46] Reduce workspace pull complexity Includes-AI-Code: true --- src/commands/workspace/workspace.service.ts | 55 ++++++++++++--------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index 77846afb..7366a852 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -165,28 +165,7 @@ export class WorkspaceService { const remote = await this.api.manifest(packageKey); const pullService = new WorkspacePullService(this.api); if (!localState) { - if (remote.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: {}, - moveHints: {}, - }; - if (restoredObservation) { - initial.git = restoredObservation; - } - this.writeState( - root, - this.withRemotePathHints( - root, - pullService.hydrateBaseline(initial, packageKey, initial.activeBranch, remote.manifest), - remote.manifest - ) - ); - logger.info(`Pulled ${packageKey}.`); + this.hydrateInitialPull(root, projectKey, packageKey, restoredObservation, remote.manifest, pullService); return; } const result = await pullService.pull( @@ -214,6 +193,38 @@ export class WorkspaceService { logger.info(`Pulled ${packageKey}.`); } + private hydrateInitialPull( + root: string, + projectKey: string, + packageKey: string, + restoredObservation: WorkspaceGitObservation | undefined, + manifest: WorkspaceManifest, + pullService: WorkspacePullService + ): void { + 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: {}, + moveHints: {}, + }; + if (restoredObservation) { + initial.git = restoredObservation; + } + this.writeState( + root, + this.withRemotePathHints( + root, + pullService.hydrateBaseline(initial, packageKey, initial.activeBranch, manifest), + manifest + ) + ); + logger.info(`Pulled ${packageKey}.`); + } + private async pullFull(): Promise { const root = this.root(); await this.synchronizeGitTarget(root, false); From 35692cfde4fce9efa8f10fee3a46b2dabf3df0e0 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Fri, 21 Aug 2026 09:56:47 +0200 Subject: [PATCH 39/46] Update beta workspace manifest handling Includes-AI-Code: true --- src/commands/workspace/workspace-api.ts | 4 +- .../workspace/workspace-path-projector.ts | 70 ++++---- .../workspace/workspace-pull.service.ts | 169 ++++++++++-------- src/commands/workspace/workspace.models.ts | 8 +- src/commands/workspace/workspace.service.ts | 139 +++++++------- .../workspace-path-projector.spec.ts | 30 ++-- .../workspace/workspace.service.spec.ts | 114 +++++++----- 7 files changed, 292 insertions(+), 242 deletions(-) diff --git a/src/commands/workspace/workspace-api.ts b/src/commands/workspace/workspace-api.ts index 69357d41..f7661ab3 100644 --- a/src/commands/workspace/workspace-api.ts +++ b/src/commands/workspace/workspace-api.ts @@ -40,8 +40,8 @@ export class WorkspaceApi { `/pacman/api/core/staging/packages/${encodeURIComponent(packageKey)}/files` ); const eTag = response.headers.etag; - if (typeof eTag !== "string" || !/^"sha256:[0-9a-f]{64}"$/.test(eTag)) { - throw new GracefulError("Workspace manifest response does not contain a valid package ETag."); + if (typeof eTag !== "string" || !eTag) { + throw new GracefulError("Workspace manifest response does not contain a package ETag."); } try { return { manifest: JSON.parse(response.data.toString("utf-8")) as WorkspaceManifest, eTag }; diff --git a/src/commands/workspace/workspace-path-projector.ts b/src/commands/workspace/workspace-path-projector.ts index e419fd20..d5eadb97 100644 --- a/src/commands/workspace/workspace-path-projector.ts +++ b/src/commands/workspace/workspace-path-projector.ts @@ -1,41 +1,41 @@ import { createHash } from "node:crypto"; import * as path from "node:path"; import { GracefulError } from "../../core/utils/logger"; -import { WorkspaceNodeMetadata } from "./workspace.models"; +import { WorkspaceNode } from "./workspace.models"; const ROOT = "\u0000root"; -export function projectWorkspacePaths(nodes: WorkspaceNodeMetadata[], packageKey?: string): Map { - const byKey = new Map(); +export function projectWorkspacePaths(nodes: WorkspaceNode[], packageKey?: string): Map { + const byKey = new Map(); nodes.forEach(node => { - if (byKey.has(node.key)) { - throw new GracefulError(`Duplicate node metadata key: ${node.key}.`); + if (byKey.has(node.nodeKey)) { + throw new GracefulError(`Duplicate node metadata key: ${node.nodeKey}.`); } - byKey.set(node.key, node); + byKey.set(node.nodeKey, node); }); - const candidates = new Map(nodes.map(node => [node.key, candidateSegment(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: WorkspaceNodeMetadata): string => { - const cached = paths.get(node.key); + const resolve = (node: WorkspaceNode): string => { + const cached = paths.get(node.nodeKey); if (cached) { return cached; } - if (resolving.has(node.key)) { - throw new GracefulError(`Circular node hierarchy at ${node.key}.`); + if (resolving.has(node.nodeKey)) { + throw new GracefulError(`Circular node hierarchy at ${node.nodeKey}.`); } - resolving.add(node.key); + 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.key}.`); + throw new GracefulError(`Invalid parent metadata for node ${node.nodeKey}.`); } - const segment = projected.get(node.key)!; + const segment = projected.get(node.nodeKey)!; const value = parent ? `${resolve(parent)}/${segment}` : segment; validateVisiblePath(value); - resolving.delete(node.key); - paths.set(node.key, value); + resolving.delete(node.nodeKey); + paths.set(node.nodeKey, value); return value; }; nodes.forEach(resolve); @@ -43,30 +43,30 @@ export function projectWorkspacePaths(nodes: WorkspaceNodeMetadata[], packageKey } export function projectedLeafAfterMove( - nodes: WorkspaceNodeMetadata[], + nodes: WorkspaceNode[], nodeKey: string, targetParentKey: string | undefined, packageKey?: string ): string { - if (!nodes.some(node => node.key === nodeKey)) { + if (!nodes.some(node => node.nodeKey === nodeKey)) { throw new GracefulError(`Tracked node metadata is missing: ${nodeKey}.`); } const moved = nodes.map(node => - node.key === nodeKey ? { ...node, parentNodeKey: targetParentKey || null } : node + node.nodeKey === nodeKey ? { ...node, parentNodeKey: targetParentKey || null } : node ); - const candidates = new Map(moved.map(node => [node.key, candidateSegment(node)])); + const candidates = new Map(moved.map(node => [node.nodeKey, candidateSegment(node)])); return projectedSegments(moved, candidates, packageKey).get(nodeKey)!; } function projectedSegments( - nodes: WorkspaceNodeMetadata[], + 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.key)!.toLowerCase()).forEach(group => { + groupBy(siblings, node => candidates.get(node.nodeKey)!.toLowerCase()).forEach(group => { if (group.length > 1) { disambiguate(group, siblings, candidates, projected); } @@ -76,27 +76,27 @@ function projectedSegments( } function disambiguate( - group: WorkspaceNodeMetadata[], - siblings: WorkspaceNodeMetadata[], + group: WorkspaceNode[], + siblings: WorkspaceNode[], candidates: Map, projected: Map ): void { - const hashes = new Map(group.map(node => [node.key, sha256(node.key)])); - const groupKeys = new Set(group.map(node => node.key)); + 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.key)).map(node => candidates.get(node.key)!.toLowerCase()) + 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.key)!.slice(0, length))); + 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.key)!, + candidates.get(node.nodeKey)!, isFolder(node), - hashes.get(node.key)!.slice(0, length) + hashes.get(node.nodeKey)!.slice(0, length) ).toLowerCase() ) ); @@ -107,13 +107,13 @@ function disambiguate( } group.forEach(node => projected.set( - node.key, - addSuffix(candidates.get(node.key)!, isFolder(node), hashes.get(node.key)!.slice(0, length)) + node.nodeKey, + addSuffix(candidates.get(node.nodeKey)!, isFolder(node), hashes.get(node.nodeKey)!.slice(0, length)) ) ); } -function candidateSegment(node: WorkspaceNodeMetadata): string { +function candidateSegment(node: WorkspaceNode): string { const extension = isFolder(node) ? "" : `.${fileExtension(node.type)}`; const segment = `${node.name}${extension}`; if ( @@ -127,7 +127,7 @@ function candidateSegment(node: WorkspaceNodeMetadata): string { return codePoint < 32 || codePoint === 127; }) ) { - throw new GracefulError(`Invalid derived filesystem name for node ${node.key}.`); + throw new GracefulError(`Invalid derived filesystem name for node ${node.nodeKey}.`); } return segment; } @@ -163,7 +163,7 @@ function normalizedParent(parentNodeKey: string | null | undefined, packageKey?: return !parentNodeKey || parentNodeKey === packageKey ? ROOT : parentNodeKey; } -function isFolder(node: WorkspaceNodeMetadata): boolean { +function isFolder(node: WorkspaceNode): boolean { return node.type.toUpperCase() === "FOLDER"; } diff --git a/src/commands/workspace/workspace-pull.service.ts b/src/commands/workspace/workspace-pull.service.ts index 7a589162..2efafc15 100644 --- a/src/commands/workspace/workspace-pull.service.ts +++ b/src/commands/workspace/workspace-pull.service.ts @@ -10,6 +10,7 @@ import { ExpectedWorkspaceFile, WorkspaceManifest, WorkspaceManifestNode, + WorkspaceNode, WorkspaceNodeMetadata, WorkspacePullOutcome, WorkspacePullStatus, @@ -36,7 +37,7 @@ interface PullOperation { localPath?: string; status: WorkspacePullStatus; entry?: WorkspaceManifestNode; - localNode?: WorkspaceNodeMetadata; + localNode?: WorkspaceNode; localChange?: ClassifiedWorkspaceChange; replacedNodeKey?: string; conflict?: string; @@ -45,9 +46,9 @@ interface PullOperation { interface PullOperationContext { snapshot: WorkspaceSnapshot; - localByKey: Map; + localByKey: Map; localPaths: Map; - localByPath: Map; + localByPath: Map; expectedByKey: Map; changeByKey: Map; replacedLocalKeys: Set; @@ -70,7 +71,7 @@ export class WorkspacePullService { public async pull( root: string, snapshot: WorkspaceSnapshot, - localNodes: WorkspaceNodeMetadata[], + localNodes: WorkspaceNode[], paths: string[], manifest: WorkspaceManifest, recoveringCreateKeys: boolean = false @@ -128,8 +129,8 @@ export class WorkspacePullService { activeBranch, baselineDigests: Object.fromEntries( manifest.nodes - .filter((entry) => entry.kind === "file") - .map((entry) => [entry.nodeKey, entry.contentDigest!]) + .filter(entry => !this.isFolder(entry.metadata)) + .map(entry => [entry.nodeKey, entry.contentDigest!]) ), moveHints: {}, }; @@ -152,7 +153,7 @@ export class WorkspacePullService { }> ): WorkspaceState { this.validateManifest(manifest); - const byNodeKey = new Map(manifest.nodes.map((entry) => [entry.nodeKey, entry])); + const byNodeKey = new Map(manifest.nodes.map(entry => [entry.nodeKey, entry])); const next: WorkspaceState = { ...state, baselineDigests: { ...state.baselineDigests }, @@ -161,8 +162,8 @@ export class WorkspacePullService { delete next.serverRevision; delete next.refreshRequired; outcomes - .filter((outcome) => (outcome.success || outcome.remoteChanged) && outcome.nodeKey) - .forEach((outcome) => { + .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); @@ -179,7 +180,7 @@ export class WorkspacePullService { return; } this.writeMetadataWithAncestors(root, entry, manifest); - if (entry.kind === "file") { + if (!this.isFolder(entry.metadata)) { next.baselineDigests[nodeKey] = entry.contentDigest!; } delete next.moveHints[nodeKey]; @@ -189,20 +190,20 @@ export class WorkspacePullService { private operations( snapshot: WorkspaceSnapshot, - localNodes: WorkspaceNodeMetadata[], + localNodes: WorkspaceNode[], manifest: WorkspaceManifest, recoveringCreateKeys: boolean ): PullOperation[] { - const localByKey = new Map(localNodes.map((node) => [node.key, node])); + 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 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] : [])) + snapshot.changes.flatMap(change => (change.nodeKey ? [[change.nodeKey, change] as const] : [])) ); - const remoteByKey = new Map(manifest.nodes.map((entry) => [entry.nodeKey, entry])); + const remoteByKey = new Map(manifest.nodes.map(entry => [entry.nodeKey, entry])); const replacedLocalKeys = new Set(); const context: PullOperationContext = { snapshot, @@ -214,7 +215,7 @@ export class WorkspacePullService { replacedLocalKeys, recoveringCreateKeys, }; - const operations = manifest.nodes.flatMap((entry) => { + const operations = manifest.nodes.flatMap(entry => { const operation = this.remoteOperation(entry, context); return operation ? [operation] : []; }); @@ -244,7 +245,7 @@ export class WorkspacePullService { )?.[1]; if ( context.recoveringCreateKeys && - entry.kind === "file" && + !this.isFolder(entry.metadata) && !provisional && visibleDigest === entry.contentDigest ) { @@ -256,19 +257,19 @@ export class WorkspacePullService { converged: true, }; } - const provisionalChange = provisional ? context.changeByKey.get(provisional.key) : undefined; - const provisionalPath = provisional ? context.localPaths.get(provisional.key) : undefined; + const provisionalChange = provisional ? context.changeByKey.get(provisional.nodeKey) : undefined; + const provisionalPath = provisional ? context.localPaths.get(provisional.nodeKey) : undefined; if ( - entry.kind === "file" && + !this.isFolder(entry.metadata) && provisional && !this.isFolder(provisional) && - provisional.type.toUpperCase() === entry.assetType?.toUpperCase() && + provisional.type.toUpperCase() === entry.metadata.type.toUpperCase() && provisionalChange?.status === "added" && - !context.expectedByKey.get(provisional.key)?.digest && + !context.expectedByKey.get(provisional.nodeKey)?.digest && provisionalPath ) { if (visibleDigest !== entry.contentDigest) { - context.replacedLocalKeys.add(provisional.key); + context.replacedLocalKeys.add(provisional.nodeKey); return { nodeKey: entry.nodeKey, path: entry.path, @@ -280,7 +281,7 @@ export class WorkspacePullService { conflict: `Server-created file no longer matches the local workspace: ${entry.path}`, }; } - context.replacedLocalKeys.add(provisional.key); + context.replacedLocalKeys.add(provisional.nodeKey); return { nodeKey: entry.nodeKey, path: entry.path, @@ -289,11 +290,11 @@ export class WorkspacePullService { entry, localNode: provisional, localChange: provisionalChange, - replacedNodeKey: provisional.key, + replacedNodeKey: provisional.nodeKey, converged: true, }; } - const occupied = entry.kind === "file" && this.visibleAt(context.snapshot, entry.path); + const occupied = !this.isFolder(entry.metadata) && this.visibleAt(context.snapshot, entry.path); return { nodeKey: entry.nodeKey, path: entry.path, @@ -305,7 +306,7 @@ export class WorkspacePullService { private changedRemoteOperation( entry: WorkspaceManifestNode, - localNode: WorkspaceNodeMetadata, + localNode: WorkspaceNode, localPath: string | undefined, context: PullOperationContext ): PullOperation { @@ -324,7 +325,7 @@ export class WorkspacePullService { } const localDigest = context.snapshot.visibleFiles.get(localChange.path); operation.converged = - entry.kind === "file" && + !this.isFolder(entry.metadata) && localChange.path.toLowerCase() === entry.path.toLowerCase() && localDigest === entry.contentDigest; if (!operation.converged) { @@ -334,22 +335,22 @@ export class WorkspacePullService { } private deletedOperations( - localNodes: WorkspaceNodeMetadata[], + localNodes: WorkspaceNode[], remoteByKey: Map, context: PullOperationContext ): PullOperation[] { - return localNodes.flatMap((node) => { - if (remoteByKey.has(node.key) || context.replacedLocalKeys.has(node.key)) { + return localNodes.flatMap(node => { + if (remoteByKey.has(node.nodeKey) || context.replacedLocalKeys.has(node.nodeKey)) { return []; } - const localPath = context.localPaths.get(node.key); + const localPath = context.localPaths.get(node.nodeKey); if (!localPath) { return []; } - const localChange = context.changeByKey.get(node.key); + const localChange = context.changeByKey.get(node.nodeKey); return [ { - nodeKey: node.key, + nodeKey: node.nodeKey, path: localPath, localPath, status: "deleted", @@ -358,7 +359,7 @@ export class WorkspacePullService { converged: localChange?.status === "deleted", conflict: localChange && localChange.status !== "deleted" - ? `Remote deletion conflicts with local changes for node ${node.key}.` + ? `Remote deletion conflicts with local changes for node ${node.nodeKey}.` : undefined, }, ]; @@ -369,7 +370,7 @@ export class WorkspacePullService { return selectWorkspaceCandidates( root, paths, - operations.map((operation) => ({ + operations.map(operation => ({ value: operation, paths: [operation.path, operation.localPath].filter((value): value is string => Boolean(value)), })) @@ -378,7 +379,7 @@ export class WorkspacePullService { private order(operations: PullOperation[]): PullOperation[] { const priority = (operation: PullOperation): number => { - if (operation.entry?.kind === "folder" && operation.status !== "deleted") { + if (operation.entry && this.isFolder(operation.entry.metadata) && operation.status !== "deleted") { return 0; } if (operation.localNode && this.isFolder(operation.localNode) && operation.status === "deleted") { @@ -418,13 +419,13 @@ export class WorkspacePullService { return; } const entry = operation.entry!; - if (entry.kind === "folder") { + if (this.isFolder(entry.metadata)) { const appliedMove = this.applyFolder(root, operation, entry); if (appliedMove) { appliedFolderMoves.push(appliedMove); } } - if (entry.kind === "file" && !operation.converged) { + if (!this.isFolder(entry.metadata) && !operation.converged) { await this.applyFile(root, packageKey, operation, entry, appliedFolderMoves); } if (operation.replacedNodeKey) { @@ -433,7 +434,7 @@ export class WorkspacePullService { delete state.moveHints[operation.replacedNodeKey]; } this.writeMetadataWithAncestors(root, entry, manifest); - if (entry.kind === "file") { + if (!this.isFolder(entry.metadata)) { state.baselineDigests[entry.nodeKey] = entry.contentDigest!; } delete state.moveHints[entry.nodeKey]; @@ -469,7 +470,7 @@ export class WorkspacePullService { return; } const remote = await this.api.readFile(packageKey, entry.path); - if (this.digestBuffer(remote.body) !== entry.contentDigest || remote.body.length !== entry.size) { + if (this.digestBuffer(remote.body) !== entry.contentDigest) { throw new GracefulError(`Remote file body does not match its manifest: ${entry.path}`); } fs.writeFileSync(target, remote.body); @@ -498,7 +499,7 @@ export class WorkspacePullService { ): boolean { const source = sourcePath.toLowerCase(); const target = targetPath.toLowerCase(); - return appliedFolderMoves.some((move) => { + return appliedFolderMoves.some(move => { const sourceRoot = move.sourcePath.toLowerCase(); if (!source.startsWith(`${sourceRoot}/`)) { return false; @@ -547,8 +548,8 @@ export class WorkspacePullService { const metadataDirectory = path.join(root, ".package", "nodes"); const hasChildren = fs .readdirSync(metadataDirectory) - .filter((file) => file.endsWith(".json") && file !== `${operation.nodeKey}.json`) - .some((file) => { + .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; @@ -613,8 +614,8 @@ export class WorkspacePullService { } const parentKey = entry.metadata.parentNodeKey; if (parentKey) { - const parent = manifest.nodes.find((candidate) => candidate.nodeKey === parentKey); - if (parent?.kind !== "folder") { + 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); @@ -629,17 +630,20 @@ export class WorkspacePullService { private remoteChanged( entry: WorkspaceManifestNode, - localNode: WorkspaceNodeMetadata, + localNode: WorkspaceNode, localPath: string | undefined, expected: ExpectedWorkspaceFile | undefined ): boolean { - if (entry.path.toLowerCase() !== localPath?.toLowerCase() || !this.sameMetadata(entry.metadata, localNode)) { + if ( + entry.path.toLowerCase() !== localPath?.toLowerCase() || + !this.sameMetadata(entry.metadata, this.nodeMetadata(localNode)) + ) { return true; } - return entry.kind === "file" && entry.contentDigest !== expected?.digest; + return !this.isFolder(entry.metadata) && entry.contentDigest !== expected?.digest; } - private localPaths(nodes: WorkspaceNodeMetadata[], packageKey: string): Map { + private localPaths(nodes: WorkspaceNode[], packageKey: string): Map { return projectWorkspacePaths(nodes, packageKey); } @@ -649,35 +653,38 @@ export class WorkspacePullService { } const keys = new Set(); const paths = new Set(); - manifest.nodes.forEach((entry) => { + const referencedDocuments = 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?.key !== entry.nodeKey || - FORBIDDEN_METADATA_FIELDS.some((field) => field in metadata) || + !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" in (entry as unknown as Record) || - (entry.kind !== "file" && entry.kind !== "folder") || - this.isFolder(entry.metadata) !== (entry.kind === "folder") || + ["body", "kind", "assetType", "size"].some(field => field in fields) || keys.has(entry.nodeKey) || paths.has(foldedPath) || - (entry.kind === "file" && - (!entry.assetType || - !entry.mediaType || - !/^sha256:[0-9a-f]{64}$/.test(entry.contentDigest || "") || - typeof entry.size !== "number" || - !Number.isSafeInteger(entry.size) || - entry.size < 0 || - !/^"sha256:[0-9a-f]{64}"$/.test(entry.eTag || ""))) + (folder + ? entry.mediaType !== undefined || entry.contentDigest !== undefined || entry.eTag !== undefined + : !entry.mediaType || !/^sha256:[0-9a-f]{64}$/.test(entry.contentDigest || "") || !entry.eTag) ) { throw new GracefulError("Unsupported workspace manifest."); } keys.add(entry.nodeKey); paths.add(foldedPath); + const document = this.validateDocument(entry, manifest); + if (document) { + referencedDocuments.add(document); + } }); - const byKey = new Map(manifest.nodes.map((entry) => [entry.nodeKey, entry])); + 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)) { @@ -690,7 +697,7 @@ export class WorkspacePullService { const parentKey = entry.metadata.parentNodeKey; if (parentKey) { const parent = byKey.get(parentKey); - if (parent?.kind !== "folder") { + if (!parent || !this.isFolder(parent.metadata)) { throw new GracefulError("Workspace manifest contains an invalid parent relationship."); } resolve(parent, resolving); @@ -698,23 +705,27 @@ export class WorkspacePullService { resolving.delete(entry.nodeKey); resolved.add(entry.nodeKey); }; - manifest.nodes.forEach((entry) => { + manifest.nodes.forEach(entry => { resolve(entry, new Set()); - this.validateDocument(entry, manifest); }); - const projectedPaths = projectWorkspacePaths(manifest.nodes.map((entry) => entry.metadata)); - if (manifest.nodes.some((entry) => projectedPaths.get(entry.nodeKey) !== entry.path)) { + 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."); } - if (!Object.values(manifest.documents).every((value) => typeof value === "string")) { + if ( + !Object.values(manifest.documents).every(value => typeof value === "string") || + Object.keys(manifest.documents).some(digest => !referencedDocuments.has(digest)) + ) { throw new GracefulError("Unsupported workspace manifest."); } } - private validateDocument(entry: WorkspaceManifestNode, manifest: WorkspaceManifest): void { + private validateDocument(entry: WorkspaceManifestNode, manifest: WorkspaceManifest): string | undefined { const reference = entry.metadata.serializedDocumentRef; if (!reference) { - return; + return undefined; } const match = /^\.package\/documents\/([0-9a-f]{64})\.bin$/.exec(reference); const digest = match ? `sha256:${match[1]}` : undefined; @@ -722,6 +733,7 @@ export class WorkspacePullService { if (!digest || typeof encoded !== "string" || this.digestBuffer(Buffer.from(encoded, "base64")) !== digest) { throw new GracefulError(`Workspace manifest has an invalid document for node ${entry.nodeKey}.`); } + return digest; } private validManifestPath(value: string | undefined): boolean { @@ -733,21 +745,26 @@ export class WorkspacePullService { return ( first !== ".package" && first !== ".git" && - segments.every((segment) => Boolean(segment) && segment !== "." && segment !== "..") + segments.every(segment => Boolean(segment) && segment !== "." && segment !== "..") ); } private visibleAt(snapshot: WorkspaceSnapshot, filePath: string): boolean { - return [...snapshot.visibleFiles.keys()].some((value) => value.toLowerCase() === filePath.toLowerCase()); + 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)); + return value.map(item => this.sorted(item)); } if (value && typeof value === "object") { return Object.fromEntries( diff --git a/src/commands/workspace/workspace.models.ts b/src/commands/workspace/workspace.models.ts index 9fd9a65f..06a9f693 100644 --- a/src/commands/workspace/workspace.models.ts +++ b/src/commands/workspace/workspace.models.ts @@ -41,7 +41,6 @@ export interface WorkspaceBranch { } export interface WorkspaceNodeMetadata { - key: string; name: string; type: string; parentNodeKey?: string | null; @@ -52,6 +51,10 @@ export interface WorkspaceNodeMetadata { additionalFields?: Record; } +export interface WorkspaceNode extends WorkspaceNodeMetadata { + nodeKey: string; +} + export interface ExpectedWorkspaceFile { nodeKey: string; path: string; @@ -95,10 +98,7 @@ export interface WorkspaceManifest { export interface WorkspaceManifestNode { nodeKey: string; path: string; - kind: "file" | "folder"; - assetType?: string | null; mediaType?: string | null; - size?: number | null; contentDigest?: string | null; eTag?: string | null; metadata: WorkspaceNodeMetadata; diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index 7366a852..608e7991 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -21,6 +21,7 @@ import { WorkspaceGitObservation, WorkspaceManifest, WorkspaceMoveHint, + WorkspaceNode, WorkspaceNodeMetadata, WorkspacePackageIdentity, WorkspacePullOptions, @@ -176,12 +177,12 @@ export class WorkspaceService { remote.manifest, recoveringCreateKeys ); - const failed = result.outcomes.filter((outcome) => !outcome.success); + const failed = result.outcomes.filter(outcome => !outcome.success); if (recoveringCreateKeys && (paths.length > 0 || failed.length > 0)) { result.state.refreshRequired = true; } this.writeState(root, result.state); - result.outcomes.forEach((outcome) => + result.outcomes.forEach(outcome => logger.info( `${outcome.success ? "succeeded" : "failed"}: ${outcome.status} ${outcome.path}` + (outcome.error ? ` (${outcome.error})` : "") @@ -255,7 +256,7 @@ export class WorkspaceService { if (changes.length === 0) { logger.info("Workspace is clean."); } else { - changes.forEach((change) => logger.info(`${change.status}: ${change.path}`)); + changes.forEach(change => logger.info(`${change.status}: ${change.path}`)); } return changes; } @@ -284,14 +285,14 @@ export class WorkspaceService { delete invalidatedBeforePush.serverRevision; this.writeState(root, invalidatedBeforePush); const outcomes: WorkspacePushOutcome[] = await new WorkspacePushService(this.api).push(root, snapshot, paths); - outcomes.forEach((outcome) => + 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 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, @@ -331,10 +332,10 @@ export class WorkspaceService { const root = this.root(); await this.synchronizeGitTarget(root, true); const snapshot = this.snapshot(root); - if (snapshot.changes.some((change) => change.status === "unresolved")) { + if (snapshot.changes.some(change => change.status === "unresolved")) { throw new GracefulError("Workspace has unresolved file identities. Record the intended moves before push."); } - const zipPath = fileService.zipDirectoryAsSinglePackage(root, (filePath) => { + const zipPath = fileService.zipDirectoryAsSinglePackage(root, filePath => { const folded = filePath.toLowerCase(); return ( folded !== ".git" && @@ -352,11 +353,11 @@ export class WorkspaceService { const moves = Object.fromEntries( snapshot.changes .filter( - (change) => + change => Boolean(change.nodeKey) && (change.status === "moved" || change.status === "moved, modified") ) - .map((change) => [change.nodeKey!, change.path]) + .map(change => [change.nodeKey!, change.path]) ); if (Object.keys(moves).length > 0) { form.append("moveMappings", JSON.stringify({ moves }), { contentType: "application/json" }); @@ -398,7 +399,7 @@ export class WorkspaceService { throw new GracefulError(`Tracked file not found: ${sourcePath}`); } const targetOwned = snapshot.expectedFiles.some( - (file) => file.nodeKey !== tracked.nodeKey && file.path.toLowerCase() === targetPath.toLowerCase() + file => file.nodeKey !== tracked.nodeKey && file.path.toLowerCase() === targetPath.toLowerCase() ); if (targetOwned) { throw new GracefulError(`Target path is already tracked: ${targetPath}`); @@ -451,24 +452,24 @@ export class WorkspaceService { private expectedFiles(root: string, state: WorkspaceState, packageKey: string): ExpectedWorkspaceFile[] { const nodes = this.nodes(root); - const byKey = new Map(nodes.map((node) => [node.key, node])); + 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.key]; + .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.key}.`); + throw new GracefulError(`Invalid baseline digest for node ${node.nodeKey}.`); } - const metadataPath = pathByKey.get(node.key)!; - const hint = state.moveHints[node.key]; + 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.key, path: sourcePath, digest: baseline }; + return { nodeKey: node.nodeKey, path: sourcePath, digest: baseline }; }); const foldedPaths = new Set(); - expected.forEach((file) => { + expected.forEach(file => { const foldedPath = file.path.toLowerCase(); if (foldedPaths.has(foldedPath)) { throw new GracefulError(`Duplicate workspace path in node metadata: ${file.path}`); @@ -491,31 +492,33 @@ export class WorkspaceService { return expected.sort((left, right) => left.path.localeCompare(right.path)); } - private nodes(root: string): WorkspaceNodeMetadata[] { + 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")) + .filter(entry => entry.isFile() && entry.name.endsWith(".json")) .sort((left, right) => left.name.localeCompare(right.name)) - .map((entry) => { - const node = JSON.parse( + .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 = node as unknown as Record; + const fields = metadata as unknown as Record; if ( - !node.key || - !node.name || - !node.type || - `${node.key}.json` !== entry.name || - NON_SEMANTIC_NODE_FIELDS.some((field) => field in fields) || - this.hasLegacyFilesystemName(node) + !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 node; + return { nodeKey, ...metadata }; }); } @@ -525,7 +528,7 @@ export class WorkspaceService { const visit = (directory: string, relativeDirectory: string): void => { fs.readdirSync(directory, { withFileTypes: true }) .sort((left, right) => left.name.localeCompare(right.name)) - .forEach((entry) => { + .forEach(entry => { if ( !relativeDirectory && (entry.name.toLowerCase() === ".package" || entry.name.toLowerCase() === ".git") @@ -559,23 +562,23 @@ export class WorkspaceService { private trackedFile(snapshot: WorkspaceSnapshot, sourcePath: string): ExpectedWorkspaceFile | undefined { const foldedSourcePath = sourcePath.toLowerCase(); - const expected = snapshot.expectedFiles.find((file) => file.path.toLowerCase() === foldedSourcePath); + 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 + file => this.moveHintTarget(snapshot.state.moveHints[file.nodeKey])?.toLowerCase() === foldedSourcePath ); if (hinted) { return hinted; } const classified = snapshot.changes.find( - (change) => + 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; + return classified ? snapshot.expectedFiles.find(file => file.nodeKey === classified.nodeKey) : undefined; } private validateParentMove( @@ -591,9 +594,9 @@ export class WorkspaceService { 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.key) === targetParentPath); + const targetParent = nodes.find(node => this.isFolder(node) && paths.get(node.nodeKey) === targetParentPath); const targetParentKey = targetParentPath - ? targetParent?.key || `__workspace_target__:${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."); @@ -666,7 +669,7 @@ export class WorkspaceService { throw new GracefulError("Archive does not contain Pacman package metadata."); } if ( - zip.getEntries().some((entry) => { + zip.getEntries().some(entry => { const folded = entry.entryName.toLowerCase(); return folded === ".package/local" || folded.startsWith(".package/local/"); }) @@ -714,23 +717,20 @@ export class WorkspaceService { moveHints: {}, }; const remoteFiles = new Map( - this.expectedFiles(remoteRoot, emptyState, packageKey).map((file) => [file.nodeKey, file]) + this.expectedFiles(remoteRoot, emptyState, packageKey).map(file => [file.nodeKey, file]) ); const localFiles = this.expectedFiles(root, emptyState, packageKey); - const remoteNodes = new Map(this.nodes(remoteRoot).map((node) => [node.key, node])); - this.nodes(root).forEach((node) => { - const remote = remoteNodes.get(node.key); + const remoteNodes = new Map(this.nodes(remoteRoot).map(node => [node.nodeKey, node])); + this.nodes(root).forEach(node => { + const remote = remoteNodes.get(node.nodeKey); if (remote && this.isFolder(remote) !== this.isFolder(node)) { - throw new GracefulError(`Node metadata type conflicts with the server for ${node.key}.`); + throw new GracefulError(`Node metadata type conflicts with the server for ${node.nodeKey}.`); } }); const moveHints: Record = Object.fromEntries( localFiles - .filter((file) => remoteFiles.has(file.nodeKey) && remoteFiles.get(file.nodeKey)!.path !== file.path) - .map((file) => [ - file.nodeKey, - { sourcePath: remoteFiles.get(file.nodeKey)!.path, targetPath: file.path }, - ]) + .filter(file => remoteFiles.has(file.nodeKey) && remoteFiles.get(file.nodeKey)!.path !== file.path) + .map(file => [file.nodeKey, { sourcePath: remoteFiles.get(file.nodeKey)!.path, targetPath: file.path }]) ); const reconciledState: WorkspaceState = { ...remoteState, @@ -754,7 +754,7 @@ export class WorkspaceService { packageKey: string, observation?: WorkspaceGitObservation ): WorkspaceState { - if (!/^"sha256:[0-9a-f]{64}"$/.test(eTag)) { + if (!eTag) { throw new GracefulError("Filesystem archive response contains an invalid ETag."); } const projectKey = this.packageIdentity(root).projectKey; @@ -770,7 +770,7 @@ export class WorkspaceService { moveHints: {}, }; const baselineDigests = Object.fromEntries( - this.expectedFiles(root, emptyState, packageKey).map((file) => { + this.expectedFiles(root, emptyState, packageKey).map(file => { const absolute = this.resolveVisiblePath(root, file.path); if (!fs.existsSync(absolute) || !fs.lstatSync(absolute).isFile()) { throw new GracefulError(`Archive is missing visible content for node ${file.nodeKey}.`); @@ -815,8 +815,8 @@ export class WorkspaceService { } catch (error) { try { fs.readdirSync(root) - .filter((entry) => entry !== ".git") - .forEach((entry) => fs.rmSync(path.join(root, entry), { recursive: true, force: true })); + .filter(entry => entry !== ".git") + .forEach(entry => fs.rmSync(path.join(root, entry), { recursive: true, force: true })); this.moveEntries(backup, root); } catch (restoreError) { preserveBackup = true; @@ -836,9 +836,9 @@ export class WorkspaceService { private moveEntries(source: string, target: string, excluded: Set = new Set()): void { fs.readdirSync(source) - .filter((entry) => !excluded.has(entry)) + .filter(entry => !excluded.has(entry)) .sort((left, right) => left.localeCompare(right)) - .forEach((entry) => fs.renameSync(path.join(source, entry), path.join(target, entry))); + .forEach(entry => fs.renameSync(path.join(source, entry), path.join(target, entry))); } private sameFile(source: string, target: string): boolean { @@ -872,19 +872,20 @@ export class WorkspaceService { parsed.schemaVersion !== 1 || !parsed.activePackageKey || !parsed.activeBranch || - (parsed.serverRevision !== undefined && !/^"sha256:[0-9a-f]{64}"$/.test(parsed.serverRevision)) || + (parsed.serverRevision !== undefined && + (typeof parsed.serverRevision !== "string" || !parsed.serverRevision)) || !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) + value => typeof value === "string" && /^sha256:[0-9a-f]{64}$/.test(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) + value => typeof value === "string" || this.isStructuredMoveHint(value) ))) || (parsed.git !== undefined && (!parsed.git || @@ -973,21 +974,21 @@ export class WorkspaceService { ): Record { const completedNodeKeys = new Set( outcomes - .filter((outcome) => outcome.success || outcome.remoteChanged) - .flatMap((outcome) => (outcome.nodeKey ? [outcome.nodeKey] : [])) + .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 => !outcome.success && !outcome.remoteChanged && outcome.nodeKey && (outcome.status === "moved" || outcome.status === "moved, modified") ) - .forEach((outcome) => { + .forEach(outcome => { retained[outcome.nodeKey!] ||= hints[outcome.nodeKey!] || outcome.path; }); return retained; @@ -1113,10 +1114,10 @@ export class WorkspaceService { observation: WorkspaceGitObservation ): Promise { const remote = await this.api.manifest(packageKey); - const localByKey = new Map(this.nodes(root).map((node) => [node.key, node])); - remote.manifest.nodes.forEach((entry) => { + const localByKey = new Map(this.nodes(root).map(node => [node.nodeKey, node])); + remote.manifest.nodes.forEach(entry => { const local = localByKey.get(entry.nodeKey); - if (local && this.isFolder(local) !== (entry.kind === "folder")) { + if (local && this.isFolder(local) !== this.isFolder(entry.metadata)) { throw new GracefulError(`Node metadata type conflicts with the server for ${entry.nodeKey}.`); } }); @@ -1137,15 +1138,15 @@ export class WorkspaceService { private withRemotePathHints(root: string, state: WorkspaceState, manifest: WorkspaceManifest): WorkspaceState { const remotePathByNodeKey = new Map( - manifest.nodes.filter((entry) => entry.kind === "file").map((entry) => [entry.nodeKey, entry.path]) + 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) => { + .filter(file => { const remotePath = remotePathByNodeKey.get(file.nodeKey); return remotePath && remotePath.toLowerCase() !== file.path.toLowerCase(); }) - .map((file) => [ + .map(file => [ file.nodeKey, { sourcePath: remotePathByNodeKey.get(file.nodeKey)!, targetPath: file.path }, ]) diff --git a/tests/commands/workspace/workspace-path-projector.spec.ts b/tests/commands/workspace/workspace-path-projector.spec.ts index b794622f..0bd6b0bc 100644 --- a/tests/commands/workspace/workspace-path-projector.spec.ts +++ b/tests/commands/workspace/workspace-path-projector.spec.ts @@ -3,15 +3,15 @@ import { projectedLeafAfterMove, projectWorkspacePaths, } from "../../../src/commands/workspace/workspace-path-projector"; -import { WorkspaceNodeMetadata } from "../../../src/commands/workspace/workspace.models"; +import { WorkspaceNode } from "../../../src/commands/workspace/workspace.models"; describe("Workspace path projector", () => { it("derives registered and fallback extensions from Asset Type", () => { - const nodes: WorkspaceNodeMetadata[] = [ - { key: "folder", name: "Guides", type: "FOLDER" }, - { key: "markdown", name: "Guide", type: "MARKDOWN_FILE", parentNodeKey: "folder" }, - { key: "html", name: "Landing", type: "HTML_CANVAS" }, - { key: "board", name: "Metrics", type: "BOARD_V2" }, + const nodes: WorkspaceNode[] = [ + { nodeKey: "folder", name: "Guides", type: "FOLDER" }, + { nodeKey: "markdown", name: "Guide", type: "MARKDOWN_FILE", parentNodeKey: "folder" }, + { nodeKey: "html", name: "Landing", type: "HTML_CANVAS" }, + { nodeKey: "board", name: "Metrics", type: "BOARD_V2" }, ]; expect(Object.fromEntries(projectWorkspacePaths(nodes))).toEqual({ @@ -23,10 +23,10 @@ describe("Workspace path projector", () => { }); it("adds stable Node-key suffixes to every colliding sibling", () => { - const nodes: WorkspaceNodeMetadata[] = [ - { key: "folder", name: "Guides", type: "FOLDER" }, - { key: "node-1", name: "Guide", type: "MARKDOWN_FILE", parentNodeKey: "folder" }, - { key: "node-2", name: "Guide", type: "MARKDOWN_FILE", parentNodeKey: "folder" }, + 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); @@ -36,11 +36,11 @@ describe("Workspace path projector", () => { }); it("projects the leaf against target siblings before a parent move", () => { - const nodes: WorkspaceNodeMetadata[] = [ - { key: "guides", name: "Guides", type: "FOLDER" }, - { key: "pages", name: "Pages", type: "FOLDER" }, - { key: "source", name: "Guide", type: "MARKDOWN_FILE", parentNodeKey: "guides" }, - { key: "target", name: "Guide", type: "MARKDOWN_FILE", parentNodeKey: "pages" }, + 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`); diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index 11064b5b..eca0e4f5 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -47,13 +47,13 @@ function digest(value: string): string { } function eTag(value: string): string { - return `"${digest(value)}"`; + return `"${createHash("sha256").update(value).digest("hex")}"`; } function metadata(files: TestFile[]): Record { const nodes: Record = {}; const folders = new Map(); - files.forEach((file) => { + files.forEach(file => { const segments = file.path.split("/"); let parentNodeKey: string | null = null; for (let index = 0; index < segments.length - 1; index += 1) { @@ -66,7 +66,6 @@ function metadata(files: TestFile[]): Record { : `folder-${createHash("sha256").update(folderPath).digest("hex").slice(0, 12)}`; folders.set(folderPath, folderKey); nodes[folderKey] = { - key: folderKey, name: segments[index], type: "FOLDER", parentNodeKey, @@ -75,7 +74,6 @@ function metadata(files: TestFile[]): Record { parentNodeKey = folderKey; } nodes[file.nodeKey] = { - key: file.nodeKey, name: path.posix.basename(file.path, path.posix.extname(file.path)), type: "MARKDOWN_FILE", parentNodeKey, @@ -94,7 +92,7 @@ function state( activePackageKey: PACKAGE_KEY, activeBranch: "main", serverRevision, - baselineDigests: Object.fromEntries(files.map((file) => [file.nodeKey, digest(file.content)])), + baselineDigests: Object.fromEntries(files.map(file => [file.nodeKey, digest(file.content)])), moveHints, }; } @@ -107,7 +105,7 @@ function archive(files: TestFile[]): Buffer { Object.entries(metadata(files)).forEach(([nodeKey, node]) => { zip.addFile(`.package/nodes/${nodeKey}.json`, Buffer.from(JSON.stringify(node))); }); - files.forEach((file) => zip.addFile(file.path, Buffer.from(file.content))); + files.forEach(file => zip.addFile(file.path, Buffer.from(file.content))); return zip.toBuffer(); } @@ -119,41 +117,35 @@ function emptyArchiveWithoutNodeMetadata(): Buffer { } function manifest(files: TestFile[]): Buffer { - const nodes = metadata(files) as Record< - string, - { key: string; name: string; type: string; parentNodeKey?: string | null } - >; + const nodes = metadata(files) as Record; const byKey = new Map(Object.entries(nodes)); const paths = new Map(); - const resolvePath = (node: { key: string; parentNodeKey?: string | null }): string => { - const cached = paths.get(node.key); + const resolvePath = (nodeKey: string): string => { + const cached = paths.get(nodeKey); if (cached) { return cached; } - const metadataNode = nodes[node.key]; - const parent = metadataNode.parentNodeKey ? byKey.get(metadataNode.parentNodeKey) : undefined; + const metadataNode = nodes[nodeKey]; + const parentKey = metadataNode.parentNodeKey; const segment = `${metadataNode.name}${metadataNode.type === "FOLDER" ? "" : ".md"}`; - const filePath = parent ? `${resolvePath(parent)}/${segment}` : segment; - paths.set(node.key, filePath); + const filePath = parentKey && byKey.has(parentKey) ? `${resolvePath(parentKey)}/${segment}` : segment; + paths.set(nodeKey, filePath); return filePath; }; return Buffer.from( JSON.stringify({ - nodes: Object.values(nodes).map((node) => { - const file = files.find((candidate) => candidate.nodeKey === node.key); + nodes: Object.entries(nodes).map(([nodeKey, node]) => { + const file = files.find(candidate => candidate.nodeKey === nodeKey); return file ? { - nodeKey: node.key, - path: resolvePath(node), - kind: "file", - assetType: node.type, + nodeKey, + path: resolvePath(nodeKey), mediaType: "text/markdown", - size: Buffer.byteLength(file.content), contentDigest: digest(file.content), eTag: eTag(file.content), metadata: node, } - : { nodeKey: node.key, path: resolvePath(node), kind: "folder", metadata: node }; + : { nodeKey, path: resolvePath(nodeKey), metadata: node }; }), documents: {}, }) @@ -178,7 +170,7 @@ function writeWorkspace( Object.entries(metadata(files)).forEach(([nodeKey, node]) => { fs.writeFileSync(path.join(process.cwd(), ".package", "nodes", `${nodeKey}.json`), JSON.stringify(node)); }); - files.forEach((file) => { + 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); }); @@ -204,7 +196,7 @@ function removeWorkspace(): void { PACKAGE_KEY, "branch-workspace", "empty-workspace", - ].forEach((entry) => fs.rmSync(path.join(process.cwd(), entry), { recursive: true, force: true })); + ].forEach(entry => fs.rmSync(path.join(process.cwd(), entry), { recursive: true, force: true })); } function mockGit( @@ -249,7 +241,12 @@ describe("Workspace service", () => { baselineDigests: { "node-1": digest("original") }, moveHints: {}, }); - const cloneRename = rename.mock.calls.find((call) => call[1] === path.join(process.cwd(), PACKAGE_KEY)); + expect( + JSON.parse( + fs.readFileSync(path.join(process.cwd(), PACKAGE_KEY, ".package", "nodes", "node-1.json"), "utf-8") + ) + ).toEqual({ name: "Guide", type: "MARKDOWN_FILE", 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 { @@ -515,7 +512,7 @@ describe("Workspace service", () => { const bodyReads = (mockedAxiosInstance.get as jest.Mock).mock.calls.filter(([url]) => url !== manifestUrl()); expect(bodyReads).toHaveLength(3); - changedIndexes.forEach((index) => { + changedIndexes.forEach(index => { expect(fs.readFileSync(path.join(process.cwd(), remote[index].path), "utf-8")).toBe(`remote-${index}`); }); }); @@ -526,10 +523,10 @@ describe("Workspace service", () => { { 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` })); + const remote = original.map(file => ({ ...file, content: `${file.content} remote` })); writeWorkspace(original); mockManifest(remote); - remote.slice(0, 2).forEach((file) => { + remote.slice(0, 2).forEach(file => { mockAxiosGet(fileUrl(file.path), Buffer.from(file.content), { etag: eTag(file.content) }); }); @@ -579,10 +576,9 @@ describe("Workspace service", () => { 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"); + const sharedFolder = remoteManifest.nodes.find(node => node.path === "Target/Shared"); sharedFolder.nodeKey = sharedKey; - sharedFolder.metadata.key = sharedKey; - remoteManifest.nodes.find((node) => node.nodeKey === "node-1").metadata.parentNodeKey = 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) }); @@ -605,7 +601,6 @@ describe("Workspace service", () => { expect(new WorkspaceService(testContext).status()).toEqual([]); expect(JSON.parse(fs.readFileSync(folderMetadataPath, "utf-8"))).toMatchObject({ - key: "folder-1", parentNodeKey: null, }); }); @@ -762,6 +757,21 @@ describe("Workspace service", () => { 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( @@ -825,7 +835,7 @@ describe("Workspace service", () => { expect(backup).toBeDefined(); expect(fs.existsSync(backup!)).toBe(true); - fs.readdirSync(backup!).forEach((entry) => { + fs.readdirSync(backup!).forEach(entry => { originalRename(path.join(backup!, entry), path.join(process.cwd(), entry)); }); fs.rmSync(backup!, { recursive: true, force: true }); @@ -892,7 +902,7 @@ describe("Workspace service", () => { const changes = new WorkspaceService(testContext).status(); expect(changes).toHaveLength(4); - expect(changes.every((change) => change.status === "unresolved")).toBe(true); + expect(changes.every(change => change.status === "unresolved")).toBe(true); }); it("reports clean, modified, added, and deleted files", () => { @@ -1083,10 +1093,10 @@ describe("Workspace service", () => { 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) => { + const exists = jest.spyOn(fs, "existsSync").mockImplementation(candidate => { return candidate.toString() === target || existsSync(candidate); }); - const lstat = jest.spyOn(fs, "lstatSync").mockImplementation((candidate) => { + const lstat = jest.spyOn(fs, "lstatSync").mockImplementation(candidate => { return candidate.toString() === target ? lstatSync(source) : lstatSync(candidate); }); @@ -1248,8 +1258,8 @@ describe("Workspace service", () => { { 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) => { + 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(fileUrl(file.path), { path: file.path, @@ -1378,13 +1388,35 @@ describe("Workspace service", () => { ).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(fileUrl("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( + fileUrl("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`)); + 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(fileUrl("Guides/One.md"), { From 1ab556b5b3d4d2fe4a2d962dd78cbd9257d0baa2 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Fri, 21 Aug 2026 10:10:13 +0200 Subject: [PATCH 40/46] Require manifest validators for folders Includes-AI-Code: true --- src/commands/workspace/workspace-pull.service.ts | 5 +++-- src/commands/workspace/workspace.models.ts | 2 +- tests/commands/workspace/workspace.service.spec.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/commands/workspace/workspace-pull.service.ts b/src/commands/workspace/workspace-pull.service.ts index 2efafc15..e230e79e 100644 --- a/src/commands/workspace/workspace-pull.service.ts +++ b/src/commands/workspace/workspace-pull.service.ts @@ -671,9 +671,10 @@ export class WorkspacePullService { ["body", "kind", "assetType", "size"].some(field => field in fields) || keys.has(entry.nodeKey) || paths.has(foldedPath) || + !entry.eTag || (folder - ? entry.mediaType !== undefined || entry.contentDigest !== undefined || entry.eTag !== undefined - : !entry.mediaType || !/^sha256:[0-9a-f]{64}$/.test(entry.contentDigest || "") || !entry.eTag) + ? entry.mediaType !== undefined || entry.contentDigest !== undefined + : !entry.mediaType || !/^sha256:[0-9a-f]{64}$/.test(entry.contentDigest || "")) ) { throw new GracefulError("Unsupported workspace manifest."); } diff --git a/src/commands/workspace/workspace.models.ts b/src/commands/workspace/workspace.models.ts index 06a9f693..10d27647 100644 --- a/src/commands/workspace/workspace.models.ts +++ b/src/commands/workspace/workspace.models.ts @@ -100,7 +100,7 @@ export interface WorkspaceManifestNode { path: string; mediaType?: string | null; contentDigest?: string | null; - eTag?: string | null; + eTag: string; metadata: WorkspaceNodeMetadata; } diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index eca0e4f5..3d8e9cf9 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -145,7 +145,7 @@ function manifest(files: TestFile[]): Buffer { eTag: eTag(file.content), metadata: node, } - : { nodeKey, path: resolvePath(nodeKey), metadata: node }; + : { nodeKey, path: resolvePath(nodeKey), eTag: eTag(nodeKey), metadata: node }; }), documents: {}, }) From 1c737c046f6ae173b2ed9cf357c08b2a9f1cfa09 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Fri, 21 Aug 2026 11:36:59 +0200 Subject: [PATCH 41/46] Fix Markdown workspace path projection Includes-AI-Code: true --- src/commands/workspace/workspace-path-projector.ts | 1 + tests/commands/workspace/workspace-path-projector.spec.ts | 8 +++++++- tests/commands/workspace/workspace.service.spec.ts | 4 ++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/commands/workspace/workspace-path-projector.ts b/src/commands/workspace/workspace-path-projector.ts index d5eadb97..ae216314 100644 --- a/src/commands/workspace/workspace-path-projector.ts +++ b/src/commands/workspace/workspace-path-projector.ts @@ -134,6 +134,7 @@ function candidateSegment(node: WorkspaceNode): string { function fileExtension(assetType: string): string { switch (assetType.toUpperCase()) { + case "MD": case "MARKDOWN_FILE": return "md"; case "HTML_CANVAS": diff --git a/tests/commands/workspace/workspace-path-projector.spec.ts b/tests/commands/workspace/workspace-path-projector.spec.ts index 0bd6b0bc..d5cc27af 100644 --- a/tests/commands/workspace/workspace-path-projector.spec.ts +++ b/tests/commands/workspace/workspace-path-projector.spec.ts @@ -9,7 +9,7 @@ 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: "MARKDOWN_FILE", parentNodeKey: "folder" }, + { nodeKey: "markdown", name: "Guide", type: "md", parentNodeKey: "folder" }, { nodeKey: "html", name: "Landing", type: "HTML_CANVAS" }, { nodeKey: "board", name: "Metrics", type: "BOARD_V2" }, ]; @@ -22,6 +22,12 @@ describe("Workspace path projector", () => { }); }); + 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" }, diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index 3d8e9cf9..03b2d589 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -75,7 +75,7 @@ function metadata(files: TestFile[]): Record { } nodes[file.nodeKey] = { name: path.posix.basename(file.path, path.posix.extname(file.path)), - type: "MARKDOWN_FILE", + type: "md", parentNodeKey, }; }); @@ -245,7 +245,7 @@ describe("Workspace service", () => { JSON.parse( fs.readFileSync(path.join(process.cwd(), PACKAGE_KEY, ".package", "nodes", "node-1.json"), "utf-8") ) - ).toEqual({ name: "Guide", type: "MARKDOWN_FILE", parentNodeKey: "folder-1" }); + ).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())); From fd41d9c6135ddb077189f984a8d8c73ac19fb492 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Fri, 21 Aug 2026 12:38:25 +0200 Subject: [PATCH 42/46] Make workspace manifests lightweight Includes-AI-Code: true --- src/commands/workspace/workspace-api.ts | 18 +- .../workspace/workspace-pull.service.ts | 233 +++++++++--------- src/commands/workspace/workspace.models.ts | 4 +- src/commands/workspace/workspace.service.ts | 149 ++++++++--- src/core/http/http-client.ts | 22 +- .../workspace/workspace.service.spec.ts | 180 ++++++++++---- tests/utls/http-requests-mock.ts | 10 +- 7 files changed, 390 insertions(+), 226 deletions(-) diff --git a/src/commands/workspace/workspace-api.ts b/src/commands/workspace/workspace-api.ts index f7661ab3..726d330f 100644 --- a/src/commands/workspace/workspace-api.ts +++ b/src/commands/workspace/workspace-api.ts @@ -35,16 +35,28 @@ export class WorkspaceApi { return { body: response.data, eTag }; } - public async manifest(packageKey: string): Promise<{ manifest: WorkspaceManifest; eTag: string }> { + 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` + `/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 }; + 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; diff --git a/src/commands/workspace/workspace-pull.service.ts b/src/commands/workspace/workspace-pull.service.ts index e230e79e..850b0446 100644 --- a/src/commands/workspace/workspace-pull.service.ts +++ b/src/commands/workspace/workspace-pull.service.ts @@ -42,6 +42,8 @@ interface PullOperation { replacedNodeKey?: string; conflict?: string; converged?: boolean; + verifyConvergence?: boolean; + downloadBody?: boolean; } interface PullOperationContext { @@ -85,6 +87,7 @@ export class WorkspacePullService { const state: WorkspaceState = { ...snapshot.state, baselineDigests: { ...snapshot.state.baselineDigests }, + baselineNodeETags: { ...snapshot.state.baselineNodeETags }, moveHints: { ...snapshot.state.moveHints }, }; delete state.serverRevision; @@ -116,7 +119,8 @@ export class WorkspacePullService { return { outcomes, state }; } - public hydrateBaseline( + public hydrateArchiveBaseline( + root: string, state: WorkspaceState, packageKey: string, activeBranch: string, @@ -130,8 +134,38 @@ export class WorkspacePullService { baselineDigests: Object.fromEntries( manifest.nodes .filter(entry => !this.isFolder(entry.metadata)) - .map(entry => [entry.nodeKey, entry.contentDigest!]) + .map(entry => [entry.nodeKey, this.digest(this.resolve(root, entry.path))]) ), + baselineNodeETags: Object.fromEntries(manifest.nodes.map(entry => [entry.nodeKey, entry.eTag])), + moveHints: {}, + }; + if (state.git) { + hydrated.git = state.git; + } + return hydrated; + } + + 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)) { + baselineDigests[entry.nodeKey] = this.digestBuffer( + (await this.api.readFile(packageKey, entry.path)).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) { @@ -157,6 +191,7 @@ export class WorkspacePullService { const next: WorkspaceState = { ...state, baselineDigests: { ...state.baselineDigests }, + baselineNodeETags: { ...state.baselineNodeETags }, moveHints: { ...state.moveHints }, }; delete next.serverRevision; @@ -168,6 +203,7 @@ export class WorkspacePullService { 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); @@ -175,13 +211,18 @@ export class WorkspacePullService { 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); - if (!this.isFolder(entry.metadata)) { - next.baselineDigests[nodeKey] = entry.contentDigest!; + 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]; }); @@ -229,8 +270,7 @@ export class WorkspacePullService { return this.missingLocalOperation(entry, context); } const localPath = context.localPaths.get(entry.nodeKey); - const expected = context.expectedByKey.get(entry.nodeKey); - if (!this.remoteChanged(entry, localNode, localPath, expected)) { + if (!this.remoteChanged(entry, localNode, localPath, context.snapshot.state)) { return undefined; } return this.changedRemoteOperation(entry, localNode, localPath, context); @@ -240,22 +280,21 @@ export class WorkspacePullService { const provisional = context.recoveringCreateKeys ? context.localByPath.get(entry.path.toLowerCase()) : undefined; - const visibleDigest = [...context.snapshot.visibleFiles.entries()].find( - ([filePath]) => filePath.toLowerCase() === entry.path.toLowerCase() - )?.[1]; - if ( - context.recoveringCreateKeys && - !this.isFolder(entry.metadata) && - !provisional && - visibleDigest === entry.contentDigest - ) { - return { - nodeKey: entry.nodeKey, - path: entry.path, - status: "added", - entry, - converged: true, - }; + 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; @@ -268,19 +307,6 @@ export class WorkspacePullService { !context.expectedByKey.get(provisional.nodeKey)?.digest && provisionalPath ) { - if (visibleDigest !== entry.contentDigest) { - context.replacedLocalKeys.add(provisional.nodeKey); - return { - nodeKey: entry.nodeKey, - path: entry.path, - localPath: provisionalPath, - status: "modified", - entry, - localNode: provisional, - localChange: provisionalChange, - conflict: `Server-created file no longer matches the local workspace: ${entry.path}`, - }; - } context.replacedLocalKeys.add(provisional.nodeKey); return { nodeKey: entry.nodeKey, @@ -291,7 +317,8 @@ export class WorkspacePullService { localNode: provisional, localChange: provisionalChange, replacedNodeKey: provisional.nodeKey, - converged: true, + verifyConvergence: true, + downloadBody: true, }; } const occupied = !this.isFolder(entry.metadata) && this.visibleAt(context.snapshot, entry.path); @@ -300,6 +327,7 @@ export class WorkspacePullService { path: entry.path, status: "added", entry, + downloadBody: !this.isFolder(entry.metadata), conflict: occupied ? `Remote file conflicts with an untracked local path: ${entry.path}` : undefined, }; } @@ -319,18 +347,24 @@ export class WorkspacePullService { entry, localNode, localChange, + downloadBody: + !this.isFolder(entry.metadata) && + context.snapshot.state.baselineNodeETags[entry.nodeKey] !== entry.eTag, }; if (!localChange) { return operation; } - const localDigest = context.snapshot.visibleFiles.get(localChange.path); - operation.converged = - !this.isFolder(entry.metadata) && + if ( + context.recoveringCreateKeys && + localChange.status === "moved" && localChange.path.toLowerCase() === entry.path.toLowerCase() && - localDigest === entry.contentDigest; - if (!operation.converged) { - operation.conflict = `Local and remote changes conflict for node ${entry.nodeKey}.`; + 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; } @@ -415,6 +449,7 @@ export class WorkspacePullService { 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; } @@ -425,17 +460,20 @@ export class WorkspacePullService { appliedFolderMoves.push(appliedMove); } } + let contentDigest: string | undefined; if (!this.isFolder(entry.metadata) && !operation.converged) { - await this.applyFile(root, packageKey, operation, entry, appliedFolderMoves); + 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); - if (!this.isFolder(entry.metadata)) { - state.baselineDigests[entry.nodeKey] = entry.contentDigest!; + state.baselineNodeETags[entry.nodeKey] = entry.eTag; + if (contentDigest) { + state.baselineDigests[entry.nodeKey] = contentDigest; } delete state.moveHints[entry.nodeKey]; } @@ -446,13 +484,13 @@ export class WorkspacePullService { operation: PullOperation, entry: WorkspaceManifestNode, appliedFolderMoves: AppliedFolderMove[] - ): Promise { + ): 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()); if (moved && fs.existsSync(target)) { - if (this.movedTargetMatches(source, target, entry)) { - return; + if (!operation.downloadBody && source && !fs.existsSync(source) && fs.lstatSync(target).isFile()) { + return this.digest(target); } if ( !fs.lstatSync(target).isFile() || @@ -463,29 +501,29 @@ export class WorkspacePullService { } } fs.mkdirSync(path.dirname(target), { recursive: true }); - if (this.localFileDigest(source) === entry.contentDigest) { - if (moved && source) { + if (!operation.downloadBody) { + if (moved && source && fs.existsSync(source)) { fs.renameSync(source, target); } - return; + return this.digest(target); } const remote = await this.api.readFile(packageKey, entry.path); - if (this.digestBuffer(remote.body) !== entry.contentDigest) { - throw new GracefulError(`Remote file body does not match its manifest: ${entry.path}`); + const remoteDigest = this.digestBuffer(remote.body); + if (operation.verifyConvergence) { + const localDigest = this.localFileDigest(source || target); + if (localDigest !== remoteDigest) { + throw new GracefulError(`Server-created file no longer matches the local workspace: ${entry.path}`); + } + } else { + fs.writeFileSync(target, remote.body); + } + if (operation.verifyConvergence && moved && source && fs.existsSync(source)) { + fs.renameSync(source, target); } - fs.writeFileSync(target, remote.body); if (moved && source && fs.existsSync(source)) { fs.rmSync(source); } - } - - private movedTargetMatches(source: string | undefined, target: string, entry: WorkspaceManifestNode): boolean { - return Boolean( - source && - !fs.existsSync(source) && - fs.lstatSync(target).isFile() && - this.digest(target) === entry.contentDigest - ); + return remoteDigest; } private localFileDigest(source: string | undefined): string | undefined { @@ -570,8 +608,7 @@ export class WorkspacePullService { } } - private writeMetadata(root: string, entry: WorkspaceManifestNode, manifest: WorkspaceManifest): void { - this.writeDocument(root, entry, manifest); + private writeMetadata(root: string, entry: WorkspaceManifestNode): void { const metadataDirectory = path.join(root, ".package", "nodes"); fs.mkdirSync(metadataDirectory, { recursive: true }); fs.writeFileSync( @@ -580,29 +617,6 @@ export class WorkspacePullService { ); } - private writeDocument(root: string, entry: WorkspaceManifestNode, manifest: WorkspaceManifest): void { - const reference = entry.metadata.serializedDocumentRef; - if (!reference) { - return; - } - const match = /^\.package\/documents\/([0-9a-f]{64})\.bin$/.exec(reference); - if (!match) { - throw new GracefulError(`Invalid document reference for node ${entry.nodeKey}.`); - } - const digest = `sha256:${match[1]}`; - const encoded = manifest.documents[digest]; - if (typeof encoded !== "string") { - throw new GracefulError(`Workspace manifest is missing document ${digest}.`); - } - const document = Buffer.from(encoded, "base64"); - if (this.digestBuffer(document) !== digest) { - throw new GracefulError(`Workspace manifest document digest does not match ${digest}.`); - } - const target = path.join(root, reference); - fs.mkdirSync(path.dirname(target), { recursive: true }); - fs.writeFileSync(target, document); - } - private writeMetadataWithAncestors( root: string, entry: WorkspaceManifestNode, @@ -620,7 +634,7 @@ export class WorkspacePullService { } this.writeMetadataWithAncestors(root, parent, manifest, visited); } - this.writeMetadata(root, entry, manifest); + this.writeMetadata(root, entry); visited.delete(entry.nodeKey); } @@ -632,7 +646,7 @@ export class WorkspacePullService { entry: WorkspaceManifestNode, localNode: WorkspaceNode, localPath: string | undefined, - expected: ExpectedWorkspaceFile | undefined + state: WorkspaceState ): boolean { if ( entry.path.toLowerCase() !== localPath?.toLowerCase() || @@ -640,7 +654,7 @@ export class WorkspacePullService { ) { return true; } - return !this.isFolder(entry.metadata) && entry.contentDigest !== expected?.digest; + return entry.eTag !== state.baselineNodeETags[entry.nodeKey]; } private localPaths(nodes: WorkspaceNode[], packageKey: string): Map { @@ -648,12 +662,15 @@ export class WorkspacePullService { } private validateManifest(manifest: WorkspaceManifest): void { - if (!manifest || !Array.isArray(manifest.nodes) || !manifest.documents || Array.isArray(manifest.documents)) { + 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(); - const referencedDocuments = new Set(); manifest.nodes.forEach(entry => { const foldedPath = entry.path?.toLowerCase(); const metadata = entry.metadata as unknown as Record; @@ -668,22 +685,16 @@ export class WorkspacePullService { "nodeKey" in metadata || FORBIDDEN_METADATA_FIELDS.some(field => field in metadata) || this.hasLegacyFilesystemName(entry.metadata) || - ["body", "kind", "assetType", "size"].some(field => field in fields) || + ["body", "kind", "assetType", "size", "contentDigest"].some(field => field in fields) || keys.has(entry.nodeKey) || paths.has(foldedPath) || !entry.eTag || - (folder - ? entry.mediaType !== undefined || entry.contentDigest !== undefined - : !entry.mediaType || !/^sha256:[0-9a-f]{64}$/.test(entry.contentDigest || "")) + (folder ? entry.mediaType !== undefined && entry.mediaType !== null : !entry.mediaType) ) { throw new GracefulError("Unsupported workspace manifest."); } keys.add(entry.nodeKey); paths.add(foldedPath); - const document = this.validateDocument(entry, manifest); - if (document) { - referencedDocuments.add(document); - } }); const byKey = new Map(manifest.nodes.map(entry => [entry.nodeKey, entry])); const resolved = new Set(); @@ -715,26 +726,6 @@ export class WorkspacePullService { 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."); } - if ( - !Object.values(manifest.documents).every(value => typeof value === "string") || - Object.keys(manifest.documents).some(digest => !referencedDocuments.has(digest)) - ) { - throw new GracefulError("Unsupported workspace manifest."); - } - } - - private validateDocument(entry: WorkspaceManifestNode, manifest: WorkspaceManifest): string | undefined { - const reference = entry.metadata.serializedDocumentRef; - if (!reference) { - return undefined; - } - const match = /^\.package\/documents\/([0-9a-f]{64})\.bin$/.exec(reference); - const digest = match ? `sha256:${match[1]}` : undefined; - const encoded = digest ? manifest.documents[digest] : undefined; - if (!digest || typeof encoded !== "string" || this.digestBuffer(Buffer.from(encoded, "base64")) !== digest) { - throw new GracefulError(`Workspace manifest has an invalid document for node ${entry.nodeKey}.`); - } - return digest; } private validManifestPath(value: string | undefined): boolean { diff --git a/src/commands/workspace/workspace.models.ts b/src/commands/workspace/workspace.models.ts index 10d27647..14b0e394 100644 --- a/src/commands/workspace/workspace.models.ts +++ b/src/commands/workspace/workspace.models.ts @@ -3,7 +3,9 @@ export interface WorkspaceState { activePackageKey: string; activeBranch: string; serverRevision?: string; + manifestETag?: string; baselineDigests: Record; + baselineNodeETags: Record; moveHints: Record; git?: WorkspaceGitObservation; refreshRequired?: boolean; @@ -92,14 +94,12 @@ export interface WorkspacePullOptions { export interface WorkspaceManifest { nodes: WorkspaceManifestNode[]; - documents: Record; } export interface WorkspaceManifestNode { nodeKey: string; path: string; mediaType?: string | null; - contentDigest?: string | null; eTag: string; metadata: WorkspaceNodeMetadata; } diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index 608e7991..ee67e853 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -82,7 +82,8 @@ export class WorkspaceService { if (fs.existsSync(target)) { throw new GracefulError(`Destination already exists: ${target}`); } - const temporary = this.validatedArchive(await this.api.download(packageKey), packageKey, projectKey); + const remote = await this.downloadWorkspace(packageKey); + const temporary = this.validatedArchive(remote.archive, packageKey, projectKey, undefined, remote.manifest); const parent = path.dirname(target); let staging: string | undefined; try { @@ -121,14 +122,15 @@ export class WorkspaceService { 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; } - const download = await this.api.download(packageKey); - const temporary = this.validatedArchive(download, packageKey, projectKey); + const remote = await this.downloadWorkspace(packageKey); + const temporary = this.validatedArchive(remote.archive, packageKey, projectKey, undefined, remote.manifest); try { await this.applyCheckout(root, temporary, packageKey, projectKey, branch, current, options); } finally { @@ -163,10 +165,23 @@ export class WorkspaceService { this.writeState(root, localState); } const { packageKey, restoredObservation } = await this.pullTarget(root, projectKey, localState); - const remote = await this.api.manifest(packageKey); + 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) { - this.hydrateInitialPull(root, projectKey, packageKey, restoredObservation, remote.manifest, pullService); + await this.hydrateInitialPull( + root, + projectKey, + packageKey, + restoredObservation, + manifest, + remote.eTag, + pullService + ); return; } const result = await pullService.pull( @@ -174,13 +189,18 @@ export class WorkspaceService { this.snapshot(root, recoveringCreateKeys), this.nodes(root), paths, - remote.manifest, + 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( @@ -194,14 +214,15 @@ export class WorkspaceService { logger.info(`Pulled ${packageKey}.`); } - private hydrateInitialPull( + private async hydrateInitialPull( root: string, projectKey: string, packageKey: string, restoredObservation: WorkspaceGitObservation | undefined, manifest: WorkspaceManifest, + manifestETag: string, pullService: WorkspacePullService - ): void { + ): Promise { if (manifest.nodes.length === 0) { fs.mkdirSync(path.join(root, ".package", "nodes"), { recursive: true }); } @@ -210,19 +231,15 @@ export class WorkspaceService { activePackageKey: packageKey, activeBranch: this.branchFromPackageKey(projectKey, packageKey), baselineDigests: {}, + baselineNodeETags: {}, moveHints: {}, }; if (restoredObservation) { initial.git = restoredObservation; } - this.writeState( - root, - this.withRemotePathHints( - root, - pullService.hydrateBaseline(initial, packageKey, initial.activeBranch, manifest), - manifest - ) - ); + 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}.`); } @@ -234,11 +251,13 @@ export class WorkspaceService { if (!state.refreshRequired && this.snapshot(root).changes.length !== 0) { throw new GracefulError("Workspace has local changes. Push or discard them before full pull."); } + const remote = await this.downloadWorkspace(state.activePackageKey); const temporary = this.validatedArchive( - await this.api.download(state.activePackageKey), + remote.archive, state.activePackageKey, projectKey, - state.git + state.git, + remote.manifest ); try { this.replaceWorkspaceContents(root, temporary); @@ -283,6 +302,7 @@ export class WorkspaceService { const snapshot = this.snapshot(root); const invalidatedBeforePush: WorkspaceState = { ...snapshot.state }; delete invalidatedBeforePush.serverRevision; + delete invalidatedBeforePush.manifestETag; this.writeState(root, invalidatedBeforePush); const outcomes: WorkspacePushOutcome[] = await new WorkspacePushService(this.api).push(root, snapshot, paths); outcomes.forEach(outcome => @@ -299,6 +319,7 @@ export class WorkspaceService { moveHints: retainedHints, }; delete invalidatedState.serverRevision; + delete invalidatedState.manifestETag; delete invalidatedState.refreshRequired; if (!remoteChanged) { this.writeState(root, invalidatedState); @@ -312,7 +333,7 @@ export class WorkspaceService { const remote = await this.api.manifest(snapshot.packageKey); this.writeState( root, - new WorkspacePullService(this.api).applyPushResults(root, invalidatedState, remote.manifest, outcomes) + new WorkspacePullService(this.api).applyPushResults(root, invalidatedState, remote.manifest!, outcomes) ); } catch (error) { this.writeState(root, { ...invalidatedState, refreshRequired: true }); @@ -365,15 +386,17 @@ export class WorkspaceService { await this.api.pushArchive(snapshot.packageKey, form, overwrite, snapshot.state.serverRevision); const pendingRefresh: WorkspaceState = { ...snapshot.state, refreshRequired: true }; delete pendingRefresh.serverRevision; + delete pendingRefresh.manifestETag; this.writeState(root, pendingRefresh); try { - const refreshedArchive = await this.api.download(snapshot.packageKey); + const refreshed = await this.downloadWorkspace(snapshot.packageKey); this.refreshMetadata( root, - refreshedArchive, + refreshed.archive, snapshot.packageKey, snapshot.projectKey, - snapshot.state.git + snapshot.state.git, + refreshed.manifest ); } catch (error) { const detail = error instanceof GracefulError ? ` ${error.message}` : ""; @@ -612,14 +635,28 @@ export class WorkspaceService { ); } + private async downloadWorkspace( + packageKey: string + ): Promise<{ archive: { archive: Buffer; eTag: string }; manifest: WorkspaceManifest }> { + for (let attempt = 0; attempt < 3; attempt += 1) { + const archive = await this.api.download(packageKey); + const manifest = await this.api.manifest(packageKey); + if (!manifest.notModified && manifest.manifest && manifest.eTag === archive.eTag) { + return { archive, manifest: manifest.manifest }; + } + } + throw new GracefulError("Filesystem archive and manifest revisions did not stabilize."); + } + private refreshMetadata( root: string, download: { archive: Buffer; eTag: string }, packageKey: string, projectKey: string, - observation?: WorkspaceGitObservation + observation: WorkspaceGitObservation | undefined, + manifest: WorkspaceManifest ): void { - const extracted = this.validatedArchive(download, packageKey, projectKey, observation); + const extracted = this.validatedArchive(download, packageKey, projectKey, observation, manifest); try { this.replaceMetadataDirectory(root, path.join(extracted, ".package")); } finally { @@ -662,7 +699,8 @@ export class WorkspaceService { download: { archive: Buffer; eTag: string }, packageKey: string, projectKey: string = BranchUtils.extractProjectKey(packageKey), - observation?: WorkspaceGitObservation + observation?: WorkspaceGitObservation, + manifest?: WorkspaceManifest ): string { const zip = new AdmZip(download.archive); if (!zip.getEntry(".package/package.json") || !zip.getEntry(".package/.gitignore")) { @@ -683,7 +721,7 @@ export class WorkspaceService { } this.validateGitignore(temporary); fs.mkdirSync(path.join(temporary, ".package", "nodes"), { recursive: true }); - this.hydrateState(temporary, download.eTag, packageKey, observation); + this.hydrateState(temporary, download.eTag, packageKey, observation, manifest); const snapshot = this.snapshot(temporary); if (snapshot.changes.length !== 0) { throw new GracefulError("Archive content does not match its workspace baseline."); @@ -714,6 +752,7 @@ export class WorkspaceService { activeBranch: branch, serverRevision: remoteState.serverRevision, baselineDigests: remoteState.baselineDigests, + baselineNodeETags: remoteState.baselineNodeETags, moveHints: {}, }; const remoteFiles = new Map( @@ -752,7 +791,8 @@ export class WorkspaceService { root: string, eTag: string, packageKey: string, - observation?: WorkspaceGitObservation + observation?: WorkspaceGitObservation, + manifest?: WorkspaceManifest ): WorkspaceState { if (!eTag) { throw new GracefulError("Filesystem archive response contains an invalid ETag."); @@ -767,6 +807,7 @@ export class WorkspaceService { activeBranch: this.branchFromPackageKey(projectKey, packageKey), serverRevision: eTag, baselineDigests: {}, + baselineNodeETags: {}, moveHints: {}, }; const baselineDigests = Object.fromEntries( @@ -778,7 +819,20 @@ export class WorkspaceService { return [file.nodeKey, this.digest(absolute)]; }) ); - const state: WorkspaceState = { ...emptyState, baselineDigests }; + let state: WorkspaceState = { ...emptyState, baselineDigests }; + if (manifest) { + const hydrated = new WorkspacePullService(this.api).hydrateArchiveBaseline( + root, + state, + packageKey, + state.activeBranch, + manifest + ); + if (!this.sameStringRecord(hydrated.baselineDigests, baselineDigests)) { + throw new GracefulError("Archive content does not match the workspace manifest."); + } + state = { ...hydrated, serverRevision: eTag, manifestETag: eTag }; + } if (observation) { state.git = observation; } @@ -874,12 +928,20 @@ export class WorkspaceService { !parsed.activeBranch || (parsed.serverRevision !== undefined && (typeof parsed.serverRevision !== "string" || !parsed.serverRevision)) || + (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 || @@ -902,11 +964,15 @@ export class WorkspaceService { activePackageKey: parsed.activePackageKey, activeBranch: parsed.activeBranch, baselineDigests: parsed.baselineDigests, + baselineNodeETags: parsed.baselineNodeETags || {}, moveHints: parsed.moveHints || {}, }; if (parsed.serverRevision) { state.serverRevision = parsed.serverRevision; } + if (parsed.manifestETag) { + state.manifestETag = parsed.manifestETag; + } if (parsed.refreshRequired) { state.refreshRequired = true; } @@ -1114,26 +1180,22 @@ export class WorkspaceService { observation: WorkspaceGitObservation ): Promise { const remote = await this.api.manifest(packageKey); + const manifest = remote.manifest!; const localByKey = new Map(this.nodes(root).map(node => [node.nodeKey, node])); - remote.manifest.nodes.forEach(entry => { + 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}.`); } }); - this.writeState( - root, - this.withRemotePathHints( - root, - new WorkspacePullService(this.api).hydrateBaseline( - { ...state, git: observation }, - packageKey, - branch, - remote.manifest - ), - remote.manifest - ) + const hydrated = await new WorkspacePullService(this.api).hydrateRemoteBaseline( + { ...state, git: observation }, + packageKey, + branch, + manifest ); + hydrated.manifestETag = remote.eTag; + this.writeState(root, this.withRemotePathHints(root, hydrated, manifest)); } private withRemotePathHints(root: string, state: WorkspaceState, manifest: WorkspaceManifest): WorkspaceState { @@ -1213,6 +1275,11 @@ export class WorkspaceService { fs.writeFileSync(this.statePath(root), JSON.stringify(state, null, 2) + "\n", { mode: 0o600 }); } + private sameStringRecord(left: Record, right: Record): boolean { + const leftKeys = Object.keys(left); + return leftKeys.length === Object.keys(right).length && leftKeys.every(key => left[key] === right[key]); + } + private packageIdentity(root: string): WorkspacePackageIdentity { const parsed = JSON.parse( fs.readFileSync(this.packageIdentityPath(root), "utf-8") diff --git a/src/core/http/http-client.ts b/src/core/http/http-client.ts index 4d08c5ac..7acb26c1 100644 --- a/src/core/http/http-client.ts +++ b/src/core/http/http-client.ts @@ -56,10 +56,14 @@ export class HttpClient { return (await this.getFileWithHeaders(url)).data; } - public async getFileWithHeaders(url: string): Promise<{ data: Buffer; headers: Record }> { + 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 => { @@ -68,18 +72,16 @@ export class HttpClient { data.push(chunk); }); response.data.on("end", () => { - if (response.status !== 200) { + if (!acceptedStatuses.includes(response.status)) { reject(new Error(Buffer.concat(data as any).toString())); return; } - const content = Buffer.concat(data as any); - if (content) { - resolve({ data: content, headers: response.headers || {} }); - return; - } - logger.error("Could not get file stream from response"); - reject(new Error("Could not get file stream from response")); + resolve({ + data: Buffer.concat(data as any), + headers: response.headers || {}, + status: response.status, + }); }); }).catch(err => { this.handleError(err, resolve, reject); diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index 03b2d589..553ad24d 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -11,6 +11,7 @@ import { mockAxiosDelete, mockAxiosGet, mockAxiosGetError, + mockAxiosGetWithStatus, mockAxiosPatch, mockAxiosPost, mockAxiosPut, @@ -28,8 +29,8 @@ function archiveUrl(packageKey: string): string { return `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${encodeURIComponent(packageKey)}/file-archive`; } -function fileUrl(filePath: string): string { - return `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${PACKAGE_KEY}/files/${filePath}`; +function fileUrl(filePath: string, packageKey: string = PACKAGE_KEY): string { + return `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${encodeURIComponent(packageKey)}/files/${filePath}`; } function manifestUrl(packageKey: string = PACKAGE_KEY): string { @@ -87,12 +88,20 @@ function state( serverRevision: string = eTag("revision-1"), moveHints: Record = {} ): object { + const nodes = metadata(files); return { schemaVersion: 1, activePackageKey: PACKAGE_KEY, activeBranch: "main", serverRevision, + 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, }; } @@ -141,19 +150,37 @@ function manifest(files: TestFile[]): Buffer { nodeKey, path: resolvePath(nodeKey), mediaType: "text/markdown", - contentDigest: digest(file.content), eTag: eTag(file.content), metadata: node, } : { nodeKey, path: resolvePath(nodeKey), eTag: eTag(nodeKey), metadata: node }; }), - documents: {}, }) ); } -function mockManifest(files: TestFile[], packageKey: string = PACKAGE_KEY): void { - mockAxiosGet(manifestUrl(packageKey), manifest(files), { etag: eTag("manifest") }); +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 mockWorkspaceDownload( + files: TestFile[], + revision: string = eTag("revision-1"), + packageKey: string = PACKAGE_KEY, + body: Buffer = archive(files) +): void { + mockAxiosGet(archiveUrl(packageKey), body, { etag: revision }); + mockManifest(files, packageKey, revision); } function writeWorkspace( @@ -215,9 +242,7 @@ describe("Workspace service", () => { afterEach(removeWorkspace); it("clones and validates a filesystem archive", async () => { - mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]), { - etag: eTag("revision-1"), - }); + mockWorkspaceDownload([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]); const rename = jest.spyOn(fs, "renameSync"); try { @@ -238,7 +263,12 @@ describe("Workspace service", () => { activePackageKey: PACKAGE_KEY, activeBranch: "main", serverRevision: eTag("revision-1"), + manifestETag: eTag("revision-1"), baselineDigests: { "node-1": digest("original") }, + baselineNodeETags: { + "folder-1": eTag("folder-1"), + "node-1": eTag("original"), + }, moveHints: {}, }); expect( @@ -263,7 +293,7 @@ describe("Workspace service", () => { }); it("clones an empty package and hydrates an empty nodes directory", async () => { - mockAxiosGet(ARCHIVE_URL, emptyArchiveWithoutNodeMetadata(), { etag: eTag("empty-revision") }); + mockWorkspaceDownload([], eTag("empty-revision"), PACKAGE_KEY, emptyArchiveWithoutNodeMetadata()); await new WorkspaceService(testContext).clone(PACKAGE_KEY, "empty-workspace"); @@ -274,16 +304,18 @@ describe("Workspace service", () => { activePackageKey: PACKAGE_KEY, activeBranch: "main", serverRevision: eTag("empty-revision"), + manifestETag: eTag("empty-revision"), baselineDigests: {}, + baselineNodeETags: {}, moveHints: {}, }); }); it("clones a selected branch while keeping stable project identity", async () => { - mockAxiosGet( - archiveUrl(BRANCH_PACKAGE_KEY), - archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "branch" }]), - { etag: eTag("branch-revision") } + mockWorkspaceDownload( + [{ 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 }); @@ -302,10 +334,10 @@ describe("Workspace service", () => { it("checks out an existing branch atomically", async () => { writeWorkspace(); - mockAxiosGet( - archiveUrl(BRANCH_PACKAGE_KEY), - archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "branch" }]), - { etag: eTag("branch-revision") } + mockWorkspaceDownload( + [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "branch" }], + eTag("branch-revision"), + BRANCH_PACKAGE_KEY ); await new WorkspaceService(testContext).checkout(BRANCH); @@ -328,10 +360,10 @@ describe("Workspace service", () => { branchKey: BRANCH, packageKey: BRANCH_PACKAGE_KEY, }); - mockAxiosGet( - archiveUrl(BRANCH_PACKAGE_KEY), - archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]), - { etag: eTag("branch-revision") } + mockWorkspaceDownload( + [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }], + eTag("branch-revision"), + BRANCH_PACKAGE_KEY ); const service = new WorkspaceService(testContext); @@ -351,7 +383,12 @@ describe("Workspace service", () => { 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); + 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 }); @@ -374,7 +411,12 @@ describe("Workspace service", () => { ); 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); + 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([]); @@ -428,7 +470,12 @@ describe("Workspace service", () => { }) ); const observation = { branch: "main", head: "b".repeat(40) }; - mockManifest([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }]); + 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(); @@ -470,7 +517,12 @@ describe("Workspace service", () => { 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" }]); + 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(); @@ -490,6 +542,21 @@ describe("Workspace service", () => { ).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}`, @@ -656,7 +723,12 @@ describe("Workspace service", () => { 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" }]); + mockManifest( + [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }], + PACKAGE_KEY, + eTag("manifest"), + true + ); await new WorkspaceService(testContext).pull(); @@ -673,7 +745,12 @@ describe("Workspace service", () => { 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); + mockManifest( + [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "branch baseline" }], + BRANCH_PACKAGE_KEY, + eTag("manifest"), + true + ); await new WorkspaceService(testContext, git).pull(); @@ -691,7 +768,12 @@ describe("Workspace service", () => { 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" }]); + mockManifest( + [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }], + PACKAGE_KEY, + eTag("manifest"), + true + ); const service = new WorkspaceService(testContext); await service.pull(); @@ -702,7 +784,12 @@ describe("Workspace service", () => { 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" }]); + mockManifest( + [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }], + PACKAGE_KEY, + eTag("manifest"), + true + ); const service = new WorkspaceService(testContext); await service.pull(); @@ -787,9 +874,7 @@ describe("Workspace service", () => { it("restores the existing workspace when applying a pull fails", async () => { writeWorkspace(); - mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }]), { - etag: eTag("revision-2"), - }); + mockWorkspaceDownload([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }], eTag("revision-2")); const originalRename = fs.renameSync; const rename = jest.spyOn(fs, "renameSync").mockImplementation((source, target) => { const sourceParent = path.basename(path.dirname(source.toString())); @@ -810,9 +895,7 @@ describe("Workspace service", () => { it("preserves the workspace backup when pull and rollback both fail", async () => { writeWorkspace(); - mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }]), { - etag: eTag("revision-2"), - }); + mockWorkspaceDownload([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }], eTag("revision-2")); const originalRename = fs.renameSync; const rename = jest.spyOn(fs, "renameSync").mockImplementation((source, target) => { const sourceParent = path.basename(path.dirname(source.toString())); @@ -1123,6 +1206,7 @@ describe("Workspace service", () => { const zip = new AdmZip(); zip.addFile("Guides/Guide.md", Buffer.from("original")); mockAxiosGet(ARCHIVE_URL, zip.toBuffer(), { etag: eTag("revision-1") }); + mockManifest([], PACKAGE_KEY, eTag("revision-1")); await expect(new WorkspaceService(testContext).clone(PACKAGE_KEY)).rejects.toThrow( "Archive does not contain Pacman package metadata" @@ -1133,6 +1217,7 @@ describe("Workspace service", () => { const zip = new AdmZip(archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }])); zip.addFile(".package/local/state.json", Buffer.from("{}")); mockAxiosGet(ARCHIVE_URL, zip.toBuffer(), { etag: eTag("revision-1") }); + mockManifest([], PACKAGE_KEY, eTag("revision-1")); await expect(new WorkspaceService(testContext).clone(PACKAGE_KEY)).rejects.toThrow( "Archive contains local Pacman workspace state" @@ -1324,7 +1409,7 @@ describe("Workspace service", () => { 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]); + mockManifest([remote], PACKAGE_KEY, eTag("manifest"), true); await expect(service.pull()).rejects.toThrow("Workspace pull failed for 1 node(s)"); @@ -1363,7 +1448,7 @@ describe("Workspace service", () => { const service = new WorkspaceService(testContext, mockGit(undefined)); await expect(service.push()).rejects.toThrow("local synchronization state could not be refreshed"); - mockManifest([remote]); + mockManifest([remote], PACKAGE_KEY, eTag("manifest"), true); await service.pull(); expect(fs.readFileSync(path.join(process.cwd(), remote.path), "utf-8")).toBe(remote.content); @@ -1593,9 +1678,7 @@ describe("Workspace service", () => { originalRename(source, target); }); mockAxiosPost(PUSH_URL, {}); - mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "changed" }]), { - etag: eTag("revision-2"), - }); + mockWorkspaceDownload([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "changed" }], eTag("revision-2")); try { await service.push([], { full: true, overwrite: true }); @@ -1624,7 +1707,14 @@ describe("Workspace service", () => { activePackageKey: PACKAGE_KEY, activeBranch: "main", serverRevision: eTag("revision-2"), + manifestETag: eTag("revision-2"), baselineDigests: { "node-1": digest("changed") }, + baselineNodeETags: { + [`folder-${createHash("sha256").update("Pages").digest("hex").slice(0, 12)}`]: eTag( + `folder-${createHash("sha256").update("Pages").digest("hex").slice(0, 12)}` + ), + "node-1": eTag("changed"), + }, moveHints: {}, }); }); @@ -1645,9 +1735,7 @@ describe("Workspace service", () => { ).toMatchObject({ refreshRequired: true }); expect(() => service.status()).toThrow("Workspace synchronization state needs refresh"); - mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "original" }]), { - etag: eTag("revision-2"), - }); + mockWorkspaceDownload([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "original" }], eTag("revision-2")); await service.pull([], { full: true }); expect(service.status()).toEqual([]); expect(fs.existsSync(path.join(process.cwd(), "Guides", "Guide.md"))).toBe(false); @@ -1687,9 +1775,7 @@ describe("Workspace service", () => { writeWorkspace(); fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "changed"); mockAxiosPost(PUSH_URL, {}); - mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "changed" }]), { - etag: eTag("revision-2"), - }); + mockWorkspaceDownload([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "changed" }], eTag("revision-2")); const originalRename = fs.renameSync; const rename = jest.spyOn(fs, "renameSync").mockImplementation((source, target) => { if (target.toString() === path.join(process.cwd(), ".package")) { diff --git a/tests/utls/http-requests-mock.ts b/tests/utls/http-requests-mock.ts index dbe8a139..025d99b1 100644 --- a/tests/utls/http-requests-mock.ts +++ b/tests/utls/http-requests-mock.ts @@ -42,7 +42,7 @@ const mockAxios = (): void => { readableStream.push(data); readableStream.push(null); return Promise.resolve({ - status: 200, + status, data: readableStream, headers: mockedGetHeadersByUrl.get(requestUrl) || {}, }); @@ -116,9 +116,15 @@ const mockAxiosGet = (url: string, responseData: any, headers: Record { +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); }; From d37b3fab319cbdd4b70d04234b7b024df854cd3a Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Fri, 21 Aug 2026 12:43:26 +0200 Subject: [PATCH 43/46] Reduce workspace synchronization complexity Includes-AI-Code: true --- .../workspace/workspace-pull.service.ts | 84 ++++++++++++++----- src/commands/workspace/workspace.service.ts | 10 ++- 2 files changed, 68 insertions(+), 26 deletions(-) diff --git a/src/commands/workspace/workspace-pull.service.ts b/src/commands/workspace/workspace-pull.service.ts index 850b0446..2bc4770f 100644 --- a/src/commands/workspace/workspace-pull.service.ts +++ b/src/commands/workspace/workspace-pull.service.ts @@ -488,35 +488,24 @@ export class WorkspacePullService { 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 && fs.existsSync(target)) { - 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}`); - } + const existingTargetDigest = this.validateMovedTarget( + target, + source, + moved, + operation, + entry, + appliedFolderMoves + ); + if (existingTargetDigest) { + return existingTargetDigest; } fs.mkdirSync(path.dirname(target), { recursive: true }); if (!operation.downloadBody) { - if (moved && source && fs.existsSync(source)) { - fs.renameSync(source, target); - } - return this.digest(target); + return this.moveWithoutDownload(source, target, moved); } const remote = await this.api.readFile(packageKey, entry.path); const remoteDigest = this.digestBuffer(remote.body); - if (operation.verifyConvergence) { - const localDigest = this.localFileDigest(source || target); - if (localDigest !== remoteDigest) { - throw new GracefulError(`Server-created file no longer matches the local workspace: ${entry.path}`); - } - } else { - fs.writeFileSync(target, remote.body); - } + this.applyDownloadedFile(source, target, operation, entry, remote.body, remoteDigest); if (operation.verifyConvergence && moved && source && fs.existsSync(source)) { fs.renameSync(source, target); } @@ -526,6 +515,55 @@ export class WorkspacePullService { 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; } diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index ee67e853..a254305d 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -141,9 +141,7 @@ export class WorkspaceService { public async pull(paths: string[] = [], options: WorkspacePullOptions = {}): Promise { if (options.full) { - if (paths.length > 0) { - throw new GracefulError("Workspace paths cannot be combined with --full."); - } + this.validateFullPullPaths(paths); await this.pullFull(); return; } @@ -214,6 +212,12 @@ export class WorkspaceService { logger.info(`Pulled ${packageKey}.`); } + private validateFullPullPaths(paths: string[]): void { + if (paths.length > 0) { + throw new GracefulError("Workspace paths cannot be combined with --full."); + } + } + private async hydrateInitialPull( root: string, projectKey: string, From 26f9fe995e0867ceb47601c06d570587fad040a7 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Mon, 24 Aug 2026 10:42:13 +0200 Subject: [PATCH 44/46] Add empty folder workspace push Includes-AI-Code: true --- src/commands/workspace/workspace-api.ts | 22 ++++- .../workspace/workspace-push.service.ts | 35 +++++-- src/commands/workspace/workspace.models.ts | 9 ++ src/commands/workspace/workspace.service.ts | 92 +++++++++++++++++-- .../workspace/workspace.service.spec.ts | 63 ++++++++++++- 5 files changed, 199 insertions(+), 22 deletions(-) diff --git a/src/commands/workspace/workspace-api.ts b/src/commands/workspace/workspace-api.ts index 726d330f..b8c18a2b 100644 --- a/src/commands/workspace/workspace-api.ts +++ b/src/commands/workspace/workspace-api.ts @@ -80,6 +80,16 @@ export class WorkspaceApi { ); } + 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, @@ -105,10 +115,18 @@ export class WorkspaceApi { } private fileUrl(packageKey: string, filePath: string): string { - const encodedPath = filePath + 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)}/files/${encodedPath}`; + return `/pacman/api/core/staging/packages/${encodeURIComponent(packageKey)}/${collection}/${encodedPath}`; } } diff --git a/src/commands/workspace/workspace-push.service.ts b/src/commands/workspace/workspace-push.service.ts index 63d9c869..909e8520 100644 --- a/src/commands/workspace/workspace-push.service.ts +++ b/src/commands/workspace/workspace-push.service.ts @@ -78,23 +78,36 @@ export class WorkspacePushService { 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 0; + return 1; case "moved": case "moved, modified": - return 1; - case "modified": return 2; - case "added": + case "modified": return 3; - case "unresolved": + case "added": return 4; + case "unresolved": + return 5; } }; - return [...changes].sort( - (left, right) => priority(left) - priority(right) || left.path.localeCompare(right.path) - ); + 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( @@ -102,6 +115,12 @@ export class WorkspacePushService { snapshot: WorkspaceSnapshot, change: ClassifiedWorkspaceChange ): 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."); } diff --git a/src/commands/workspace/workspace.models.ts b/src/commands/workspace/workspace.models.ts index 14b0e394..6037e4a7 100644 --- a/src/commands/workspace/workspace.models.ts +++ b/src/commands/workspace/workspace.models.ts @@ -63,6 +63,11 @@ export interface ExpectedWorkspaceFile { digest?: string; } +export interface ExpectedWorkspaceFolder { + nodeKey: string; + path: string; +} + export type WorkspaceChangeStatus = "added" | "deleted" | "modified" | "moved" | "moved, modified" | "unresolved"; export interface WorkspaceChange { @@ -72,6 +77,7 @@ export interface WorkspaceChange { export interface ClassifiedWorkspaceChange extends WorkspaceChange { nodeKey?: string; + kind?: "file" | "folder"; } export interface WorkspaceSnapshot { @@ -79,7 +85,9 @@ export interface WorkspaceSnapshot { packageKey: string; state: WorkspaceState; expectedFiles: ExpectedWorkspaceFile[]; + expectedFolders: ExpectedWorkspaceFolder[]; visibleFiles: Map; + visibleFolders: Set; changes: ClassifiedWorkspaceChange[]; } @@ -119,6 +127,7 @@ export interface WorkspacePushOutcome { status: WorkspaceChangeStatus; nodeKey?: string; localNodeKey?: string; + kind?: "file" | "folder"; success: boolean; remoteChanged?: boolean; error?: string; diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index a254305d..47e827bf 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -14,7 +14,9 @@ import { projectedLeafAfterMove, projectWorkspacePaths } from "./workspace-path- import { WorkspacePullService } from "./workspace-pull.service"; import { WorkspacePushService } from "./workspace-push.service"; import { + ClassifiedWorkspaceChange, ExpectedWorkspaceFile, + ExpectedWorkspaceFolder, WorkspaceChange, WorkspaceCheckoutOptions, WorkspaceCloneOptions, @@ -31,6 +33,12 @@ import { WorkspaceState, } from "./workspace.models"; +interface VisibleWorkspaceTree { + files: Map; + folders: Set; + emptyFolders: Set; +} + const NON_SEMANTIC_NODE_FIELDS = [ "configuration", "invalidConfiguration", @@ -328,7 +336,7 @@ export class WorkspaceService { if (!remoteChanged) { this.writeState(root, invalidatedState); if (failed.length > 0) { - throw new GracefulError(`Workspace push failed for ${failed.length} file(s).`); + 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; @@ -348,7 +356,7 @@ export class WorkspaceService { throw failure; } if (failed.length > 0) { - throw new GracefulError(`Workspace push failed for ${failed.length} file(s).`); + throw new GracefulError(`Workspace push failed for ${failed.length} path(s).`); } logger.info(`Pushed ${snapshot.packageKey}.`); } @@ -466,17 +474,61 @@ export class WorkspaceService { throw new GracefulError("Active Pacman package does not belong to this workspace project."); } const expectedFiles = this.expectedFiles(root, state, state.activePackageKey); - const visibleFiles = this.visibleFiles(root); + 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, - visibleFiles, - changes: classifyWorkspaceChanges(expectedFiles, visibleFiles, this.moveHintTargets(state.moveHints)), + 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])); @@ -549,8 +601,9 @@ export class WorkspaceService { }); } - private visibleFiles(root: string): Map { + 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 }) @@ -568,6 +621,15 @@ export class WorkspaceService { 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; } @@ -584,7 +646,23 @@ export class WorkspaceService { }); }; visit(root, ""); - return files; + 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 { diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index 553ad24d..dd1eda5c 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -33,6 +33,10 @@ function fileUrl(filePath: string, packageKey: string = PACKAGE_KEY): string { return `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${encodeURIComponent(packageKey)}/files/${filePath}`; } +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`; } @@ -210,6 +214,7 @@ function removeWorkspace(): void { "Guides", "Pages", "Other", + "Empty", "New", "New.md", "Bulk", @@ -1392,6 +1397,38 @@ describe("Workspace service", () => { expect(fs.existsSync(path.join(process.cwd(), ".package/nodes/server-node.json"))).toBe(true); }); + 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" }; @@ -1465,7 +1502,7 @@ describe("Workspace service", () => { mockAxiosGet(fileUrl("Guides/Guide.md"), Buffer.from("original"), { etag: eTag("file-1") }); mockAxiosPutError(fileUrl("Guides/Guide.md"), 412, { message: "stale" }); - await expect(new WorkspaceService(testContext).push()).rejects.toThrow("Workspace push failed for 1 file(s)"); + 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( @@ -1513,7 +1550,7 @@ describe("Workspace service", () => { mockAxiosPutError(fileUrl("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 file(s)"); + 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( @@ -1629,7 +1666,7 @@ describe("Workspace service", () => { mockAxiosPutError(fileUrl("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 file(s)"); + await expect(service.push()).rejects.toThrow("Workspace push failed for 1 path(s)"); expect(service.status()).toEqual([{ path: "Pages/Guide.md", status: "modified" }]); expect( @@ -1678,7 +1715,22 @@ describe("Workspace service", () => { originalRename(source, target); }); mockAxiosPost(PUSH_URL, {}); - mockWorkspaceDownload([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "changed" }], eTag("revision-2")); + const remoteFiles = [{ nodeKey: "node-1", path: "Pages/Guide.md", content: "changed" }]; + const remoteArchive = new AdmZip(archive(remoteFiles)); + remoteArchive.addFile( + ".package/nodes/folder-1.json", + Buffer.from(JSON.stringify({ name: "Guides", type: "FOLDER", parentNodeKey: null })) + ); + remoteArchive.addFile("Guides/", Buffer.alloc(0)); + mockAxiosGet(archiveUrl(PACKAGE_KEY), remoteArchive.toBuffer(), { etag: eTag("revision-2") }); + const remoteManifest = JSON.parse(manifest(remoteFiles).toString("utf-8")); + remoteManifest.nodes.push({ + nodeKey: "folder-1", + path: "Guides", + eTag: eTag("folder-1"), + metadata: { name: "Guides", type: "FOLDER", parentNodeKey: null }, + }); + mockAxiosGet(manifestUrl(), Buffer.from(JSON.stringify(remoteManifest)), { etag: eTag("revision-2") }); try { await service.push([], { full: true, overwrite: true }); @@ -1710,6 +1762,7 @@ describe("Workspace service", () => { manifestETag: eTag("revision-2"), baselineDigests: { "node-1": digest("changed") }, baselineNodeETags: { + "folder-1": eTag("folder-1"), [`folder-${createHash("sha256").update("Pages").digest("hex").slice(0, 12)}`]: eTag( `folder-${createHash("sha256").update("Pages").digest("hex").slice(0, 12)}` ), @@ -1810,7 +1863,7 @@ describe("Workspace service", () => { 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 file(s)"); + await expect(new WorkspaceService(testContext).push()).rejects.toThrow("Workspace push failed for 1 path(s)"); expect(mockedAxiosInstance.post).not.toHaveBeenCalled(); }); From 4437b76c61d2e3f54b606373988a0bd0dca53b13 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Mon, 24 Aug 2026 11:05:25 +0200 Subject: [PATCH 45/46] Require Asset Type for workspace file writes Includes-AI-Code: true --- src/commands/workspace/module.ts | 2 + src/commands/workspace/workspace-api.ts | 3 +- .../workspace/workspace-push.service.ts | 34 ++++++- src/commands/workspace/workspace.models.ts | 2 + src/commands/workspace/workspace.service.ts | 12 ++- tests/commands/workspace/module.spec.ts | 8 +- .../workspace-change-classifier.spec.ts | 33 ++++--- .../workspace/workspace.service.spec.ts | 89 ++++++++++++++----- 8 files changed, 138 insertions(+), 45 deletions(-) diff --git a/src/commands/workspace/module.ts b/src/commands/workspace/module.ts index 117a18a2..b747d675 100644 --- a/src/commands/workspace/module.ts +++ b/src/commands/workspace/module.ts @@ -47,6 +47,7 @@ class Module extends IModule { .description("Push local changes.") .option("--full", "Push the full workspace archive", false) .option("--overwrite", "Replace missing remote files during a full push", false) + .option("--asset-type ", "Asset Type for new files") .action(this.push); workspace @@ -90,6 +91,7 @@ class Module extends IModule { new WorkspaceService(context).push(command.args, { full: options.full, overwrite: options.overwrite, + assetType: options.assetType, }) ); } diff --git a/src/commands/workspace/workspace-api.ts b/src/commands/workspace/workspace-api.ts index b8c18a2b..66856d83 100644 --- a/src/commands/workspace/workspace-api.ts +++ b/src/commands/workspace/workspace-api.ts @@ -67,12 +67,13 @@ export class WorkspaceApi { public putFile( packageKey: string, filePath: string, + assetType: string, body: Buffer, contentType: string, headers: Record ): Promise { return this.context.httpClient.putFile( - this.fileUrl(packageKey, filePath), + `${this.fileUrl(packageKey, filePath)}?assetType=${encodeURIComponent(assetType)}`, body, contentType, undefined, diff --git a/src/commands/workspace/workspace-push.service.ts b/src/commands/workspace/workspace-push.service.ts index 909e8520..0512b393 100644 --- a/src/commands/workspace/workspace-push.service.ts +++ b/src/commands/workspace/workspace-push.service.ts @@ -40,12 +40,17 @@ function isOperationError(error: unknown): error is WorkspacePushOperationError export class WorkspacePushService { constructor(private readonly api: WorkspaceApi) {} - public async push(root: string, snapshot: WorkspaceSnapshot, paths: string[]): Promise { + 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); + const result = await this.pushChange(root, snapshot, change, assetType); outcomes.push({ ...change, nodeKey: result?.nodeKey || change.nodeKey, @@ -113,7 +118,8 @@ export class WorkspacePushService { private async pushChange( root: string, snapshot: WorkspaceSnapshot, - change: ClassifiedWorkspaceChange + change: ClassifiedWorkspaceChange, + requestedAssetType?: string ): Promise { if (change.kind === "folder") { if (change.status === "added") { @@ -132,6 +138,7 @@ export class WorkspacePushService { 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": "*" } @@ -141,6 +148,7 @@ export class WorkspacePushService { return this.api.putFile( snapshot.packageKey, change.path, + this.requireExpected(expected).assetType, this.content(root, change.path), this.contentType(change.path), { "If-Match": eTag } @@ -165,6 +173,7 @@ export class WorkspacePushService { return await this.api.putFile( snapshot.packageKey, change.path, + tracked.assetType, this.content(root, change.path), this.contentType(change.path), { "If-Match": eTag } @@ -182,6 +191,25 @@ export class WorkspacePushService { } } + 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}`); diff --git a/src/commands/workspace/workspace.models.ts b/src/commands/workspace/workspace.models.ts index 6037e4a7..1d9da607 100644 --- a/src/commands/workspace/workspace.models.ts +++ b/src/commands/workspace/workspace.models.ts @@ -60,6 +60,7 @@ export interface WorkspaceNode extends WorkspaceNodeMetadata { export interface ExpectedWorkspaceFile { nodeKey: string; path: string; + assetType: string; digest?: string; } @@ -94,6 +95,7 @@ export interface WorkspaceSnapshot { export interface WorkspacePushOptions { full?: boolean; overwrite?: boolean; + assetType?: string; } export interface WorkspacePullOptions { diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index 47e827bf..f40f9514 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -303,6 +303,9 @@ export class WorkspaceService { if (paths.length > 0) { throw new GracefulError("Workspace paths cannot be combined with --full."); } + if (options.assetType) { + throw new GracefulError("--asset-type cannot be combined with --full."); + } await this.pushFull(Boolean(options.overwrite)); return; } @@ -316,7 +319,12 @@ export class WorkspaceService { delete invalidatedBeforePush.serverRevision; delete invalidatedBeforePush.manifestETag; this.writeState(root, invalidatedBeforePush); - const outcomes: WorkspacePushOutcome[] = await new WorkspacePushService(this.api).push(root, snapshot, paths); + 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}` + @@ -545,7 +553,7 @@ export class WorkspaceService { const sourcePath = this.isStructuredMoveHint(hint) ? this.validateRelative(hint.sourcePath) : metadataPath; - return { nodeKey: node.nodeKey, path: sourcePath, digest: baseline }; + return { nodeKey: node.nodeKey, path: sourcePath, assetType: node.type, digest: baseline }; }); const foldedPaths = new Set(); expected.forEach(file => { diff --git a/tests/commands/workspace/module.spec.ts b/tests/commands/workspace/module.spec.ts index 2b9726eb..85143532 100644 --- a/tests/commands/workspace/module.spec.ts +++ b/tests/commands/workspace/module.spec.ts @@ -43,7 +43,7 @@ describe("Workspace module", () => { await execute("workspace", "pull", "target", "other.md"); await execute("workspace", "pull", "--full"); await execute("workspace", "status", "target"); - await execute("workspace", "push", "target", "other.md"); + 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" }); @@ -60,7 +60,11 @@ describe("Workspace module", () => { expect(pull).toHaveBeenNthCalledWith(1, ["target", "other.md"], { full: false }); expect(pull).toHaveBeenNthCalledWith(2, [], { full: true }); expect(status).toHaveBeenCalledWith("target"); - expect(push).toHaveBeenCalledWith(["target", "other.md"], { full: false, overwrite: false }); + expect(push).toHaveBeenCalledWith(["target", "other.md"], { + full: false, + overwrite: false, + assetType: "MARKDOWN_FILE", + }); expect(move).toHaveBeenCalledWith("old.md", "new.md", true); }); diff --git a/tests/commands/workspace/workspace-change-classifier.spec.ts b/tests/commands/workspace/workspace-change-classifier.spec.ts index 400b4fca..3547fdbe 100644 --- a/tests/commands/workspace/workspace-change-classifier.spec.ts +++ b/tests/commands/workspace/workspace-change-classifier.spec.ts @@ -1,10 +1,15 @@ 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( - [{ nodeKey: "node-1", path: "Guides/Guide.md", digest: "sha256:one" }], + [tracked("node-1", "Guides/Guide.md", "sha256:one")], new Map([["Guides/Guide.md", "sha256:one"]]), {} ) @@ -14,7 +19,7 @@ describe("Workspace change classifier", () => { it("infers a uniquely matching unchanged move", () => { expect( classifyWorkspaceChanges( - [{ nodeKey: "node-1", path: "Guides/Guide.md", digest: "sha256:one" }], + [tracked("node-1", "Guides/Guide.md", "sha256:one")], new Map([["Pages/Guide.md", "sha256:one"]]), {} ) @@ -24,7 +29,7 @@ describe("Workspace change classifier", () => { it("keeps a uniquely matching filename rename unresolved", () => { expect( classifyWorkspaceChanges( - [{ nodeKey: "node-1", path: "Guides/Guide.md", digest: "sha256:one" }], + [tracked("node-1", "Guides/Guide.md", "sha256:one")], new Map([["Pages/Renamed.md", "sha256:one"]]), {} ) @@ -34,7 +39,7 @@ describe("Workspace change classifier", () => { it("uses a recorded hint for a move followed by an edit", () => { expect( classifyWorkspaceChanges( - [{ nodeKey: "node-1", path: "Guides/Guide.md", digest: "sha256:one" }], + [tracked("node-1", "Guides/Guide.md", "sha256:one")], new Map([["Pages/Guide.md", "sha256:two"]]), { "node-1": "Pages/Guide.md" } ) @@ -44,7 +49,7 @@ describe("Workspace change classifier", () => { it("uses a reconciliation hint when metadata already names the destination", () => { expect( classifyWorkspaceChanges( - [{ nodeKey: "node-1", path: "Pages/Guide.md", digest: "sha256:one" }], + [tracked("node-1", "Pages/Guide.md", "sha256:one")], new Map([["Pages/Guide.md", "sha256:one"]]), { "node-1": "Pages/Guide.md" } ) @@ -54,7 +59,7 @@ describe("Workspace change classifier", () => { it("keeps a recorded case-only filename rename unresolved", () => { expect( classifyWorkspaceChanges( - [{ nodeKey: "node-1", path: "Guides/Guide.md", digest: "sha256:one" }], + [tracked("node-1", "Guides/Guide.md", "sha256:one")], new Map([["Guides/Guide.md", "sha256:one"]]), { "node-1": "Guides/guide.md" } ) @@ -64,7 +69,7 @@ describe("Workspace change classifier", () => { it("keeps a distinct deletion and addition separate", () => { expect( classifyWorkspaceChanges( - [{ nodeKey: "node-1", path: "Guides/Old.md", digest: "sha256:one" }], + [tracked("node-1", "Guides/Old.md", "sha256:one")], new Map([["Pages/New.md", "sha256:two"]]), {} ) @@ -78,8 +83,8 @@ describe("Workspace change classifier", () => { expect( classifyWorkspaceChanges( [ - { nodeKey: "node-1", path: "Guides/Guide.md", digest: "sha256:one" }, - { nodeKey: "node-2", path: "Tutorials/Guide.md", digest: "sha256:two" }, + tracked("node-1", "Guides/Guide.md", "sha256:one"), + tracked("node-2", "Tutorials/Guide.md", "sha256:two"), ], new Map([["Pages/Guide.md", "sha256:three"]]), {} @@ -94,7 +99,7 @@ describe("Workspace change classifier", () => { it("classifies a metadata-backed file without a baseline as added", () => { expect( classifyWorkspaceChanges( - [{ nodeKey: "node-1", path: "Guides/New.md" }], + [tracked("node-1", "Guides/New.md")], new Map([["Guides/New.md", "sha256:new"]]), {} ) @@ -104,7 +109,7 @@ describe("Workspace change classifier", () => { it("keeps an unrecorded move without a baseline unresolved once", () => { expect( classifyWorkspaceChanges( - [{ nodeKey: "node-1", path: "Guides/New.md" }], + [tracked("node-1", "Guides/New.md")], new Map([["Pages/New.md", "sha256:new"]]), {} ) @@ -114,7 +119,7 @@ describe("Workspace change classifier", () => { it("classifies a recorded move without a baseline as added", () => { expect( classifyWorkspaceChanges( - [{ nodeKey: "node-1", path: "Guides/New.md" }], + [tracked("node-1", "Guides/New.md")], new Map([["Pages/New.md", "sha256:new"]]), { "node-1": "Pages/New.md" } ) @@ -124,7 +129,7 @@ describe("Workspace change classifier", () => { it("keeps an invalid recorded move unresolved", () => { expect( classifyWorkspaceChanges( - [{ nodeKey: "node-1", path: "Guides/Guide.md", digest: "sha256:one" }], + [tracked("node-1", "Guides/Guide.md", "sha256:one")], new Map([["Guides/Guide.md", "sha256:one"]]), { "node-1": "Pages/Guide.md" } ) @@ -134,7 +139,7 @@ describe("Workspace change classifier", () => { it("keeps an unrecorded same-name move and edit unresolved", () => { expect( classifyWorkspaceChanges( - [{ nodeKey: "node-1", path: "Guides/Guide.md", digest: "sha256:one" }], + [tracked("node-1", "Guides/Guide.md", "sha256:one")], new Map([["Pages/Guide.md", "sha256:two"]]), {} ) diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index dd1eda5c..88f73938 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -33,6 +33,10 @@ 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}`; } @@ -45,6 +49,7 @@ interface TestFile { nodeKey: string; path: string; content: string; + assetType?: string; } function digest(value: string): string { @@ -80,7 +85,7 @@ function metadata(files: TestFile[]): Record { } nodes[file.nodeKey] = { name: path.posix.basename(file.path, path.posix.extname(file.path)), - type: "md", + type: file.assetType ?? "md", parentNodeKey, }; }); @@ -1243,7 +1248,7 @@ describe("Workspace service", () => { } fs.writeFileSync(path.join(process.cwd(), file.path), `changed-${index}`); mockAxiosGet(fileUrl(file.path), Buffer.from(file.content), { etag: eTag(`file-${index}`) }); - mockAxiosPut(fileUrl(file.path), { + mockAxiosPut(fileWriteUrl(file.path), { path: file.path, nodeKey: file.nodeKey, assetType: "MARKDOWN_FILE", @@ -1278,7 +1283,7 @@ describe("Workspace service", () => { 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(fileUrl("Selected/One.md"), { + mockAxiosPut(fileWriteUrl("Selected/One.md"), { path: "Selected/One.md", nodeKey: "node-1", assetType: "MARKDOWN_FILE", @@ -1351,7 +1356,7 @@ describe("Workspace service", () => { 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(fileUrl(file.path), { + mockAxiosPut(fileWriteUrl(file.path), { path: file.path, nodeKey: file.nodeKey, assetType: "MARKDOWN_FILE", @@ -1370,13 +1375,13 @@ describe("Workspace service", () => { ]); }); - it("lets Pacman select the Asset Type for a registered-extension addition", async () => { + 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(fileUrl(local.path), { + mockAxiosPut(fileWriteUrl(local.path), { path: local.path, nodeKey: remote.nodeKey, assetType: "MARKDOWN_FILE", @@ -1387,16 +1392,49 @@ describe("Workspace service", () => { await new WorkspaceService(testContext).push(); expect(mockedAxiosInstance.put).toHaveBeenCalledWith( - fileUrl(local.path), + fileWriteUrl(local.path), Buffer.from("new"), expect.objectContaining({ headers: expect.objectContaining({ "If-None-Match": "*" }) }) ); - expect((mockedAxiosInstance.put as jest.Mock).mock.calls[0][0]).not.toContain("assetType="); + 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); @@ -1435,7 +1473,7 @@ describe("Workspace service", () => { writeWorkspace([local]); const statePath = path.join(process.cwd(), ".package", "local", "state.json"); fs.writeFileSync(statePath, JSON.stringify({ ...state([local]), baselineDigests: {} })); - mockAxiosPut(fileUrl(local.path), { + mockAxiosPut(fileWriteUrl(local.path), { path: local.path, nodeKey: remote.nodeKey, assetType: "MARKDOWN_FILE", @@ -1472,10 +1510,10 @@ describe("Workspace service", () => { }); it("recovers an untracked server-created file by path and digest", async () => { - const remote = { nodeKey: "server-node", path: "New.md", content: "new" }; + 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(fileUrl(remote.path), { + mockAxiosPut(fileWriteUrl(remote.path, "MARKDOWN_FILE"), { path: remote.path, nodeKey: remote.nodeKey, assetType: "MARKDOWN_FILE", @@ -1484,7 +1522,9 @@ describe("Workspace service", () => { 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"); + 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(); @@ -1500,7 +1540,7 @@ describe("Workspace service", () => { writeWorkspace(); fs.writeFileSync(path.join(process.cwd(), "Guides/Guide.md"), "changed"); mockAxiosGet(fileUrl("Guides/Guide.md"), Buffer.from("original"), { etag: eTag("file-1") }); - mockAxiosPutError(fileUrl("Guides/Guide.md"), 412, { message: "stale" }); + mockAxiosPutError(fileWriteUrl("Guides/Guide.md"), 412, { message: "stale" }); await expect(new WorkspaceService(testContext).push()).rejects.toThrow("Workspace push failed for 1 path(s)"); @@ -1515,7 +1555,7 @@ describe("Workspace service", () => { writeWorkspace(); fs.writeFileSync(path.join(process.cwd(), "Guides/Guide.md"), "changed"); mockAxiosGet(fileUrl("Guides/Guide.md"), Buffer.from("original"), { etag: weakETag }); - mockAxiosPut(fileUrl("Guides/Guide.md"), { + mockAxiosPut(fileWriteUrl("Guides/Guide.md"), { path: "Guides/Guide.md", nodeKey: "node-1", assetType: "MARKDOWN_FILE", @@ -1526,7 +1566,7 @@ describe("Workspace service", () => { await new WorkspaceService(testContext).push(); expect(mockedAxiosInstance.put).toHaveBeenCalledWith( - fileUrl("Guides/Guide.md"), + fileWriteUrl("Guides/Guide.md"), expect.any(Buffer), expect.objectContaining({ headers: expect.objectContaining({ "If-Match": weakETag }) }) ); @@ -1541,13 +1581,13 @@ describe("Workspace service", () => { 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(fileUrl("Guides/One.md"), { + mockAxiosPut(fileWriteUrl("Guides/One.md"), { path: "Guides/One.md", nodeKey: "node-1", assetType: "MARKDOWN_FILE", eTag: eTag("one changed"), }); - mockAxiosPutError(fileUrl("Guides/Two.md"), 412, { message: "stale" }); + 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)"); @@ -1590,7 +1630,7 @@ describe("Workspace service", () => { assetType: "MARKDOWN_FILE", eTag: eTag("file-2"), }); - mockAxiosPut(fileUrl("Pages/Guide.md"), { + mockAxiosPut(fileWriteUrl("Pages/Guide.md"), { path: "Pages/Guide.md", nodeKey: "node-1", assetType: "MARKDOWN_FILE", @@ -1606,7 +1646,7 @@ describe("Workspace service", () => { expect.objectContaining({ headers: expect.objectContaining({ "If-Match": eTag("file-1") }) }) ); expect(mockedAxiosInstance.put).toHaveBeenCalledWith( - fileUrl("Pages/Guide.md"), + fileWriteUrl("Pages/Guide.md"), Buffer.from("changed"), expect.objectContaining({ headers: expect.objectContaining({ "If-Match": eTag("file-2") }) }) ); @@ -1632,7 +1672,7 @@ describe("Workspace service", () => { assetType: "MARKDOWN_FILE", eTag: eTag("file-2"), }); - mockAxiosPut(fileUrl("Guides/Guide.md"), { + mockAxiosPut(fileWriteUrl("Guides/Guide.md", "MARKDOWN_FILE"), { path: "Guides/Guide.md", nodeKey: "node-2", assetType: "MARKDOWN_FILE", @@ -1643,7 +1683,7 @@ describe("Workspace service", () => { { nodeKey: "node-2", path: "Guides/Guide.md", content: "replacement" }, ]); - await service.push(); + await service.push([], { assetType: "MARKDOWN_FILE" }); expect((mockedAxiosInstance.patch as jest.Mock).mock.invocationCallOrder[0]).toBeLessThan( (mockedAxiosInstance.put as jest.Mock).mock.invocationCallOrder[0] @@ -1663,7 +1703,7 @@ describe("Workspace service", () => { assetType: "MARKDOWN_FILE", eTag: eTag("file-2"), }); - mockAxiosPutError(fileUrl("Pages/Guide.md"), 412, { message: "stale" }); + 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)"); @@ -1677,7 +1717,7 @@ describe("Workspace service", () => { }); }); - it("rejects paths with a full push and overwrite without a full push", async () => { + it("rejects incompatible full-push options", async () => { writeWorkspace(); await expect(new WorkspaceService(testContext).push(["Guides"], { full: true })).rejects.toThrow( @@ -1686,6 +1726,9 @@ describe("Workspace service", () => { await expect(new WorkspaceService(testContext).push([], { overwrite: true })).rejects.toThrow( "--overwrite requires --full" ); + await expect( + new WorkspaceService(testContext).push([], { full: true, assetType: "MARKDOWN_FILE" }) + ).rejects.toThrow("--asset-type cannot be combined with --full"); }); it("pushes resolved changes and refreshes disposable metadata", async () => { From edb96d5f01e9b82fc53d70e0d64e9cf74be60db3 Mon Sep 17 00:00:00 2001 From: Meris Nici Date: Mon, 24 Aug 2026 14:32:12 +0200 Subject: [PATCH 46/46] Remove archive-backed workspace operations Includes-AI-Code: true --- src/commands/workspace/module.ts | 15 +- src/commands/workspace/workspace-api.ts | 23 +- .../workspace/workspace-pull.service.ts | 94 ++-- src/commands/workspace/workspace.models.ts | 8 - src/commands/workspace/workspace.service.ts | 521 ++++-------------- tests/commands/workspace/module.spec.ts | 6 +- .../workspace/workspace.service.spec.ts | 350 ++---------- 7 files changed, 198 insertions(+), 819 deletions(-) diff --git a/src/commands/workspace/module.ts b/src/commands/workspace/module.ts index b747d675..56226c0c 100644 --- a/src/commands/workspace/module.ts +++ b/src/commands/workspace/module.ts @@ -32,12 +32,7 @@ class Module extends IModule { .option("--link-git", "Map the current Git branch", false) .action(this.checkout); - workspace - .command("pull [paths...]") - .beta() - .description("Pull remote changes.") - .option("--full", "Pull and replace from the full workspace archive", false) - .action(this.pull); + workspace.command("pull [paths...]").beta().description("Pull remote changes.").action(this.pull); workspace.command("status [directory]").beta().description("Show local changes.").action(this.status); @@ -45,8 +40,6 @@ class Module extends IModule { .command("push [paths...]") .beta() .description("Push local changes.") - .option("--full", "Push the full workspace archive", false) - .option("--overwrite", "Replace missing remote files during a full push", false) .option("--asset-type ", "Asset Type for new files") .action(this.push); @@ -78,8 +71,8 @@ class Module extends IModule { }); } - private async pull(context: Context, command: Command, options: OptionValues): Promise { - await runWorkspaceCommand(() => new WorkspaceService(context).pull(command.args, { full: options.full })); + private async pull(context: Context, command: Command): Promise { + await runWorkspaceCommand(() => new WorkspaceService(context).pull(command.args)); } private async status(context: Context, command: Command): Promise { @@ -89,8 +82,6 @@ class Module extends IModule { private async push(context: Context, command: Command, options: OptionValues): Promise { await runWorkspaceCommand(() => new WorkspaceService(context).push(command.args, { - full: options.full, - overwrite: options.overwrite, assetType: options.assetType, }) ); diff --git a/src/commands/workspace/workspace-api.ts b/src/commands/workspace/workspace-api.ts index 66856d83..26edd218 100644 --- a/src/commands/workspace/workspace-api.ts +++ b/src/commands/workspace/workspace-api.ts @@ -1,4 +1,3 @@ -import * as FormData from "form-data"; import { Context } from "../../core/command/cli-context"; import { GracefulError } from "../../core/utils/logger"; import { NodeFileWriteResponse, WorkspaceBranch, WorkspaceManifest } from "./workspace.models"; @@ -6,26 +5,6 @@ import { NodeFileWriteResponse, WorkspaceBranch, WorkspaceManifest } from "./wor export class WorkspaceApi { constructor(private readonly context: Context) {} - public async download(packageKey: string): Promise<{ archive: Buffer; eTag: string }> { - const response = await this.context.httpClient.getFileWithHeaders( - `/pacman/api/core/staging/packages/${encodeURIComponent(packageKey)}/file-archive` - ); - const eTag = response.headers.etag; - if (typeof eTag !== "string") { - throw new GracefulError("Filesystem archive response does not contain an ETag."); - } - return { archive: response.data, eTag }; - } - - public pushArchive(packageKey: string, data: FormData, overwrite: boolean, eTag: string): Promise { - return this.context.httpClient.postFile( - `/pacman/api/core/staging/packages/${encodeURIComponent(packageKey)}/file-archive`, - data, - { overwrite }, - { "If-Match": eTag } - ); - } - 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; @@ -126,7 +105,7 @@ export class WorkspaceApi { private pathUrl(packageKey: string, collection: string, entryPath: string): string { const encodedPath = entryPath .split("/") - .map(segment => encodeURIComponent(segment)) + .map((segment) => encodeURIComponent(segment)) .join("/"); return `/pacman/api/core/staging/packages/${encodeURIComponent(packageKey)}/${collection}/${encodedPath}`; } diff --git a/src/commands/workspace/workspace-pull.service.ts b/src/commands/workspace/workspace-pull.service.ts index 2bc4770f..5b01aca5 100644 --- a/src/commands/workspace/workspace-pull.service.ts +++ b/src/commands/workspace/workspace-pull.service.ts @@ -27,7 +27,6 @@ const FORBIDDEN_METADATA_FIELDS = [ "creationDate", "changeDate", "revision", - "serverRevision", "filesystemName", ]; @@ -90,7 +89,6 @@ export class WorkspacePullService { baselineNodeETags: { ...snapshot.state.baselineNodeETags }, moveHints: { ...snapshot.state.moveHints }, }; - delete state.serverRevision; delete state.refreshRequired; const outcomes: WorkspacePullOutcome[] = []; const appliedFolderMoves: AppliedFolderMove[] = []; @@ -119,32 +117,6 @@ export class WorkspacePullService { return { outcomes, state }; } - public hydrateArchiveBaseline( - root: string, - state: WorkspaceState, - packageKey: string, - activeBranch: string, - manifest: WorkspaceManifest - ): WorkspaceState { - this.validateManifest(manifest); - const hydrated: WorkspaceState = { - schemaVersion: 1, - activePackageKey: packageKey, - activeBranch, - baselineDigests: Object.fromEntries( - manifest.nodes - .filter(entry => !this.isFolder(entry.metadata)) - .map(entry => [entry.nodeKey, this.digest(this.resolve(root, entry.path))]) - ), - baselineNodeETags: Object.fromEntries(manifest.nodes.map(entry => [entry.nodeKey, entry.eTag])), - moveHints: {}, - }; - if (state.git) { - hydrated.git = state.git; - } - return hydrated; - } - public async hydrateRemoteBaseline( state: WorkspaceState, packageKey: string, @@ -155,9 +127,11 @@ export class WorkspacePullService { const baselineDigests: Record = {}; for (const entry of manifest.nodes) { if (!this.isFolder(entry.metadata)) { - baselineDigests[entry.nodeKey] = this.digestBuffer( - (await this.api.readFile(packageKey, entry.path)).body - ); + 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 = { @@ -165,7 +139,7 @@ export class WorkspacePullService { activePackageKey: packageKey, activeBranch, baselineDigests, - baselineNodeETags: Object.fromEntries(manifest.nodes.map(entry => [entry.nodeKey, entry.eTag])), + baselineNodeETags: Object.fromEntries(manifest.nodes.map((entry) => [entry.nodeKey, entry.eTag])), moveHints: {}, }; if (state.git) { @@ -187,18 +161,17 @@ export class WorkspacePullService { }> ): WorkspaceState { this.validateManifest(manifest); - const byNodeKey = new Map(manifest.nodes.map(entry => [entry.nodeKey, entry])); + 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.serverRevision; delete next.refreshRequired; outcomes - .filter(outcome => (outcome.success || outcome.remoteChanged) && outcome.nodeKey) - .forEach(outcome => { + .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); @@ -235,16 +208,16 @@ export class WorkspacePullService { manifest: WorkspaceManifest, recoveringCreateKeys: boolean ): PullOperation[] { - const localByKey = new Map(localNodes.map(node => [node.nodeKey, node])); + 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 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] : [])) + snapshot.changes.flatMap((change) => (change.nodeKey ? [[change.nodeKey, change] as const] : [])) ); - const remoteByKey = new Map(manifest.nodes.map(entry => [entry.nodeKey, entry])); + const remoteByKey = new Map(manifest.nodes.map((entry) => [entry.nodeKey, entry])); const replacedLocalKeys = new Set(); const context: PullOperationContext = { snapshot, @@ -256,7 +229,7 @@ export class WorkspacePullService { replacedLocalKeys, recoveringCreateKeys, }; - const operations = manifest.nodes.flatMap(entry => { + const operations = manifest.nodes.flatMap((entry) => { const operation = this.remoteOperation(entry, context); return operation ? [operation] : []; }); @@ -282,7 +255,7 @@ export class WorkspacePullService { : undefined; if (context.recoveringCreateKeys && !this.isFolder(entry.metadata) && !provisional) { const visiblePath = [...context.snapshot.visibleFiles.keys()].find( - filePath => filePath.toLowerCase() === entry.path.toLowerCase() + (filePath) => filePath.toLowerCase() === entry.path.toLowerCase() ); if (visiblePath) { return { @@ -373,7 +346,7 @@ export class WorkspacePullService { remoteByKey: Map, context: PullOperationContext ): PullOperation[] { - return localNodes.flatMap(node => { + return localNodes.flatMap((node) => { if (remoteByKey.has(node.nodeKey) || context.replacedLocalKeys.has(node.nodeKey)) { return []; } @@ -404,7 +377,7 @@ export class WorkspacePullService { return selectWorkspaceCandidates( root, paths, - operations.map(operation => ({ + operations.map((operation) => ({ value: operation, paths: [operation.path, operation.localPath].filter((value): value is string => Boolean(value)), })) @@ -504,6 +477,9 @@ export class WorkspacePullService { 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)) { @@ -575,7 +551,7 @@ export class WorkspacePullService { ): boolean { const source = sourcePath.toLowerCase(); const target = targetPath.toLowerCase(); - return appliedFolderMoves.some(move => { + return appliedFolderMoves.some((move) => { const sourceRoot = move.sourcePath.toLowerCase(); if (!source.startsWith(`${sourceRoot}/`)) { return false; @@ -624,8 +600,8 @@ export class WorkspacePullService { const metadataDirectory = path.join(root, ".package", "nodes"); const hasChildren = fs .readdirSync(metadataDirectory) - .filter(file => file.endsWith(".json") && file !== `${operation.nodeKey}.json`) - .some(file => { + .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; @@ -666,7 +642,7 @@ export class WorkspacePullService { } const parentKey = entry.metadata.parentNodeKey; if (parentKey) { - const parent = manifest.nodes.find(candidate => candidate.nodeKey === 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}.`); } @@ -703,13 +679,13 @@ export class WorkspacePullService { if ( !manifest || !Array.isArray(manifest.nodes) || - Object.keys(manifest as unknown as Record).some(field => field !== "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 => { + manifest.nodes.forEach((entry) => { const foldedPath = entry.path?.toLowerCase(); const metadata = entry.metadata as unknown as Record; const fields = entry as unknown as Record; @@ -721,9 +697,9 @@ export class WorkspacePullService { !entry.metadata?.type || "key" in metadata || "nodeKey" in metadata || - FORBIDDEN_METADATA_FIELDS.some(field => field in metadata) || + FORBIDDEN_METADATA_FIELDS.some((field) => field in metadata) || this.hasLegacyFilesystemName(entry.metadata) || - ["body", "kind", "assetType", "size", "contentDigest"].some(field => field in fields) || + ["body", "kind", "assetType", "size", "contentDigest"].some((field) => field in fields) || keys.has(entry.nodeKey) || paths.has(foldedPath) || !entry.eTag || @@ -734,7 +710,7 @@ export class WorkspacePullService { keys.add(entry.nodeKey); paths.add(foldedPath); }); - const byKey = new Map(manifest.nodes.map(entry => [entry.nodeKey, entry])); + 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)) { @@ -755,13 +731,13 @@ export class WorkspacePullService { resolving.delete(entry.nodeKey); resolved.add(entry.nodeKey); }; - manifest.nodes.forEach(entry => { + manifest.nodes.forEach((entry) => { resolve(entry, new Set()); }); const projectedPaths = projectWorkspacePaths( - manifest.nodes.map(entry => ({ nodeKey: entry.nodeKey, ...entry.metadata })) + manifest.nodes.map((entry) => ({ nodeKey: entry.nodeKey, ...entry.metadata })) ); - if (manifest.nodes.some(entry => projectedPaths.get(entry.nodeKey) !== entry.path)) { + 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."); } } @@ -775,12 +751,12 @@ export class WorkspacePullService { return ( first !== ".package" && first !== ".git" && - segments.every(segment => Boolean(segment) && segment !== "." && segment !== "..") + segments.every((segment) => Boolean(segment) && segment !== "." && segment !== "..") ); } private visibleAt(snapshot: WorkspaceSnapshot, filePath: string): boolean { - return [...snapshot.visibleFiles.keys()].some(value => value.toLowerCase() === filePath.toLowerCase()); + return [...snapshot.visibleFiles.keys()].some((value) => value.toLowerCase() === filePath.toLowerCase()); } private sameMetadata(left: WorkspaceNodeMetadata, right: WorkspaceNodeMetadata): boolean { @@ -794,7 +770,7 @@ export class WorkspacePullService { private sorted(value: unknown): unknown { if (Array.isArray(value)) { - return value.map(item => this.sorted(item)); + return value.map((item) => this.sorted(item)); } if (value && typeof value === "object") { return Object.fromEntries( diff --git a/src/commands/workspace/workspace.models.ts b/src/commands/workspace/workspace.models.ts index 1d9da607..0751d308 100644 --- a/src/commands/workspace/workspace.models.ts +++ b/src/commands/workspace/workspace.models.ts @@ -2,7 +2,6 @@ export interface WorkspaceState { schemaVersion: number; activePackageKey: string; activeBranch: string; - serverRevision?: string; manifestETag?: string; baselineDigests: Record; baselineNodeETags: Record; @@ -47,7 +46,6 @@ export interface WorkspaceNodeMetadata { type: string; parentNodeKey?: string | null; schemaVersion?: number; - serializedDocumentRef?: string; dependenciesConfiguration?: unknown; metadata?: Record; additionalFields?: Record; @@ -93,15 +91,9 @@ export interface WorkspaceSnapshot { } export interface WorkspacePushOptions { - full?: boolean; - overwrite?: boolean; assetType?: string; } -export interface WorkspacePullOptions { - full?: boolean; -} - export interface WorkspaceManifest { nodes: WorkspaceManifestNode[]; } diff --git a/src/commands/workspace/workspace.service.ts b/src/commands/workspace/workspace.service.ts index f40f9514..58c99086 100644 --- a/src/commands/workspace/workspace.service.ts +++ b/src/commands/workspace/workspace.service.ts @@ -1,10 +1,7 @@ import { createHash } from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; -import * as FormData from "form-data"; -import AdmZip = require("adm-zip"); import { Context } from "../../core/command/cli-context"; -import { fileService } from "../../core/utils/file-service"; import { GracefulError, logger } from "../../core/utils/logger"; import { BranchUtils } from "../../core/utils/branches"; import { WorkspaceApi } from "./workspace-api"; @@ -26,7 +23,6 @@ import { WorkspaceNode, WorkspaceNodeMetadata, WorkspacePackageIdentity, - WorkspacePullOptions, WorkspacePushOptions, WorkspacePushOutcome, WorkspaceSnapshot, @@ -90,26 +86,23 @@ export class WorkspaceService { if (fs.existsSync(target)) { throw new GracefulError(`Destination already exists: ${target}`); } - const remote = await this.downloadWorkspace(packageKey); - const temporary = this.validatedArchive(remote.archive, packageKey, projectKey, undefined, remote.manifest); + 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-")); - fs.rmSync(staging, { recursive: true }); - fs.cpSync(temporary, staging, { recursive: true, force: false, errorOnExist: true }); + 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; - } finally { - fs.rmSync(temporary, { recursive: true, force: true }); } logger.info(`Cloned ${packageKey} to ${target}`); } @@ -137,22 +130,26 @@ export class WorkspaceService { logger.info(`${options.create ? "Created and selected" : "Selected"} ${packageKey}.`); return; } - const remote = await this.downloadWorkspace(packageKey); - const temporary = this.validatedArchive(remote.archive, packageKey, projectKey, undefined, remote.manifest); + 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.applyCheckout(root, temporary, packageKey, projectKey, branch, current, options); + 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[] = [], options: WorkspacePullOptions = {}): Promise { - if (options.full) { - this.validateFullPullPaths(paths); - await this.pullFull(); - return; - } + public async pull(paths: string[] = []): Promise { const root = this.root(); const projectKey = this.packageIdentity(root).projectKey; const hasLocalState = fs.existsSync(this.statePath(root)); @@ -163,13 +160,8 @@ export class WorkspaceService { return; } } - let localState = hasLocalState ? this.state(root) : undefined; + const localState = hasLocalState ? this.state(root) : undefined; const recoveringCreateKeys = Boolean(localState?.refreshRequired); - if (localState?.serverRevision) { - localState = { ...localState }; - delete localState.serverRevision; - this.writeState(root, localState); - } const { packageKey, restoredObservation } = await this.pullTarget(root, projectKey, localState); const remote = await this.api.manifest(packageKey, localState?.manifestETag); if (remote.notModified) { @@ -198,7 +190,7 @@ export class WorkspaceService { manifest, recoveringCreateKeys ); - const failed = result.outcomes.filter(outcome => !outcome.success); + const failed = result.outcomes.filter((outcome) => !outcome.success); if (recoveringCreateKeys && (paths.length > 0 || failed.length > 0)) { result.state.refreshRequired = true; } @@ -208,7 +200,7 @@ export class WorkspaceService { delete result.state.manifestETag; } this.writeState(root, result.state); - result.outcomes.forEach(outcome => + result.outcomes.forEach((outcome) => logger.info( `${outcome.success ? "succeeded" : "failed"}: ${outcome.status} ${outcome.path}` + (outcome.error ? ` (${outcome.error})` : "") @@ -220,12 +212,6 @@ export class WorkspaceService { logger.info(`Pulled ${packageKey}.`); } - private validateFullPullPaths(paths: string[]): void { - if (paths.length > 0) { - throw new GracefulError("Workspace paths cannot be combined with --full."); - } - } - private async hydrateInitialPull( root: string, projectKey: string, @@ -255,30 +241,6 @@ export class WorkspaceService { logger.info(`Pulled ${packageKey}.`); } - private async pullFull(): Promise { - const root = this.root(); - await this.synchronizeGitTarget(root, false); - const state = this.state(root); - const projectKey = this.packageIdentity(root).projectKey; - if (!state.refreshRequired && this.snapshot(root).changes.length !== 0) { - throw new GracefulError("Workspace has local changes. Push or discard them before full pull."); - } - const remote = await this.downloadWorkspace(state.activePackageKey); - const temporary = this.validatedArchive( - remote.archive, - state.activePackageKey, - projectKey, - state.git, - remote.manifest - ); - try { - this.replaceWorkspaceContents(root, temporary); - } finally { - fs.rmSync(temporary, { recursive: true, force: true }); - } - logger.info(`Pulled ${state.activePackageKey}.`); - } - public status(directory?: string): WorkspaceChange[] { const changes = this.snapshot(this.root(directory)).changes.map(({ path: filePath, status }) => ({ path: filePath, @@ -287,7 +249,7 @@ export class WorkspaceService { if (changes.length === 0) { logger.info("Workspace is clean."); } else { - changes.forEach(change => logger.info(`${change.status}: ${change.path}`)); + changes.forEach((change) => logger.info(`${change.status}: ${change.path}`)); } return changes; } @@ -299,24 +261,10 @@ export class WorkspaceService { } public async push(paths: string[] = [], options: WorkspacePushOptions = {}): Promise { - if (options.full) { - if (paths.length > 0) { - throw new GracefulError("Workspace paths cannot be combined with --full."); - } - if (options.assetType) { - throw new GracefulError("--asset-type cannot be combined with --full."); - } - await this.pushFull(Boolean(options.overwrite)); - return; - } - if (options.overwrite) { - throw new GracefulError("--overwrite requires --full."); - } const root = this.root(); await this.synchronizeGitTarget(root, true); const snapshot = this.snapshot(root); const invalidatedBeforePush: WorkspaceState = { ...snapshot.state }; - delete invalidatedBeforePush.serverRevision; delete invalidatedBeforePush.manifestETag; this.writeState(root, invalidatedBeforePush); const outcomes: WorkspacePushOutcome[] = await new WorkspacePushService(this.api).push( @@ -325,20 +273,19 @@ export class WorkspaceService { paths, options.assetType ); - outcomes.forEach(outcome => + 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 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.serverRevision; delete invalidatedState.manifestETag; delete invalidatedState.refreshRequired; if (!remoteChanged) { @@ -369,69 +316,6 @@ export class WorkspaceService { logger.info(`Pushed ${snapshot.packageKey}.`); } - private async pushFull(overwrite: boolean): Promise { - const root = this.root(); - await this.synchronizeGitTarget(root, true); - const snapshot = this.snapshot(root); - if (snapshot.changes.some(change => change.status === "unresolved")) { - throw new GracefulError("Workspace has unresolved file identities. Record the intended moves before push."); - } - const zipPath = fileService.zipDirectoryAsSinglePackage(root, filePath => { - const folded = filePath.toLowerCase(); - return ( - folded !== ".git" && - !folded.startsWith(".git/") && - folded !== ".package/local" && - !folded.startsWith(".package/local/") - ); - }); - try { - if (!snapshot.state.serverRevision) { - throw new GracefulError("A full push requires a revision from workspace pull --full."); - } - const form = new FormData(); - form.append("packageFile", fs.createReadStream(zipPath), { filename: "workspace.zip" }); - const moves = Object.fromEntries( - snapshot.changes - .filter( - change => - Boolean(change.nodeKey) && - (change.status === "moved" || change.status === "moved, modified") - ) - .map(change => [change.nodeKey!, change.path]) - ); - if (Object.keys(moves).length > 0) { - form.append("moveMappings", JSON.stringify({ moves }), { contentType: "application/json" }); - } - await this.api.pushArchive(snapshot.packageKey, form, overwrite, snapshot.state.serverRevision); - const pendingRefresh: WorkspaceState = { ...snapshot.state, refreshRequired: true }; - delete pendingRefresh.serverRevision; - delete pendingRefresh.manifestETag; - this.writeState(root, pendingRefresh); - try { - const refreshed = await this.downloadWorkspace(snapshot.packageKey); - this.refreshMetadata( - root, - refreshed.archive, - snapshot.packageKey, - snapshot.projectKey, - snapshot.state.git, - refreshed.manifest - ); - } catch (error) { - const detail = error instanceof GracefulError ? ` ${error.message}` : ""; - const failure = new GracefulError( - `Push succeeded, but local state refresh failed.${detail} Run workspace pull before retrying.` - ); - failure.cause = error; - throw failure; - } - } finally { - fs.rmSync(zipPath, { force: true }); - } - 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); @@ -442,7 +326,7 @@ export class WorkspaceService { throw new GracefulError(`Tracked file not found: ${sourcePath}`); } const targetOwned = snapshot.expectedFiles.some( - file => file.nodeKey !== tracked.nodeKey && file.path.toLowerCase() === targetPath.toLowerCase() + (file) => file.nodeKey !== tracked.nodeKey && file.path.toLowerCase() === targetPath.toLowerCase() ); if (targetOwned) { throw new GracefulError(`Target path is already tracked: ${targetPath}`); @@ -508,8 +392,8 @@ export class WorkspaceService { 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)! })) + .filter((node) => this.isFolder(node)) + .map((node) => ({ nodeKey: node.nodeKey, path: pathByKey.get(node.nodeKey)! })) .sort((left, right) => left.path.localeCompare(right.path)); } @@ -518,18 +402,18 @@ export class WorkspaceService { 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())); + 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 => ({ + ...missing.map((folder) => ({ nodeKey: folder.nodeKey, path: folder.path, status: "unresolved" as const, kind: "folder" as const, })), - ...additions.map(folderPath => ({ + ...additions.map((folderPath) => ({ path: folderPath, status: "added" as const, kind: "folder" as const, @@ -539,11 +423,11 @@ export class WorkspaceService { 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 byKey = new Map(nodes.map((node) => [node.nodeKey, node])); const pathByKey = projectWorkspacePaths(nodes, packageKey); const expected = nodes - .filter(node => !this.isFolder(node)) - .map(node => { + .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}.`); @@ -556,7 +440,7 @@ export class WorkspaceService { return { nodeKey: node.nodeKey, path: sourcePath, assetType: node.type, digest: baseline }; }); const foldedPaths = new Set(); - expected.forEach(file => { + expected.forEach((file) => { const foldedPath = file.path.toLowerCase(); if (foldedPaths.has(foldedPath)) { throw new GracefulError(`Duplicate workspace path in node metadata: ${file.path}`); @@ -586,9 +470,9 @@ export class WorkspaceService { } return fs .readdirSync(directory, { withFileTypes: true }) - .filter(entry => entry.isFile() && entry.name.endsWith(".json")) + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) .sort((left, right) => left.name.localeCompare(right.name)) - .map(entry => { + .map((entry) => { const nodeKey = entry.name.slice(0, -".json".length); const metadata = JSON.parse( fs.readFileSync(path.join(directory, entry.name), "utf-8") @@ -600,7 +484,7 @@ export class WorkspaceService { !metadata.type || "key" in fields || "nodeKey" in fields || - NON_SEMANTIC_NODE_FIELDS.some(field => field in fields) || + NON_SEMANTIC_NODE_FIELDS.some((field) => field in fields) || this.hasLegacyFilesystemName(metadata) ) { throw new GracefulError(`Invalid node metadata file: ${entry.name}`); @@ -616,7 +500,7 @@ export class WorkspaceService { const visit = (directory: string, relativeDirectory: string): void => { fs.readdirSync(directory, { withFileTypes: true }) .sort((left, right) => left.name.localeCompare(right.name)) - .forEach(entry => { + .forEach((entry) => { if ( !relativeDirectory && (entry.name.toLowerCase() === ".package" || entry.name.toLowerCase() === ".git") @@ -655,7 +539,7 @@ export class WorkspaceService { }; visit(root, ""); const nonEmptyParents = new Set(); - [...files.keys(), ...folders].forEach(entryPath => { + [...files.keys(), ...folders].forEach((entryPath) => { let parent = path.posix.dirname(entryPath); while (parent !== ".") { nonEmptyParents.add(parent.toLowerCase()); @@ -665,7 +549,7 @@ export class WorkspaceService { return { files, folders, - emptyFolders: new Set([...folders].filter(folderPath => !nonEmptyParents.has(folderPath.toLowerCase()))), + emptyFolders: new Set([...folders].filter((folderPath) => !nonEmptyParents.has(folderPath.toLowerCase()))), }; } @@ -675,23 +559,23 @@ export class WorkspaceService { private trackedFile(snapshot: WorkspaceSnapshot, sourcePath: string): ExpectedWorkspaceFile | undefined { const foldedSourcePath = sourcePath.toLowerCase(); - const expected = snapshot.expectedFiles.find(file => file.path.toLowerCase() === foldedSourcePath); + 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 + (file) => this.moveHintTarget(snapshot.state.moveHints[file.nodeKey])?.toLowerCase() === foldedSourcePath ); if (hinted) { return hinted; } const classified = snapshot.changes.find( - change => + (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; + return classified ? snapshot.expectedFiles.find((file) => file.nodeKey === classified.nodeKey) : undefined; } private validateParentMove( @@ -707,7 +591,7 @@ export class WorkspaceService { 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 targetParent = nodes.find((node) => this.isFolder(node) && paths.get(node.nodeKey) === targetParentPath); const targetParentKey = targetParentPath ? targetParent?.nodeKey || `__workspace_target__:${targetParentPath}` : undefined; @@ -725,209 +609,80 @@ export class WorkspaceService { ); } - private async downloadWorkspace( - packageKey: string - ): Promise<{ archive: { archive: Buffer; eTag: string }; manifest: WorkspaceManifest }> { - for (let attempt = 0; attempt < 3; attempt += 1) { - const archive = await this.api.download(packageKey); - const manifest = await this.api.manifest(packageKey); - if (!manifest.notModified && manifest.manifest && manifest.eTag === archive.eTag) { - return { archive, manifest: manifest.manifest }; - } + 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."); } - throw new GracefulError("Filesystem archive and manifest revisions did not stabilize."); + return { manifest: remote.manifest, eTag: remote.eTag }; } - private refreshMetadata( + private async materializeWorkspace( root: string, - download: { archive: Buffer; eTag: string }, - packageKey: string, projectKey: string, - observation: WorkspaceGitObservation | undefined, - manifest: WorkspaceManifest - ): void { - const extracted = this.validatedArchive(download, packageKey, projectKey, observation, manifest); - try { - this.replaceMetadataDirectory(root, path.join(extracted, ".package")); - } finally { - fs.rmSync(extracted, { recursive: true, force: true }); - } - } - - private replaceMetadataDirectory(root: string, sourceMetadata: string): void { - const refreshRoot = fs.mkdtempSync(path.join(path.dirname(root), `.${path.basename(root)}-pacman-refresh-`)); - const stagedMetadata = path.join(refreshRoot, "metadata"); - const previousMetadata = path.join(refreshRoot, "previous"); - const metadata = path.join(root, ".package"); - let preserveBackup = false; - try { - fs.cpSync(sourceMetadata, stagedMetadata, { recursive: true }); - fs.renameSync(metadata, previousMetadata); - try { - fs.renameSync(stagedMetadata, metadata); - } catch (error) { - try { - fs.renameSync(previousMetadata, metadata); - } catch (restoreError) { - preserveBackup = true; - const failure = new GracefulError( - `Metadata refresh failed; workspace metadata backup remains at ${previousMetadata}.` - ); - failure.cause = restoreError; - throw failure; - } - throw error; - } - } finally { - if (!preserveBackup) { - fs.rmSync(refreshRoot, { recursive: true, force: true }); - } - } - } - - private validatedArchive( - download: { archive: Buffer; eTag: string }, - packageKey: string, - projectKey: string = BranchUtils.extractProjectKey(packageKey), - observation?: WorkspaceGitObservation, - manifest?: WorkspaceManifest - ): string { - const zip = new AdmZip(download.archive); - if (!zip.getEntry(".package/package.json") || !zip.getEntry(".package/.gitignore")) { - throw new GracefulError("Archive does not contain Pacman package metadata."); - } - if ( - zip.getEntries().some(entry => { - const folded = entry.entryName.toLowerCase(); - return folded === ".package/local" || folded.startsWith(".package/local/"); - }) - ) { - throw new GracefulError("Archive contains local Pacman workspace state."); - } - const temporary = fileService.extractZipBufferToTempDirectory(download.archive); - try { - if (this.packageIdentity(temporary).projectKey !== projectKey) { - throw new GracefulError("Archive project key does not match the requested project."); - } - this.validateGitignore(temporary); - fs.mkdirSync(path.join(temporary, ".package", "nodes"), { recursive: true }); - this.hydrateState(temporary, download.eTag, packageKey, observation, manifest); - const snapshot = this.snapshot(temporary); - if (snapshot.changes.length !== 0) { - throw new GracefulError("Archive content does not match its workspace baseline."); - } - return temporary; - } catch (error) { - fs.rmSync(temporary, { recursive: true, force: true }); - throw error; - } - } - - private reconcileLocalState( - root: string, - remoteRoot: string, packageKey: string, branch: string, - observation?: WorkspaceGitObservation - ): void { - const projectKey = this.packageIdentity(root).projectKey; - if (this.packageIdentity(remoteRoot).projectKey !== projectKey) { - throw new GracefulError("Remote archive project key does not match this workspace."); + manifest: WorkspaceManifest, + manifestETag: string + ): Promise { + if (BranchUtils.extractProjectKey(packageKey) !== projectKey) { + throw new GracefulError("Active Pacman package does not belong to this workspace project."); } - this.validateGitignore(root); - const remoteState = this.state(remoteRoot); - const emptyState: WorkspaceState = { + 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, - serverRevision: remoteState.serverRevision, - baselineDigests: remoteState.baselineDigests, - baselineNodeETags: remoteState.baselineNodeETags, + baselineDigests: {}, + baselineNodeETags: {}, moveHints: {}, }; - const remoteFiles = new Map( - this.expectedFiles(remoteRoot, emptyState, packageKey).map(file => [file.nodeKey, file]) - ); - const localFiles = this.expectedFiles(root, emptyState, packageKey); - const remoteNodes = new Map(this.nodes(remoteRoot).map(node => [node.nodeKey, node])); - this.nodes(root).forEach(node => { - const remote = remoteNodes.get(node.nodeKey); - if (remote && this.isFolder(remote) !== this.isFolder(node)) { - throw new GracefulError(`Node metadata type conflicts with the server for ${node.nodeKey}.`); - } - }); - const moveHints: Record = Object.fromEntries( - localFiles - .filter(file => remoteFiles.has(file.nodeKey) && remoteFiles.get(file.nodeKey)!.path !== file.path) - .map(file => [file.nodeKey, { sourcePath: remoteFiles.get(file.nodeKey)!.path, targetPath: file.path }]) - ); - const reconciledState: WorkspaceState = { - ...remoteState, - activePackageKey: packageKey, - activeBranch: branch, - moveHints, - }; - if (observation) { - reconciledState.git = observation; - } else { - delete reconciledState.git; + 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).`); } - const expectedFiles = this.expectedFiles(root, reconciledState, packageKey); - classifyWorkspaceChanges(expectedFiles, this.visibleFiles(root), this.moveHintTargets(moveHints)); - this.writeState(root, reconciledState); + result.state.manifestETag = manifestETag; + this.writeState(root, result.state); } - private hydrateState( + private async hydrateRemoteBaseline( root: string, - eTag: string, + state: WorkspaceState, packageKey: string, - observation?: WorkspaceGitObservation, - manifest?: WorkspaceManifest - ): WorkspaceState { - if (!eTag) { - throw new GracefulError("Filesystem archive response contains an invalid ETag."); - } - const projectKey = this.packageIdentity(root).projectKey; - if (BranchUtils.extractProjectKey(packageKey) !== projectKey) { - throw new GracefulError("Active Pacman package does not belong to the archive project."); - } - const emptyState: WorkspaceState = { - schemaVersion: 1, - activePackageKey: packageKey, - activeBranch: this.branchFromPackageKey(projectKey, packageKey), - serverRevision: eTag, - baselineDigests: {}, - baselineNodeETags: {}, - moveHints: {}, - }; - const baselineDigests = Object.fromEntries( - this.expectedFiles(root, emptyState, packageKey).map(file => { - const absolute = this.resolveVisiblePath(root, file.path); - if (!fs.existsSync(absolute) || !fs.lstatSync(absolute).isFile()) { - throw new GracefulError(`Archive is missing visible content for node ${file.nodeKey}.`); - } - return [file.nodeKey, this.digest(absolute)]; - }) - ); - let state: WorkspaceState = { ...emptyState, baselineDigests }; - if (manifest) { - const hydrated = new WorkspacePullService(this.api).hydrateArchiveBaseline( - root, - state, - packageKey, - state.activeBranch, - manifest - ); - if (!this.sameStringRecord(hydrated.baselineDigests, baselineDigests)) { - throw new GracefulError("Archive content does not match the workspace manifest."); + 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}.`); } - state = { ...hydrated, serverRevision: eTag, manifestETag: eTag }; - } + }); + const base = { ...state }; if (observation) { - state.git = observation; + base.git = observation; + } else { + delete base.git; } - this.writeState(root, state); - return state; + 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 { @@ -959,8 +714,8 @@ export class WorkspaceService { } catch (error) { try { fs.readdirSync(root) - .filter(entry => entry !== ".git") - .forEach(entry => fs.rmSync(path.join(root, entry), { recursive: true, force: true })); + .filter((entry) => entry !== ".git") + .forEach((entry) => fs.rmSync(path.join(root, entry), { recursive: true, force: true })); this.moveEntries(backup, root); } catch (restoreError) { preserveBackup = true; @@ -980,9 +735,9 @@ export class WorkspaceService { private moveEntries(source: string, target: string, excluded: Set = new Set()): void { fs.readdirSync(source) - .filter(entry => !excluded.has(entry)) + .filter((entry) => !excluded.has(entry)) .sort((left, right) => left.localeCompare(right)) - .forEach(entry => fs.renameSync(path.join(source, entry), path.join(target, entry))); + .forEach((entry) => fs.renameSync(path.join(source, entry), path.join(target, entry))); } private sameFile(source: string, target: string): boolean { @@ -1016,28 +771,26 @@ export class WorkspaceService { parsed.schemaVersion !== 1 || !parsed.activePackageKey || !parsed.activeBranch || - (parsed.serverRevision !== undefined && - (typeof parsed.serverRevision !== "string" || !parsed.serverRevision)) || (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) + (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) + (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) + (value) => typeof value === "string" || this.isStructuredMoveHint(value) ))) || (parsed.git !== undefined && (!parsed.git || @@ -1057,9 +810,6 @@ export class WorkspaceService { baselineNodeETags: parsed.baselineNodeETags || {}, moveHints: parsed.moveHints || {}, }; - if (parsed.serverRevision) { - state.serverRevision = parsed.serverRevision; - } if (parsed.manifestETag) { state.manifestETag = parsed.manifestETag; } @@ -1130,21 +880,21 @@ export class WorkspaceService { ): Record { const completedNodeKeys = new Set( outcomes - .filter(outcome => outcome.success || outcome.remoteChanged) - .flatMap(outcome => (outcome.nodeKey ? [outcome.nodeKey] : [])) + .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) => !outcome.success && !outcome.remoteChanged && outcome.nodeKey && (outcome.status === "moved" || outcome.status === "moved, modified") ) - .forEach(outcome => { + .forEach((outcome) => { retained[outcome.nodeKey!] ||= hints[outcome.nodeKey!] || outcome.path; }); return retained; @@ -1172,26 +922,6 @@ export class WorkspaceService { return created.packageKey; } - private async applyCheckout( - root: string, - temporary: string, - packageKey: string, - projectKey: string, - branch: string, - current: WorkspaceState | undefined, - options: WorkspaceCheckoutOptions - ): Promise { - if (options.create || options.linkGit) { - const observation = options.linkGit ? await this.linkCurrentGitBranch(root, projectKey, branch) : undefined; - this.reconcileLocalState(root, temporary, packageKey, branch, observation); - 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."); - } - this.replaceWorkspaceContents(root, temporary); - } - private async pullTarget( root: string, projectKey: string, @@ -1269,36 +999,20 @@ export class WorkspaceService { branch: string, observation: WorkspaceGitObservation ): Promise { - const remote = await this.api.manifest(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 hydrated = await new WorkspacePullService(this.api).hydrateRemoteBaseline( - { ...state, git: observation }, - packageKey, - branch, - manifest - ); - hydrated.manifestETag = remote.eTag; - this.writeState(root, this.withRemotePathHints(root, hydrated, manifest)); + 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]) + 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 => { + .filter((file) => { const remotePath = remotePathByNodeKey.get(file.nodeKey); return remotePath && remotePath.toLowerCase() !== file.path.toLowerCase(); }) - .map(file => [ + .map((file) => [ file.nodeKey, { sourcePath: remotePathByNodeKey.get(file.nodeKey)!, targetPath: file.path }, ]) @@ -1365,11 +1079,6 @@ export class WorkspaceService { fs.writeFileSync(this.statePath(root), JSON.stringify(state, null, 2) + "\n", { mode: 0o600 }); } - private sameStringRecord(left: Record, right: Record): boolean { - const leftKeys = Object.keys(left); - return leftKeys.length === Object.keys(right).length && leftKeys.every(key => left[key] === right[key]); - } - private packageIdentity(root: string): WorkspacePackageIdentity { const parsed = JSON.parse( fs.readFileSync(this.packageIdentityPath(root), "utf-8") diff --git a/tests/commands/workspace/module.spec.ts b/tests/commands/workspace/module.spec.ts index 85143532..5f9eb511 100644 --- a/tests/commands/workspace/module.spec.ts +++ b/tests/commands/workspace/module.spec.ts @@ -41,7 +41,6 @@ describe("Workspace module", () => { 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", "pull", "--full"); 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"); @@ -57,12 +56,9 @@ describe("Workspace module", () => { discard: false, linkGit: false, }); - expect(pull).toHaveBeenNthCalledWith(1, ["target", "other.md"], { full: false }); - expect(pull).toHaveBeenNthCalledWith(2, [], { full: true }); + expect(pull).toHaveBeenCalledWith(["target", "other.md"]); expect(status).toHaveBeenCalledWith("target"); expect(push).toHaveBeenCalledWith(["target", "other.md"], { - full: false, - overwrite: false, assetType: "MARKDOWN_FILE", }); expect(move).toHaveBeenCalledWith("old.md", "new.md", true); diff --git a/tests/commands/workspace/workspace.service.spec.ts b/tests/commands/workspace/workspace.service.spec.ts index 88f73938..30e90d48 100644 --- a/tests/commands/workspace/workspace.service.spec.ts +++ b/tests/commands/workspace/workspace.service.spec.ts @@ -1,10 +1,8 @@ import { createHash } from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; -import AdmZip = require("adm-zip"); import { WorkspaceService } from "../../../src/commands/workspace/workspace.service"; import { WorkspaceGitService } from "../../../src/commands/workspace/workspace-git.service"; -import { fileService } from "../../../src/core/utils/file-service"; import { logger } from "../../../src/core/utils/logger"; import { testContext } from "../../utls/test-context"; import { @@ -22,13 +20,6 @@ import { const PACKAGE_KEY = "pkg-1"; const BRANCH = "feature-a"; const BRANCH_PACKAGE_KEY = `${PACKAGE_KEY}@${BRANCH}`; -const ARCHIVE_URL = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${PACKAGE_KEY}/file-archive`; -const PUSH_URL = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${PACKAGE_KEY}/file-archive`; - -function archiveUrl(packageKey: string): string { - return `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${encodeURIComponent(packageKey)}/file-archive`; -} - function fileUrl(filePath: string, packageKey: string = PACKAGE_KEY): string { return `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${encodeURIComponent(packageKey)}/files/${filePath}`; } @@ -63,7 +54,7 @@ function eTag(value: string): string { function metadata(files: TestFile[]): Record { const nodes: Record = {}; const folders = new Map(); - files.forEach(file => { + files.forEach((file) => { const segments = file.path.split("/"); let parentNodeKey: string | null = null; for (let index = 0; index < segments.length - 1; index += 1) { @@ -92,22 +83,17 @@ function metadata(files: TestFile[]): Record { return nodes; } -function state( - files: TestFile[], - serverRevision: string = eTag("revision-1"), - moveHints: Record = {} -): object { +function state(files: TestFile[], moveHints: Record = {}): object { const nodes = metadata(files); return { schemaVersion: 1, activePackageKey: PACKAGE_KEY, activeBranch: "main", - serverRevision, manifestETag: eTag("manifest"), - baselineDigests: Object.fromEntries(files.map(file => [file.nodeKey, digest(file.content)])), + 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); + Object.keys(nodes).map((nodeKey) => { + const file = files.find((candidate) => candidate.nodeKey === nodeKey); return [nodeKey, eTag(file ? file.content : nodeKey)]; }) ), @@ -115,25 +101,6 @@ function state( }; } -function archive(files: TestFile[]): Buffer { - const zip = new AdmZip(); - zip.addFile(".package/.gitignore", Buffer.from("local/\n")); - zip.addFile(".package/package.json", Buffer.from(JSON.stringify({ schemaVersion: 1, projectKey: PACKAGE_KEY }))); - zip.addFile(".package/nodes/", Buffer.alloc(0)); - Object.entries(metadata(files)).forEach(([nodeKey, node]) => { - zip.addFile(`.package/nodes/${nodeKey}.json`, Buffer.from(JSON.stringify(node))); - }); - files.forEach(file => zip.addFile(file.path, Buffer.from(file.content))); - return zip.toBuffer(); -} - -function emptyArchiveWithoutNodeMetadata(): Buffer { - const zip = new AdmZip(); - zip.addFile(".package/.gitignore", Buffer.from("local/\n")); - zip.addFile(".package/package.json", Buffer.from(JSON.stringify({ schemaVersion: 1, projectKey: PACKAGE_KEY }))); - return zip.toBuffer(); -} - function manifest(files: TestFile[]): Buffer { const nodes = metadata(files) as Record; const byKey = new Map(Object.entries(nodes)); @@ -153,7 +120,7 @@ function manifest(files: TestFile[]): Buffer { return Buffer.from( JSON.stringify({ nodes: Object.entries(nodes).map(([nodeKey, node]) => { - const file = files.find(candidate => candidate.nodeKey === nodeKey); + const file = files.find((candidate) => candidate.nodeKey === nodeKey); return file ? { nodeKey, @@ -176,20 +143,18 @@ function mockManifest( ): void { mockAxiosGet(manifestUrl(packageKey), manifest(files), { etag: manifestETag }); if (mockBodies) { - files.forEach(file => + files.forEach((file) => mockAxiosGet(fileUrl(file.path, packageKey), Buffer.from(file.content), { etag: eTag(file.content) }) ); } } -function mockWorkspaceDownload( +function mockWorkspaceHydration( files: TestFile[], revision: string = eTag("revision-1"), - packageKey: string = PACKAGE_KEY, - body: Buffer = archive(files) + packageKey: string = PACKAGE_KEY ): void { - mockAxiosGet(archiveUrl(packageKey), body, { etag: revision }); - mockManifest(files, packageKey, revision); + mockManifest(files, packageKey, revision, true); } function writeWorkspace( @@ -206,7 +171,7 @@ function writeWorkspace( Object.entries(metadata(files)).forEach(([nodeKey, node]) => { fs.writeFileSync(path.join(process.cwd(), ".package", "nodes", `${nodeKey}.json`), JSON.stringify(node)); }); - files.forEach(file => { + 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); }); @@ -233,7 +198,7 @@ function removeWorkspace(): void { PACKAGE_KEY, "branch-workspace", "empty-workspace", - ].forEach(entry => fs.rmSync(path.join(process.cwd(), entry), { recursive: true, force: true })); + ].forEach((entry) => fs.rmSync(path.join(process.cwd(), entry), { recursive: true, force: true })); } function mockGit( @@ -251,8 +216,8 @@ describe("Workspace service", () => { beforeEach(removeWorkspace); afterEach(removeWorkspace); - it("clones and validates a filesystem archive", async () => { - mockWorkspaceDownload([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }]); + 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 { @@ -272,7 +237,6 @@ describe("Workspace service", () => { schemaVersion: 1, activePackageKey: PACKAGE_KEY, activeBranch: "main", - serverRevision: eTag("revision-1"), manifestETag: eTag("revision-1"), baselineDigests: { "node-1": digest("original") }, baselineNodeETags: { @@ -286,7 +250,7 @@ describe("Workspace service", () => { 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)); + 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 { @@ -295,15 +259,26 @@ describe("Workspace service", () => { }); it("rejects a clone response without an ETag", async () => { - mockAxiosGet(ARCHIVE_URL, archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }])); + mockAxiosGet(manifestUrl(), manifest([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }])); await expect(new WorkspaceService(testContext).clone(PACKAGE_KEY)).rejects.toThrow( - "Filesystem archive response does not contain an ETag." + "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 () => { - mockWorkspaceDownload([], eTag("empty-revision"), PACKAGE_KEY, emptyArchiveWithoutNodeMetadata()); + mockWorkspaceHydration([], eTag("empty-revision"), PACKAGE_KEY); await new WorkspaceService(testContext).clone(PACKAGE_KEY, "empty-workspace"); @@ -313,7 +288,6 @@ describe("Workspace service", () => { schemaVersion: 1, activePackageKey: PACKAGE_KEY, activeBranch: "main", - serverRevision: eTag("empty-revision"), manifestETag: eTag("empty-revision"), baselineDigests: {}, baselineNodeETags: {}, @@ -322,7 +296,7 @@ describe("Workspace service", () => { }); it("clones a selected branch while keeping stable project identity", async () => { - mockWorkspaceDownload( + mockWorkspaceHydration( [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "branch" }], eTag("branch-revision"), BRANCH_PACKAGE_KEY @@ -344,7 +318,7 @@ describe("Workspace service", () => { it("checks out an existing branch atomically", async () => { writeWorkspace(); - mockWorkspaceDownload( + mockWorkspaceHydration( [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "branch" }], eTag("branch-revision"), BRANCH_PACKAGE_KEY @@ -370,7 +344,7 @@ describe("Workspace service", () => { branchKey: BRANCH, packageKey: BRANCH_PACKAGE_KEY, }); - mockWorkspaceDownload( + mockWorkspaceHydration( [{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }], eTag("branch-revision"), BRANCH_PACKAGE_KEY @@ -589,7 +563,7 @@ describe("Workspace service", () => { const bodyReads = (mockedAxiosInstance.get as jest.Mock).mock.calls.filter(([url]) => url !== manifestUrl()); expect(bodyReads).toHaveLength(3); - changedIndexes.forEach(index => { + changedIndexes.forEach((index) => { expect(fs.readFileSync(path.join(process.cwd(), remote[index].path), "utf-8")).toBe(`remote-${index}`); }); }); @@ -600,10 +574,10 @@ describe("Workspace service", () => { { 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` })); + const remote = original.map((file) => ({ ...file, content: `${file.content} remote` })); writeWorkspace(original); mockManifest(remote); - remote.slice(0, 2).forEach(file => { + remote.slice(0, 2).forEach((file) => { mockAxiosGet(fileUrl(file.path), Buffer.from(file.content), { etag: eTag(file.content) }); }); @@ -653,9 +627,9 @@ describe("Workspace service", () => { 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"); + const sharedFolder = remoteManifest.nodes.find((node) => node.path === "Target/Shared"); sharedFolder.nodeKey = sharedKey; - remoteManifest.nodes.find(node => node.nodeKey === "node-1").metadata.parentNodeKey = 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) }); @@ -720,15 +694,6 @@ describe("Workspace service", () => { expect(localState).not.toHaveProperty("serverRevision"); }); - it("rejects paths with a full pull", async () => { - writeWorkspace(); - - await expect(new WorkspaceService(testContext).pull(["Guides"], { full: true })).rejects.toThrow( - "Workspace paths cannot be combined with --full" - ); - expect(mockedAxiosInstance.get).not.toHaveBeenCalled(); - }); - it("hydrates local state after an external Git restore without overwriting files", async () => { writeWorkspace(); fs.rmSync(path.join(process.cwd(), ".package", "local"), { recursive: true }); @@ -882,59 +847,6 @@ describe("Workspace service", () => { ); }); - it("restores the existing workspace when applying a pull fails", async () => { - writeWorkspace(); - mockWorkspaceDownload([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }], eTag("revision-2")); - const originalRename = fs.renameSync; - const rename = jest.spyOn(fs, "renameSync").mockImplementation((source, target) => { - const sourceParent = path.basename(path.dirname(source.toString())); - if (sourceParent.startsWith(".package-pull-") && !sourceParent.startsWith(".package-pull-backup-")) { - throw new Error("apply failed"); - } - originalRename(source, target); - }); - - try { - await expect(new WorkspaceService(testContext).pull([], { full: true })).rejects.toThrow("apply failed"); - } finally { - rename.mockRestore(); - } - expect(fs.readFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "utf-8")).toBe("original"); - expect(new WorkspaceService(testContext).status()).toEqual([]); - }); - - it("preserves the workspace backup when pull and rollback both fail", async () => { - writeWorkspace(); - mockWorkspaceDownload([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "remote" }], eTag("revision-2")); - const originalRename = fs.renameSync; - const rename = jest.spyOn(fs, "renameSync").mockImplementation((source, target) => { - const sourceParent = path.basename(path.dirname(source.toString())); - if (sourceParent.startsWith(".package-pull-")) { - throw new Error("rename failed"); - } - originalRename(source, target); - }); - let backup: string | undefined; - - try { - await new WorkspaceService(testContext).pull([], { full: true }); - } catch (error) { - expect(error).toBeInstanceOf(Error); - const match = (error as Error).message.match(/backup remains at (.+)\.$/); - backup = match?.[1]; - } finally { - rename.mockRestore(); - } - - expect(backup).toBeDefined(); - expect(fs.existsSync(backup!)).toBe(true); - fs.readdirSync(backup!).forEach(entry => { - originalRename(path.join(backup!, entry), path.join(process.cwd(), entry)); - }); - fs.rmSync(backup!, { recursive: true, force: true }); - expect(new WorkspaceService(testContext).status()).toEqual([]); - }); - it("infers an unchanged move performed by another tool without writing path state", () => { writeWorkspace(); fs.mkdirSync(path.join(process.cwd(), "Pages")); @@ -995,7 +907,7 @@ describe("Workspace service", () => { const changes = new WorkspaceService(testContext).status(); expect(changes).toHaveLength(4); - expect(changes.every(change => change.status === "unresolved")).toBe(true); + expect(changes.every((change) => change.status === "unresolved")).toBe(true); }); it("reports clean, modified, added, and deleted files", () => { @@ -1186,10 +1098,10 @@ describe("Workspace service", () => { 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 => { + const exists = jest.spyOn(fs, "existsSync").mockImplementation((candidate) => { return candidate.toString() === target || existsSync(candidate); }); - const lstat = jest.spyOn(fs, "lstatSync").mockImplementation(candidate => { + const lstat = jest.spyOn(fs, "lstatSync").mockImplementation((candidate) => { return candidate.toString() === target ? lstatSync(source) : lstatSync(candidate); }); @@ -1212,28 +1124,6 @@ describe("Workspace service", () => { ); }); - it("rejects an archive without stable package metadata", async () => { - const zip = new AdmZip(); - zip.addFile("Guides/Guide.md", Buffer.from("original")); - mockAxiosGet(ARCHIVE_URL, zip.toBuffer(), { etag: eTag("revision-1") }); - mockManifest([], PACKAGE_KEY, eTag("revision-1")); - - await expect(new WorkspaceService(testContext).clone(PACKAGE_KEY)).rejects.toThrow( - "Archive does not contain Pacman package metadata" - ); - }); - - it("rejects downloaded archives containing local workspace state", async () => { - const zip = new AdmZip(archive([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "original" }])); - zip.addFile(".package/local/state.json", Buffer.from("{}")); - mockAxiosGet(ARCHIVE_URL, zip.toBuffer(), { etag: eTag("revision-1") }); - mockManifest([], PACKAGE_KEY, eTag("revision-1")); - - await expect(new WorkspaceService(testContext).clone(PACKAGE_KEY)).rejects.toThrow( - "Archive contains local Pacman workspace state" - ); - }); - it("pushes only three changed files from a one hundred file workspace", async () => { const original = Array.from({ length: 100 }, (_, index) => ({ nodeKey: `node-${index}`, @@ -1353,8 +1243,8 @@ describe("Workspace service", () => { { 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 => { + 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, @@ -1578,7 +1468,7 @@ describe("Workspace service", () => { { 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`)); + 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"), { @@ -1717,127 +1607,6 @@ describe("Workspace service", () => { }); }); - it("rejects incompatible full-push options", async () => { - writeWorkspace(); - - await expect(new WorkspaceService(testContext).push(["Guides"], { full: true })).rejects.toThrow( - "Workspace paths cannot be combined with --full" - ); - await expect(new WorkspaceService(testContext).push([], { overwrite: true })).rejects.toThrow( - "--overwrite requires --full" - ); - await expect( - new WorkspaceService(testContext).push([], { full: true, assetType: "MARKDOWN_FILE" }) - ).rejects.toThrow("--asset-type cannot be combined with --full"); - }); - - it("pushes resolved changes and refreshes disposable metadata", 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); - const zip = jest.spyOn(fileService, "zipDirectoryAsSinglePackage"); - const originalRename = fs.renameSync; - const rename = jest.spyOn(fs, "renameSync").mockImplementation((source, target) => { - const workspaceRoot = `${process.cwd()}${path.sep}`; - const refreshPrefix = path.join( - path.dirname(process.cwd()), - `.${path.basename(process.cwd())}-pacman-refresh-` - ); - const sourcePath = path.resolve(source.toString()); - const targetPath = path.resolve(target.toString()); - const sourceOnWorkspaceFilesystem = - sourcePath.startsWith(workspaceRoot) || sourcePath.startsWith(refreshPrefix); - const targetOnWorkspaceFilesystem = - targetPath.startsWith(workspaceRoot) || targetPath.startsWith(refreshPrefix); - if (sourceOnWorkspaceFilesystem !== targetOnWorkspaceFilesystem) { - throw Object.assign(new Error("cross-device rename"), { code: "EXDEV" }); - } - originalRename(source, target); - }); - mockAxiosPost(PUSH_URL, {}); - const remoteFiles = [{ nodeKey: "node-1", path: "Pages/Guide.md", content: "changed" }]; - const remoteArchive = new AdmZip(archive(remoteFiles)); - remoteArchive.addFile( - ".package/nodes/folder-1.json", - Buffer.from(JSON.stringify({ name: "Guides", type: "FOLDER", parentNodeKey: null })) - ); - remoteArchive.addFile("Guides/", Buffer.alloc(0)); - mockAxiosGet(archiveUrl(PACKAGE_KEY), remoteArchive.toBuffer(), { etag: eTag("revision-2") }); - const remoteManifest = JSON.parse(manifest(remoteFiles).toString("utf-8")); - remoteManifest.nodes.push({ - nodeKey: "folder-1", - path: "Guides", - eTag: eTag("folder-1"), - metadata: { name: "Guides", type: "FOLDER", parentNodeKey: null }, - }); - mockAxiosGet(manifestUrl(), Buffer.from(JSON.stringify(remoteManifest)), { etag: eTag("revision-2") }); - - try { - await service.push([], { full: true, overwrite: true }); - } finally { - rename.mockRestore(); - } - - expect(mockedAxiosInstance.post).toHaveBeenCalledWith( - PUSH_URL, - expect.anything(), - expect.objectContaining({ - params: { overwrite: true }, - headers: expect.objectContaining({ "If-Match": eTag("revision-1") }), - }) - ); - const include = zip.mock.calls[0][1]!; - expect(include(".package/local/state.json")).toBe(false); - expect(include(".package/nodes/node-1.json")).toBe(true); - const form = (mockedAxiosInstance.post as jest.Mock).mock.calls[0][1] as { _streams: unknown[] }; - expect(form._streams).toContain(JSON.stringify({ moves: { "node-1": "Pages/Guide.md" } })); - expect(service.status()).toEqual([]); - expect( - JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package", "local", "state.json"), "utf-8")) - ).toEqual({ - schemaVersion: 1, - activePackageKey: PACKAGE_KEY, - activeBranch: "main", - serverRevision: eTag("revision-2"), - manifestETag: eTag("revision-2"), - baselineDigests: { "node-1": digest("changed") }, - baselineNodeETags: { - "folder-1": eTag("folder-1"), - [`folder-${createHash("sha256").update("Pages").digest("hex").slice(0, 12)}`]: eTag( - `folder-${createHash("sha256").update("Pages").digest("hex").slice(0, 12)}` - ), - "node-1": eTag("changed"), - }, - moveHints: {}, - }); - }); - - it("replaces the workspace on full pull when post-push refresh fails", 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")); - mockAxiosPost(PUSH_URL, {}); - mockAxiosGetError(ARCHIVE_URL, 503, { message: "unavailable" }); - const service = new WorkspaceService(testContext); - - await expect(service.push([], { full: true })).rejects.toThrow( - "Push succeeded, but local state refresh failed" - ); - expect( - JSON.parse(fs.readFileSync(path.join(process.cwd(), ".package", "local", "state.json"), "utf-8")) - ).toMatchObject({ refreshRequired: true }); - expect(() => service.status()).toThrow("Workspace synchronization state needs refresh"); - - mockWorkspaceDownload([{ nodeKey: "node-1", path: "Pages/Guide.md", content: "original" }], eTag("revision-2")); - await service.pull([], { full: true }); - expect(service.status()).toEqual([]); - expect(fs.existsSync(path.join(process.cwd(), "Guides", "Guide.md"))).toBe(false); - expect(fs.readFileSync(path.join(process.cwd(), "Pages", "Guide.md"), "utf-8")).toBe("original"); - }); - it("clears an applied move hint when an incremental push refresh is recovered by pull", async () => { writeWorkspace(); fs.mkdirSync(path.join(process.cwd(), "Pages")); @@ -1867,39 +1636,6 @@ describe("Workspace service", () => { ).toMatchObject({ moveHints: {} }); }); - it("preserves the metadata backup when refresh and rollback both fail", async () => { - writeWorkspace(); - fs.writeFileSync(path.join(process.cwd(), "Guides", "Guide.md"), "changed"); - mockAxiosPost(PUSH_URL, {}); - mockWorkspaceDownload([{ nodeKey: "node-1", path: "Guides/Guide.md", content: "changed" }], eTag("revision-2")); - const originalRename = fs.renameSync; - const rename = jest.spyOn(fs, "renameSync").mockImplementation((source, target) => { - if (target.toString() === path.join(process.cwd(), ".package")) { - throw new Error("rename failed"); - } - originalRename(source, target); - }); - let backup: string | undefined; - - try { - await new WorkspaceService(testContext).push([], { full: true }); - } catch (error) { - expect(error).toBeInstanceOf(Error); - const match = (error as Error).message.match( - /backup remains at (.+?)\. Run workspace pull before retrying\.$/ - ); - backup = match?.[1]; - } finally { - rename.mockRestore(); - } - - expect(backup).toBeDefined(); - expect(fs.existsSync(backup!)).toBe(true); - expect(backup!.startsWith(`${process.cwd()}${path.sep}`)).toBe(false); - originalRename(backup!, path.join(process.cwd(), ".package")); - fs.rmSync(path.dirname(backup!), { recursive: true, force: true }); - }); - it("does not push unresolved file identities", async () => { writeWorkspace(); fs.mkdirSync(path.join(process.cwd(), "Pages"));