From 002b9ff85a5ebcfe3cece7b0546fa790f3e85dd3 Mon Sep 17 00:00:00 2001 From: Laberion Ajvazi Date: Tue, 28 Jul 2026 14:23:51 +0200 Subject: [PATCH 1/8] SP-1447: Add config branch command group (beta) Includes-AI-Code: true --- docs/command-graph.html | 36 +++ docs/user-guide/branch-commands.md | 139 +++++++++++ docs/user-guide/index.md | 1 + mkdocs.yaml | 1 + .../branch/api/branch.api.ts | 75 ++++++ .../branch/branch.command.service.ts | 183 ++++++++++++++ .../branch/interfaces/branch.interfaces.ts | 157 ++++++++++++ .../configuration-management/module.ts | 92 +++++++ .../branch/branch-create.spec.ts | 55 ++++ .../branch/branch-delete.spec.ts | 21 ++ .../branch/branch-list.spec.ts | 54 ++++ .../branch/branch-merge.spec.ts | 234 ++++++++++++++++++ .../branch/branch-settings.spec.ts | 36 +++ 13 files changed, 1084 insertions(+) create mode 100644 docs/user-guide/branch-commands.md create mode 100644 src/commands/configuration-management/branch/api/branch.api.ts create mode 100644 src/commands/configuration-management/branch/branch.command.service.ts create mode 100644 src/commands/configuration-management/branch/interfaces/branch.interfaces.ts create mode 100644 tests/commands/configuration-management/branch/branch-create.spec.ts create mode 100644 tests/commands/configuration-management/branch/branch-delete.spec.ts create mode 100644 tests/commands/configuration-management/branch/branch-list.spec.ts create mode 100644 tests/commands/configuration-management/branch/branch-merge.spec.ts create mode 100644 tests/commands/configuration-management/branch/branch-settings.spec.ts diff --git a/docs/command-graph.html b/docs/command-graph.html index 2245c415..717b1035 100644 --- a/docs/command-graph.html +++ b/docs/command-graph.html @@ -314,6 +314,36 @@ description: "List packages in the target team. Lists staging packages by default.", options: ["-p, --profile ", "--json", "--flavors ", "-h, --help"] }, + // config branch + { id: "config_branch_area", label: "branch (beta)", group: "subarea", path: "config branch", + description: "Author, merge, and mirror package branches", options: ["-p, --profile ", "-h, --help"] }, + { id: "config_branch_create", label: "create (beta)", group: "command", path: "config branch create", + description: "Create a new branch from a source version", + options: ["-p, --profile ", "--packageKey (Main package key to branch from)", "--branchKey ", "--sourceVersion ", "--validate (default: false)", "--json (default: false)", "-h, --help"] }, + { id: "config_branch_list", label: "list (beta)", group: "command", path: "config branch list", + description: "List branches of a main package", + options: ["-p, --profile ", "--packageKey ", "--json (default: false)", "-h, --help"] }, + { id: "config_branch_delete", label: "delete (beta)", group: "command", path: "config branch delete", + description: "Delete a branch", + options: ["-p, --profile ", "--packageKey (Main package key, no '@')", "--branchKey ", "-h, --help"] }, + + // config branch settings + { id: "config_branch_settings_area", label: "settings (beta)", group: "subsubarea", path: "config branch settings", + description: "Commands to manage branch settings on a package", options: ["-p, --profile ", "-h, --help"] }, + { id: "config_branch_settings_set", label: "set (beta)", group: "command", path: "config branch settings set", + description: "Configure branch settings on a main package", + options: ["-p, --profile ", "--packageKey ", "--enabled (true|false)", "--json (default: false)", "-h, --help"] }, + + // config branch merge + { id: "config_branch_merge_area", label: "merge (beta)", group: "subsubarea", path: "config branch merge", + description: "Commands to preview and apply branch merges", options: ["-p, --profile ", "-h, --help"] }, + { id: "config_branch_merge_preview", label: "preview (beta)", group: "command", path: "config branch merge preview", + description: "Preview the changes a merge would apply to a target branch", + options: ["-p, --profile ", "--packageKey (Target package key: '' or '@')", "--sourceKey ", "--sourceVersion (version or \"LATEST\")", "--json (default: false)", "-h, --help"] }, + { id: "config_branch_merge_apply", label: "apply (beta)", group: "command", path: "config branch merge apply", + description: "Merge changes from a source into a target branch", + options: ["-p, --profile ", "--packageKey (Target package key: '' or '@')", "-f, --file (MergeBranchTransport payload; required only when the merge has conflicts)", "--sourceKey (overrides the file's 'sourceKey')", "--sourceVersion (overrides the file's 'sourceVersion'; or \"LATEST\")", "--bump (PATCH | MINOR | MAJOR)", "--version (pin an explicit semver)", "--summary ", "--json (default: false)", "-h, --help"] }, + // config metadata { id: "config_metadata_area", label: "metadata", group: "subarea", path: "config metadata", description: "Commands related to package metadata", options: ["-p, --profile ", "-h, --help"] }, @@ -471,6 +501,12 @@ ["area_config","config_package_area"], ["config_package_area","config_package_import"],["config_package_area","config_package_export"], ["config_package_area","config_package_validate"],["config_package_area","config_package_list"], + ["area_config","config_branch_area"], + ["config_branch_area","config_branch_create"],["config_branch_area","config_branch_list"], + ["config_branch_area","config_branch_delete"], + ["config_branch_area","config_branch_settings_area"],["config_branch_settings_area","config_branch_settings_set"], + ["config_branch_area","config_branch_merge_area"], + ["config_branch_merge_area","config_branch_merge_preview"],["config_branch_merge_area","config_branch_merge_apply"], ["area_config","config_metadata_area"],["config_metadata_area","config_metadata_export"], ["area_config","config_versions_area"],["config_versions_area","config_versions_get"], ["config_versions_area","config_versions_create"], diff --git a/docs/user-guide/branch-commands.md b/docs/user-guide/branch-commands.md new file mode 100644 index 00000000..da204565 --- /dev/null +++ b/docs/user-guide/branch-commands.md @@ -0,0 +1,139 @@ +# Branch Commands (beta) + +The `config branch` command group lets you author and merge branches from the CLI. + +## Concepts + +- **Main package** — a regular package, identified by its plain `` (no `@`). +- **Branch** — a separate package keyed `@`, created from a version of the source package. The source can be the main package or another branch. + +## Branching Settings + +Configure branching settings on a main package. Currently the only option is whether branching is enabled. + +```bash +content-cli config branch settings set --packageKey --enabled true +content-cli config branch settings set --packageKey --enabled false +``` + +The `--packageKey` must be the main package key, not a `@` value. + +## Create a Branch + +```bash +content-cli config branch create \ + --packageKey \ + --branchKey \ + --sourceVersion 1.4.0 +``` + +`--validate` runs the server-side validator without persisting and prints a success message instead of the created branch. + +## List Branches + +```bash +content-cli config branch list --packageKey +content-cli config branch list --packageKey --json +``` + +`--json` writes the raw `BranchTransport[]` payload to a file in the working directory. + +Each entry includes `sourcePackageKey` and `sourceVersion`, which together form the join key back to the version the branch was cut from. Multiple branches may share the same source. + +## Delete a Branch + +```bash +content-cli config branch delete \ + --packageKey \ + --branchKey +``` + +- `--packageKey` must be the main package key (no `@`). +- `--branchKey` is the branch to delete. + +## Preview and Apply Merges + +You can inspect the changes a merge would apply with `merge preview`, then apply them with `merge apply`. + +```bash +content-cli config branch merge preview \ + --packageKey \ + --sourceKey \ + --sourceVersion 1.4.0 \ + --json +``` + +`sourcePackageKey` is typically `@`. `` may be a version or `LATEST`. + +The JSON preview contains the conflict layout (`changes.configuration.conflicts`, `changes.metadata.conflicts`) and auto-merge results. + +### Quick merge + +When you do not need to override any node-level resolutions, the resolutions file is optional: + +```bash +content-cli config branch merge apply \ + --packageKey \ + --sourceKey \ + --sourceVersion 1.4.0 +``` + +The CLI posts the merge directly. If the server detects conflicts it returns an error — at that point run `config branch merge preview` to inspect them and re-run `apply` with `-f `. + +Customise the published version without writing a file: + +- `--bump PATCH|MINOR|MAJOR` — pick the bump option (default `PATCH`). +- `--version 1.5.0` — pin an explicit semver. +- `--summary ""` — summary of changes (default `"Merge @"`). + +### With a resolutions file + +Build a `MergeBranchTransport` resolution file from the preview (one entry per node where a custom resolution is needed), then apply: + +```bash +content-cli config branch merge apply \ + --packageKey \ + --file merge-request.json +``` + +- `--sourceKey` / `--sourceVersion` override the file's values when you want to keep one resolution file but target different sources. +- `--bump` / `--version` / `--summary` override the file's `versionCreate` block when set. + +A valid merge body looks like: + +```json +{ + "sourceKey": "my-pkg@feature-a", + "sourceVersion": "1.4.0", + "resolvedPackageConflict": null, + "resolvedNodeConflicts": [ + { "nodeKey": "node-1", "resolution": "ACCEPT_SOURCE" } + ], + "versionCreate": { + "versionBumpOption": "PATCH", + "summaryOfChanges": "Merge feature-a into main" + } +} +``` + +`versionCreate` is filled in this order of precedence (highest wins): + +1. `--version ` flag or `--bump PATCH|MINOR|MAJOR` flag (and `--summary`). +2. Whatever the resolutions file already has under `versionCreate`. +3. Default: `versionBumpOption: PATCH` + `summaryOfChanges: "Merge @"`. + +`resolution` per node accepts `ACCEPT_SOURCE`, `ACCEPT_TARGET`, or `CUSTOM`. `CUSTOM` requires the matching `customConfigurationChanges` / `customMetadataChanges` JSON-Patch ops. + +> **Custom changes are restricted to paths the preview already touched.** Each op in `customConfigurationChanges` / `customMetadataChanges` must reference a `path` that appears in either the preview's `sourceChanges` or `targetChanges` for that node — you cannot introduce edits at paths neither side modified. The server rejects ops at unrelated paths. + +Worked example: the preview reports that for node `node-1`, the source set `/title` to `"Source title"` and the target set `/title` to `"Target title"` (a conflict at `/title`). A valid CUSTOM resolution can pick a third value for `/title`, but it cannot also touch `/description` (which neither side changed): + +```json +{ + "nodeKey": "node-1", + "resolution": "CUSTOM", + "customConfigurationChanges": [ + { "op": "replace", "path": "/title", "value": "Negotiated title" } + ] +} +``` diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index 04805f70..48eb3267 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -6,6 +6,7 @@ Content CLI organizes its commands into groups by area. Each group covers a spec |---|-------------------------------------------------------------------------------| | [Studio Commands](./studio-commands.md) | Pull and push packages, assets, spaces, and widgets to and from Studio | | [Config Commands](./config-commands.md) | List, batch export, and import all packages and their configurations | +| [Branch Commands](./branch-commands.md) | Create, list, merge, and delete package branches | | [Deployment Commands](./deployment-commands.md) | Create deployments, list history, check active deployments, and manage targets | | [Asset Registry Commands](./asset-registry-commands.md) | Discover registered asset types and their service descriptors | | [Data Pool Commands](./data-pool-commands.md) | Export and import Data Pools with their dependencies | diff --git a/mkdocs.yaml b/mkdocs.yaml index 7b0bea30..4648ae2f 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -13,6 +13,7 @@ nav: - Overview: './user-guide/index.md' - Studio Commands: './user-guide/studio-commands.md' - Config Commands: './user-guide/config-commands.md' + - Branch Commands: './user-guide/branch-commands.md' - T2TC Commands: './user-guide/t2tc-commands.md' - Deployment Commands: './user-guide/deployment-commands.md' - Asset Registry Commands: './user-guide/asset-registry-commands.md' diff --git a/src/commands/configuration-management/branch/api/branch.api.ts b/src/commands/configuration-management/branch/api/branch.api.ts new file mode 100644 index 00000000..f3705192 --- /dev/null +++ b/src/commands/configuration-management/branch/api/branch.api.ts @@ -0,0 +1,75 @@ +import { URLSearchParams } from "url"; +import { HttpClient } from "../../../../core/http/http-client"; +import { Context } from "../../../../core/command/cli-context"; +import { FatalError } from "../../../../core/utils/logger"; +import { + BranchingSettingsTransport, + BranchTransport, + CreateBranchTransport, + MergeBranchTransport, + MergePreviewRequestTransport, + MergePreviewTransport, + PackageVersionCreatedTransport, +} from "../interfaces/branch.interfaces"; + +export class BranchApi { + private httpClient: () => HttpClient; + + constructor(context: Context) { + this.httpClient = () => context.httpClient; + } + + public async configureBranchingSettings(packageKey: string, transport: BranchingSettingsTransport): Promise { + return this.httpClient() + .put(`/pacman/api/core/packages/${packageKey}/branch-settings`, transport) + .catch(e => { + throw new FatalError(`Problem updating branch settings for package ${packageKey}: ${e}`); + }); + } + + public async createBranch(packageKey: string, transport: CreateBranchTransport, validate: boolean = false): Promise { + const params = new URLSearchParams(); + if (validate) { + params.set("validate", "true"); + } + const query = params.toString().length ? `?${params.toString()}` : ""; + + return this.httpClient() + .post(`/pacman/api/core/packages/${packageKey}/branches${query}`, transport) + .catch(e => { + throw new FatalError(`Problem creating branch '${transport.branchKey}' from package ${packageKey}: ${e}`); + }); + } + + public async listBranches(packageKey: string): Promise { + return this.httpClient() + .get(`/pacman/api/core/packages/${packageKey}/branches`) + .catch(e => { + throw new FatalError(`Problem listing branches for package ${packageKey}: ${e}`); + }); + } + + public async mergePreview(packageKey: string, transport: MergePreviewRequestTransport): Promise { + return this.httpClient() + .post(`/pacman/api/core/staging/packages/${packageKey}/merge/preview`, transport) + .catch(e => { + throw new FatalError(`Problem previewing merge into package ${packageKey} from ${transport.sourceKey}@${transport.sourceVersion}: ${e}`); + }); + } + + public async merge(packageKey: string, transport: MergeBranchTransport): Promise { + return this.httpClient() + .post(`/pacman/api/core/staging/packages/${packageKey}/merge`, transport) + .catch(e => { + throw new FatalError(`Problem merging into package ${packageKey} from ${transport.sourceKey}@${transport.sourceVersion}: ${e}`); + }); + } + + public async deleteBranch(branchPackageKey: string): Promise { + return this.httpClient() + .delete(`/pacman/api/core/staging/packages/${branchPackageKey}/purge`) + .catch(e => { + throw new FatalError(`Problem deleting branch ${branchPackageKey}: ${e}`); + }); + } +} diff --git a/src/commands/configuration-management/branch/branch.command.service.ts b/src/commands/configuration-management/branch/branch.command.service.ts new file mode 100644 index 00000000..98260b6e --- /dev/null +++ b/src/commands/configuration-management/branch/branch.command.service.ts @@ -0,0 +1,183 @@ +import { v4 as uuidv4 } from "uuid"; +import { Context } from "../../../core/command/cli-context"; +import { fileService, FileService } from "../../../core/utils/file-service"; +import { logger } from "../../../core/utils/logger"; +import { BranchApi } from "./api/branch.api"; +import { + BranchTransport, + BranchingSettingsTransport, + CreateBranchTransport, + MergeApplyOptions, + MergeBranchTransport, + MergePreviewRequestTransport, + MergePreviewTransport, + PackageVersionCreatedTransport, + SavePackageVersionTransport, + VersionBumpOption, +} from "./interfaces/branch.interfaces"; + +export class BranchCommandService { + private branchApi: BranchApi; + + constructor(context: Context) { + this.branchApi = new BranchApi(context); + } + + public async setBranchingEnabled(packageKey: string, enabled: boolean, jsonResponse: boolean): Promise { + const transport: BranchingSettingsTransport = { branchingEnabled: enabled }; + const result = await this.branchApi.configureBranchingSettings(packageKey, transport); + + if (jsonResponse) { + BranchCommandService.writeJson(result); + } else { + logger.info(`Branching ${result.branchingEnabled ? "enabled" : "disabled"} for package ${packageKey}.`); + } + return result; + } + + public async createBranch(packageKey: string, branchKey: string, sourceVersion: string, validate: boolean, jsonResponse: boolean): Promise { + const transport: CreateBranchTransport = { branchKey, version: sourceVersion }; + const result = await this.branchApi.createBranch(packageKey, transport, validate); + + if (validate) { + logger.info(`Validation successful for branch '${branchKey}' from ${packageKey}@${sourceVersion}.`); + return; + } + + if (jsonResponse) { + BranchCommandService.writeJson(result); + } else if (result) { + BranchCommandService.printBranch(result); + } + return result; + } + + public async listBranches(packageKey: string, jsonResponse: boolean): Promise { + const branches = await this.branchApi.listBranches(packageKey); + + if (jsonResponse) { + BranchCommandService.writeJson(branches); + } else if (branches.length === 0) { + logger.info(`No branches found for ${packageKey}.`); + } else { + branches.forEach(branch => { + logger.info( + `${branch.branchKey} (package: ${branch.packageKey}, source: ${branch.sourcePackageKey}@${branch.sourceVersion})` + ); + }); + } + return branches; + } + + public async deleteBranch(packageKey: string, branchKey: string): Promise { + const branchPackageKey = `${packageKey}@${branchKey}`; + await this.branchApi.deleteBranch(branchPackageKey); + logger.info(`Branch ${branchPackageKey} deleted.`); + } + + public async mergePreview(targetPackageKey: string, sourceKey: string, sourceVersion: string, jsonResponse: boolean): Promise { + const transport: MergePreviewRequestTransport = { sourceKey, sourceVersion }; + const preview = await this.branchApi.mergePreview(targetPackageKey, transport); + + if (jsonResponse) { + BranchCommandService.writeJson(preview); + } else { + BranchCommandService.printPreviewSummary(targetPackageKey, sourceKey, sourceVersion, preview); + } + return preview; + } + + public async mergeApply(targetPackageKey: string, options: MergeApplyOptions): Promise { + const rawBody: Partial = options.file + ? JSON.parse(fileService.readFile(options.file)) + : {}; + + const effectiveSourceKey = options.sourceKey ?? rawBody.sourceKey; + const effectiveSourceVersion = options.sourceVersion ?? rawBody.sourceVersion; + + const versionCreate = BranchCommandService.buildVersionCreate( + rawBody.versionCreate, + options.bump, + options.version, + options.summary, + effectiveSourceKey, + effectiveSourceVersion, + ); + + const transport: MergeBranchTransport = { + ...rawBody, + sourceKey: effectiveSourceKey, + sourceVersion: effectiveSourceVersion, + versionCreate, + }; + const result = await this.branchApi.merge(targetPackageKey, transport); + + if (options.jsonResponse) { + BranchCommandService.writeJson(result); + } else { + logger.info( + `Merge applied: published ${result.packageKey}@${result.version} from ${effectiveSourceKey}@${effectiveSourceVersion}.` + ); + } + return result; + } + + private static buildVersionCreate( + fromFile: SavePackageVersionTransport | undefined, + flagBump: string | undefined, + flagVersion: string | undefined, + flagSummary: string | undefined, + sourceKey: string | undefined, + sourceVersion: string | undefined, + ): SavePackageVersionTransport { + const versionCreate: SavePackageVersionTransport = fromFile ? { ...fromFile } : {}; + + if (flagVersion !== undefined) { + versionCreate.version = flagVersion; + versionCreate.versionBumpOption = undefined; + } else if (flagBump !== undefined) { + versionCreate.versionBumpOption = flagBump.toUpperCase() as SavePackageVersionTransport["versionBumpOption"]; + versionCreate.version = undefined; + } else if (!versionCreate.version && !versionCreate.versionBumpOption) { + versionCreate.versionBumpOption = VersionBumpOption.PATCH; + } + + if (flagSummary !== undefined) { + versionCreate.summaryOfChanges = flagSummary; + } else if (!versionCreate.summaryOfChanges && sourceKey && sourceVersion) { + versionCreate.summaryOfChanges = `Merge ${sourceKey}@${sourceVersion}`; + } + + return versionCreate; + } + + private static writeJson(payload: unknown): void { + const filename = `${uuidv4()}.json`; + fileService.writeToFileWithGivenName(JSON.stringify(payload, null, 2), filename); + logger.info(FileService.fileDownloadedMessage + filename); + } + + private static printBranch(branch: BranchTransport): void { + logger.info(`Main Package Key: ${branch.projectKey}`); + logger.info(`Branch Key: ${branch.branchKey}`); + logger.info(`Package Key: ${branch.packageKey}`); + logger.info(`Source Package Key: ${branch.sourcePackageKey}`); + logger.info(`Source Version: ${branch.sourceVersion}`); + } + + private static printPreviewSummary(targetPackageKey: string, sourceKey: string, sourceVersion: string, preview: MergePreviewTransport): void { + logger.info(`Preview: merge ${sourceKey}@${sourceVersion} into ${targetPackageKey}`); + logger.info(`Common ancestor: ${preview.commonPackageKey}@${preview.commonVersion}`); + const totalNodes = preview.nodeChanges?.length ?? 0; + const packageConflicts = + (preview.packageChanges?.changes?.configuration?.conflicts?.length ?? 0) + + (preview.packageChanges?.changes?.metadata?.conflicts?.length ?? 0); + const nodeConflicts = preview.nodeChanges?.reduce((sum, node) => { + const config = node.changes?.configuration?.conflicts?.length ?? 0; + const metadata = node.changes?.metadata?.conflicts?.length ?? 0; + return sum + config + metadata; + }, 0) ?? 0; + const conflicts = packageConflicts + nodeConflicts; + logger.info(`Nodes touched: ${totalNodes}, conflicting paths: ${conflicts}.`); + } +} diff --git a/src/commands/configuration-management/branch/interfaces/branch.interfaces.ts b/src/commands/configuration-management/branch/interfaces/branch.interfaces.ts new file mode 100644 index 00000000..2ad7bfd1 --- /dev/null +++ b/src/commands/configuration-management/branch/interfaces/branch.interfaces.ts @@ -0,0 +1,157 @@ +export enum MergeResolution { + CUSTOM = "CUSTOM", + ACCEPT_SOURCE = "ACCEPT_SOURCE", + ACCEPT_TARGET = "ACCEPT_TARGET", +} + +export enum MergeStatus { + UNKNOWN = "UNKNOWN", + UNCHANGED = "UNCHANGED", + SOURCE_ONLY = "SOURCE_ONLY", + TARGET_ONLY = "TARGET_ONLY", + AUTO_MERGE = "AUTO_MERGE", + CONFLICT = "CONFLICT", +} + +export enum ChangeType { + UNKNOWN = "UNKNOWN", + ADDED = "ADDED", + DELETED = "DELETED", + CHANGED = "CHANGED", + UNCHANGED = "UNCHANGED", +} + +export enum VersionBumpOption { + NONE = "NONE", + PATCH = "PATCH", + MINOR = "MINOR", + MAJOR = "MAJOR", +} + +export interface BranchingSettingsTransport { + branchingEnabled: boolean; +} + +export interface CreateBranchTransport { + branchKey: string; + version: string; +} + +export interface BranchTransport { + projectKey: string; + branchKey: string; + sourcePackageKey: string; + sourceVersion: string; + packageKey: string; + packageId?: string; +} + +export interface ConfigurationChangeTransport { + op: string; + path: string; + from?: string; + value?: unknown; + fromValue?: unknown; +} + +export interface MergePackageResolutionTransport { + resolution: MergeResolution; + customConfigurationChanges?: ConfigurationChangeTransport[]; + customMetadataChanges?: ConfigurationChangeTransport[]; +} + +export interface MergeNodeResolutionTransport { + nodeKey: string; + resolution: MergeResolution; + customConfigurationChanges?: ConfigurationChangeTransport[]; + customMetadataChanges?: ConfigurationChangeTransport[]; +} + +export interface NodeFilterTransport { + nodeKeys?: string[]; +} + +export interface SavePackageVersionTransport { + version?: string | null; + versionBumpOption?: VersionBumpOption; + summaryOfChanges?: string; + nodeFilter?: NodeFilterTransport | null; +} + +export interface MergePreviewRequestTransport { + sourceKey: string; + sourceVersion: string; +} + +export interface MergeBranchTransport { + sourceKey: string; + sourceVersion: string; + resolvedPackageConflict?: MergePackageResolutionTransport | null; + resolvedNodeConflicts?: MergeNodeResolutionTransport[]; + versionCreate: SavePackageVersionTransport; +} + +export interface MergePathConflictTransport { + path: string; + sourceChange: ConfigurationChangeTransport; + targetChange: ConfigurationChangeTransport; +} + +export interface MergeDiffDetailTransport { + status: MergeStatus; + sourceChanges: ConfigurationChangeTransport[]; + targetChanges: ConfigurationChangeTransport[]; + conflicts: MergePathConflictTransport[]; + autoMergedChanges: ConfigurationChangeTransport[]; +} + +export interface MergeDiffTransport { + configuration: MergeDiffDetailTransport; + metadata: MergeDiffDetailTransport; +} + +export interface MergePreviewPackageTransport { + sourceChange: ChangeType; + targetChange: ChangeType; + changeDate: string; + updatedBy: string; + changes: MergeDiffTransport; +} + +export interface MergePreviewNodeTransport { + nodeKey: string; + name: string; + type: string; + parentNodeKey?: string; + invalidContent: boolean; + sourceChange: ChangeType; + targetChange: ChangeType; + changeDate: string; + updatedBy: string; + changes: MergeDiffTransport; +} + +export interface MergePreviewTransport { + commonPackageKey: string; + commonVersion: string; + packageChanges: MergePreviewPackageTransport; + nodeChanges: MergePreviewNodeTransport[]; +} + +export interface PackageVersionCreatedTransport { + packageKey: string; + version: string; + summaryOfChanges?: string; + creationDate: string; + createdBy: string; +} + +export interface MergeApplyOptions { + sourceKey?: string; + sourceVersion?: string; + file?: string; + bump?: string; + version?: string; + summary?: string; + jsonResponse?: boolean; +} diff --git a/src/commands/configuration-management/module.ts b/src/commands/configuration-management/module.ts index a1aee91d..65375123 100644 --- a/src/commands/configuration-management/module.ts +++ b/src/commands/configuration-management/module.ts @@ -17,12 +17,71 @@ import { PackageVersionCommandService } from "./package-version-command.service" import { PackageValidationService } from "./package-validation.service"; import { SinglePackageImportService } from "./single-package-import.service"; import { SinglePackageExportService } from "./single-package-export.service"; +import { BranchCommandService } from "./branch/branch.command.service"; class Module extends IModule { public register(context: Context, configurator: Configurator): void { const configCommand = configurator.command("config") .description("Manage package configurations and their resources (package, nodes, versions, variables, metadata). Note: 'config list/export/import/diff' are deprecated — bulk Team-to-Team Copy operations have moved to the 't2tc package' group, and single-package operations live under 'config package'."); + + const branchCommand = configCommand.command("branch").beta() + .description("Author, merge, and mirror package branches"); + + const branchSettingsCommand = branchCommand.command("settings").beta() + .description("Commands to manage branch settings on a package"); + + branchSettingsCommand.command("set").beta() + .description("Configure branch settings on a main package") + .requiredOption("--packageKey ", "Main package key") + .requiredOption("--enabled ", "true|false") + .option("--json", "Write response to a JSON file", false) + .action(this.setBranchSettings); + + branchCommand.command("create").beta() + .description("Create a new branch from a source version") + .requiredOption("--packageKey ", "Main package key to branch from") + .requiredOption("--branchKey ", "New branch key") + .requiredOption("--sourceVersion ", "Source version to branch from") + .option("--validate", "Only validate the request without creating the branch", false) + .option("--json", "Write response to a JSON file", false) + .action(this.createBranch); + + branchCommand.command("list").beta() + .description("List branches of a main package") + .requiredOption("--packageKey ", "Main package key") + .option("--json", "Write response to a JSON file", false) + .action(this.listBranches); + + branchCommand.command("delete").beta() + .description("Delete a branch") + .requiredOption("--packageKey ", "Main package key (no '@')") + .requiredOption("--branchKey ", "Branch key to delete") + .action(this.deleteBranch); + + const branchMergeCommand = branchCommand.command("merge").beta() + .description("Commands to preview and apply branch merges"); + + branchMergeCommand.command("preview").beta() + .description("Preview the changes a merge would apply to a target branch") + .requiredOption("--packageKey ", "Target package key (main package key or '@')") + .requiredOption("--sourceKey ", "Source package key (main package key or '@')") + .requiredOption("--sourceVersion ", "Source version (or 'LATEST')") + .option("--json", "Write response to a JSON file", false) + .action(this.previewBranchMerge); + + branchMergeCommand.command("apply").beta() + .description("Merge changes from a source into a target branch") + .requiredOption("--packageKey ", "Target package key (main package key or '@')") + .option("-f, --file ", "Path to a JSON file containing the MergeBranchTransport payload") + .option("--sourceKey ", "Source package key, main package key or '@' (overrides the file's 'sourceKey')") + .option("--sourceVersion ", "Source version (overrides the file's 'sourceVersion'; or 'LATEST')") + .option("--bump ", "Version bump for the new published version: PATCH | MINOR | MAJOR") + .option("--version ", "Pin the new published version to an explicit semver") + .option("--summary ", "Summary of changes for the new published version") + .option("--json", "Write response to a JSON file", false) + .action(this.applyBranchMerge); + configCommand.command("list") .description("[Deprecated] Use 't2tc package list' instead. List packages in the target team.") .deprecationNotice("'config list' is deprecated and will be removed in a future release. Use 't2tc package list' instead.") @@ -239,6 +298,39 @@ class Module extends IModule { .action(this.listAssignments); } + private async setBranchSettings(context: Context, command: Command, options: OptionValues): Promise { + const enabled = options.enabled === "true"; + await new BranchCommandService(context).setBranchingEnabled(options.packageKey, enabled, !!options.json); + } + + private async createBranch(context: Context, command: Command, options: OptionValues): Promise { + await new BranchCommandService(context).createBranch(options.packageKey, options.branchKey, options.sourceVersion, !!options.validate, !!options.json); + } + + private async listBranches(context: Context, command: Command, options: OptionValues): Promise { + await new BranchCommandService(context).listBranches(options.packageKey, !!options.json); + } + + private async deleteBranch(context: Context, command: Command, options: OptionValues): Promise { + await new BranchCommandService(context).deleteBranch(options.packageKey, options.branchKey); + } + + private async previewBranchMerge(context: Context, command: Command, options: OptionValues): Promise { + await new BranchCommandService(context).mergePreview(options.packageKey, options.sourceKey, options.sourceVersion, !!options.json); + } + + private async applyBranchMerge(context: Context, command: Command, options: OptionValues): Promise { + await new BranchCommandService(context).mergeApply(options.packageKey, { + sourceKey: options.sourceKey, + sourceVersion: options.sourceVersion, + file: options.file, + bump: options.bump, + version: options.version, + summary: options.summary, + jsonResponse: !!options.json, + }); + } + private async listPackages(context: Context, command: Command, options: OptionValues): Promise { if (options.staging && (options.withDependencies || options.packageKeys || options.keysByVersion || options.variableValue || options.variableType)) { throw new Error("Staging parameter is not compatible with --withDependencies, --packageKeys, --keysByVersion, --variableValue, --variableType"); diff --git a/tests/commands/configuration-management/branch/branch-create.spec.ts b/tests/commands/configuration-management/branch/branch-create.spec.ts new file mode 100644 index 00000000..06ae72ff --- /dev/null +++ b/tests/commands/configuration-management/branch/branch-create.spec.ts @@ -0,0 +1,55 @@ +import { mockAxiosPost, mockedPostRequestBodyByUrl } from "../../../utls/http-requests-mock"; +import { BranchCommandService } from "../../../../src/commands/configuration-management/branch/branch.command.service"; +import { testContext } from "../../../utls/test-context"; +import { loggingTestTransport } from "../../../jest.setup"; +import { getJsonFromDownloadedFile } from "../../../utls/fs-utils"; +import { FileService } from "../../../../src/core/utils/file-service"; +import { BranchTransport, CreateBranchTransport } from "../../../../src/commands/configuration-management/branch/interfaces/branch.interfaces"; + +describe("branch create", () => { + const packageKey = "my-package"; + const apiUrl = `https://myTeam.celonis.cloud/pacman/api/core/packages/${packageKey}/branches`; + const validateUrl = `${apiUrl}?validate=true`; + + const created: BranchTransport = { + projectKey: packageKey, + branchKey: "feature-a", + sourcePackageKey: packageKey, + sourceVersion: "1.4.0", + packageKey: `${packageKey}@feature-a`, + }; + + it("creates a branch and prints summary", async () => { + mockAxiosPost(apiUrl, created); + + await new BranchCommandService(testContext).createBranch(packageKey, "feature-a", "1.4.0", false, false); + + const requestBody: CreateBranchTransport = JSON.parse(mockedPostRequestBodyByUrl.get(apiUrl) as string); + expect(requestBody).toEqual({ branchKey: "feature-a", version: "1.4.0" }); + expect(loggingTestTransport.logMessages.some((m) => m.message.includes("Branch Key: feature-a"))).toBe(true); + }); + + it("hits the validate endpoint when --validate is set", async () => { + mockAxiosPost(validateUrl, undefined); + + await new BranchCommandService(testContext).createBranch(packageKey, "feature-a", "1.4.0", true, false); + + expect(mockedPostRequestBodyByUrl.get(validateUrl)).toBeDefined(); + expect( + loggingTestTransport.logMessages.some((m) => m.message.includes(FileService.fileDownloadedMessage)) + ).toBe(false); + expect( + loggingTestTransport.logMessages.some((m) => + m.message.includes(`Validation successful for branch 'feature-a' from ${packageKey}@1.4.0.`) + ) + ).toBe(true); + }); + + it("writes JSON file when jsonResponse=true", async () => { + mockAxiosPost(apiUrl, created); + + await new BranchCommandService(testContext).createBranch(packageKey, "feature-a", "1.4.0", false, true); + + expect(getJsonFromDownloadedFile()).toEqual(created); + }); +}); diff --git a/tests/commands/configuration-management/branch/branch-delete.spec.ts b/tests/commands/configuration-management/branch/branch-delete.spec.ts new file mode 100644 index 00000000..0d4a732d --- /dev/null +++ b/tests/commands/configuration-management/branch/branch-delete.spec.ts @@ -0,0 +1,21 @@ +import { mockAxiosDelete, mockedAxiosInstance } from "../../../utls/http-requests-mock"; +import { BranchCommandService } from "../../../../src/commands/configuration-management/branch/branch.command.service"; +import { testContext } from "../../../utls/test-context"; +import { loggingTestTransport } from "../../../jest.setup"; + +describe("branch delete", () => { + const packageKey = "my-package"; + const branchKey = "feature-a"; + const branchPackageKey = `${packageKey}@${branchKey}`; + const purgeUrl = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${branchPackageKey}/purge`; + + it("DELETEs the branch's package and logs a success message", async () => { + mockAxiosDelete(purgeUrl); + + await new BranchCommandService(testContext).deleteBranch(packageKey, branchKey); + + expect(mockedAxiosInstance.delete).toHaveBeenCalledTimes(1); + expect((mockedAxiosInstance.delete as jest.Mock).mock.calls[0][0]).toBe(purgeUrl); + expect(loggingTestTransport.logMessages.some((m) => m.message.includes(`Branch ${branchPackageKey} deleted.`))).toBe(true); + }); +}); diff --git a/tests/commands/configuration-management/branch/branch-list.spec.ts b/tests/commands/configuration-management/branch/branch-list.spec.ts new file mode 100644 index 00000000..8786c973 --- /dev/null +++ b/tests/commands/configuration-management/branch/branch-list.spec.ts @@ -0,0 +1,54 @@ +import { mockAxiosGet } from "../../../utls/http-requests-mock"; +import { BranchCommandService } from "../../../../src/commands/configuration-management/branch/branch.command.service"; +import { testContext } from "../../../utls/test-context"; +import { loggingTestTransport } from "../../../jest.setup"; +import { getJsonFromDownloadedFile } from "../../../utls/fs-utils"; +import { BranchTransport } from "../../../../src/commands/configuration-management/branch/interfaces/branch.interfaces"; + +describe("branch list", () => { + const packageKey = "my-package"; + const apiUrl = `https://myTeam.celonis.cloud/pacman/api/core/packages/${packageKey}/branches`; + + const branches: BranchTransport[] = [ + { + projectKey: packageKey, + branchKey: "feature-a", + sourcePackageKey: packageKey, + sourceVersion: "1.4.0", + packageKey: `${packageKey}@feature-a`, + }, + { + projectKey: packageKey, + branchKey: "release", + sourcePackageKey: packageKey, + sourceVersion: "1.5.0", + packageKey: `${packageKey}@release`, + }, + ]; + + it("lists branches and prints one line per branch", async () => { + mockAxiosGet(apiUrl, branches); + + await new BranchCommandService(testContext).listBranches(packageKey, false); + + const messages = loggingTestTransport.logMessages.map((m) => m.message); + expect(messages.some((m) => m.includes(`feature-a (package: ${packageKey}@feature-a`))).toBe(true); + expect(messages.some((m) => m.includes(`release (package: ${packageKey}@release`))).toBe(true); + }); + + it("logs a friendly message when there are no branches", async () => { + mockAxiosGet(apiUrl, []); + + await new BranchCommandService(testContext).listBranches(packageKey, false); + + expect(loggingTestTransport.logMessages[0].message).toContain(`No branches found for ${packageKey}.`); + }); + + it("writes the raw transport list to a JSON file when jsonResponse=true", async () => { + mockAxiosGet(apiUrl, branches); + + await new BranchCommandService(testContext).listBranches(packageKey, true); + + expect(getJsonFromDownloadedFile()).toEqual(branches); + }); +}); diff --git a/tests/commands/configuration-management/branch/branch-merge.spec.ts b/tests/commands/configuration-management/branch/branch-merge.spec.ts new file mode 100644 index 00000000..bd2616b9 --- /dev/null +++ b/tests/commands/configuration-management/branch/branch-merge.spec.ts @@ -0,0 +1,234 @@ +import { mockAxiosPost, mockedPostRequestBodyByUrl } from "../../../utls/http-requests-mock"; +import { BranchCommandService } from "../../../../src/commands/configuration-management/branch/branch.command.service"; +import { testContext } from "../../../utls/test-context"; +import { loggingTestTransport } from "../../../jest.setup"; +import { getJsonFromDownloadedFile, writeJsonTempFile } from "../../../utls/fs-utils"; +import { + ChangeType, + MergeBranchTransport, + MergePreviewTransport, + MergeResolution, + MergeStatus, + PackageVersionCreatedTransport, + VersionBumpOption, +} from "../../../../src/commands/configuration-management/branch/interfaces/branch.interfaces"; + +describe("branch merge preview", () => { + const targetPackageKey = "my-package"; + const previewUrl = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${targetPackageKey}/merge/preview`; + + const preview: MergePreviewTransport = { + commonPackageKey: targetPackageKey, + commonVersion: "1.2.0", + packageChanges: { + sourceChange: ChangeType.UNCHANGED, + targetChange: ChangeType.UNCHANGED, + changeDate: new Date().toISOString(), + updatedBy: "user", + changes: { + configuration: { + status: MergeStatus.UNCHANGED, + sourceChanges: [], + targetChanges: [], + conflicts: [], + autoMergedChanges: [], + }, + metadata: { + status: MergeStatus.UNCHANGED, + sourceChanges: [], + targetChanges: [], + conflicts: [], + autoMergedChanges: [], + }, + }, + }, + nodeChanges: [ + { + nodeKey: "node-1", + name: "Node 1", + type: "VIEW", + invalidContent: false, + sourceChange: ChangeType.CHANGED, + targetChange: ChangeType.UNCHANGED, + changeDate: new Date().toISOString(), + updatedBy: "user", + changes: { + configuration: { + status: MergeStatus.CONFLICT, + sourceChanges: [], + targetChanges: [], + conflicts: [ + { path: "/title", sourceChange: { op: "replace", path: "/title", value: "A" }, targetChange: { op: "replace", path: "/title", value: "B" } }, + ], + autoMergedChanges: [], + }, + metadata: { + status: MergeStatus.UNCHANGED, + sourceChanges: [], + targetChanges: [], + conflicts: [], + autoMergedChanges: [], + }, + }, + }, + ], + }; + + it("posts the source key/version and prints a summary with conflict count", async () => { + mockAxiosPost(previewUrl, preview); + + await new BranchCommandService(testContext).mergePreview( + targetPackageKey, + `${targetPackageKey}@feature-a`, + "1.4.0", + false, + ); + + expect(JSON.parse(mockedPostRequestBodyByUrl.get(previewUrl) as string)).toEqual({ + sourceKey: `${targetPackageKey}@feature-a`, + sourceVersion: "1.4.0", + }); + const messages = loggingTestTransport.logMessages.map((m) => m.message); + expect(messages.some((m) => m.includes("conflicting paths: 1"))).toBe(true); + }); + + it("writes raw transport to JSON when jsonResponse=true", async () => { + mockAxiosPost(previewUrl, preview); + + await new BranchCommandService(testContext).mergePreview(targetPackageKey, "src", "1.4.0", true); + + expect(getJsonFromDownloadedFile()).toEqual(preview); + }); +}); + +describe("branch merge apply", () => { + const targetPackageKey = "my-package"; + const mergeUrl = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${targetPackageKey}/merge`; + const previewUrl = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${targetPackageKey}/merge/preview`; + + const created: PackageVersionCreatedTransport = { + packageKey: targetPackageKey, + version: "1.5.0", + summaryOfChanges: "merge from feature-a", + creationDate: new Date().toISOString(), + createdBy: "user", + }; + + const baseBody: MergeBranchTransport = { + sourceKey: `${targetPackageKey}@feature-a`, + sourceVersion: "1.4.0", + resolvedNodeConflicts: [ + { nodeKey: "node-1", resolution: MergeResolution.ACCEPT_SOURCE }, + ], + versionCreate: { versionBumpOption: VersionBumpOption.MAJOR, summaryOfChanges: "merge from feature-a" }, + }; + + beforeEach(() => { + writeJsonTempFile("merge-request.json", baseBody); + }); + + it("merges using values from the body when flags are omitted", async () => { + mockAxiosPost(mergeUrl, created); + + await new BranchCommandService(testContext).mergeApply(targetPackageKey, { + file: "merge-request.json", + }); + + const sent: MergeBranchTransport = JSON.parse(mockedPostRequestBodyByUrl.get(mergeUrl) as string); + expect(sent.sourceKey).toBe(baseBody.sourceKey); + expect(sent.sourceVersion).toBe(baseBody.sourceVersion); + expect(sent.resolvedNodeConflicts).toEqual(baseBody.resolvedNodeConflicts); + expect(sent.versionCreate).toEqual(baseBody.versionCreate); + expect(loggingTestTransport.logMessages.some((m) => m.message.includes(`Merge applied: published ${targetPackageKey}@1.5.0`))).toBe(true); + }); + + it("overrides source from flags when provided", async () => { + mockAxiosPost(mergeUrl, created); + + await new BranchCommandService(testContext).mergeApply(targetPackageKey, { + sourceKey: "override-key", + sourceVersion: "LATEST", + file: "merge-request.json", + }); + + const sent: MergeBranchTransport = JSON.parse(mockedPostRequestBodyByUrl.get(mergeUrl) as string); + expect(sent.sourceKey).toBe("override-key"); + expect(sent.sourceVersion).toBe("LATEST"); + }); + + it("flag --bump overrides versionCreate.versionBumpOption from the file", async () => { + mockAxiosPost(mergeUrl, created); + + await new BranchCommandService(testContext).mergeApply(targetPackageKey, { + file: "merge-request.json", + bump: "minor", + }); + + const sent: MergeBranchTransport = JSON.parse(mockedPostRequestBodyByUrl.get(mergeUrl) as string); + expect(sent.versionCreate.versionBumpOption).toBe(VersionBumpOption.MINOR); + expect(sent.versionCreate.version).toBeUndefined(); + }); + + it("flag --version overrides versionCreate.version and clears bump", async () => { + mockAxiosPost(mergeUrl, created); + + await new BranchCommandService(testContext).mergeApply(targetPackageKey, { + file: "merge-request.json", + version: "2.0.0", + }); + + const sent: MergeBranchTransport = JSON.parse(mockedPostRequestBodyByUrl.get(mergeUrl) as string); + expect(sent.versionCreate.version).toBe("2.0.0"); + expect(sent.versionCreate.versionBumpOption).toBeUndefined(); + }); + + it("writes raw transport to JSON when jsonResponse=true", async () => { + mockAxiosPost(mergeUrl, created); + + await new BranchCommandService(testContext).mergeApply(targetPackageKey, { + file: "merge-request.json", + jsonResponse: true, + }); + + expect(getJsonFromDownloadedFile()).toEqual(created); + }); + + describe("without a resolutions file", () => { + it("posts to the merge endpoint directly with default versionCreate", async () => { + mockAxiosPost(mergeUrl, created); + + await new BranchCommandService(testContext).mergeApply(targetPackageKey, { + sourceKey: `${targetPackageKey}@feature-a`, + sourceVersion: "1.4.0", + }); + + expect(mockedPostRequestBodyByUrl.get(previewUrl)).toBeUndefined(); + const sent: MergeBranchTransport = JSON.parse(mockedPostRequestBodyByUrl.get(mergeUrl) as string); + expect(sent.sourceKey).toBe(`${targetPackageKey}@feature-a`); + expect(sent.sourceVersion).toBe("1.4.0"); + expect(sent.resolvedNodeConflicts).toBeUndefined(); + expect(sent.versionCreate).toEqual({ + versionBumpOption: VersionBumpOption.PATCH, + summaryOfChanges: `Merge ${targetPackageKey}@feature-a@1.4.0`, + }); + }); + + it("honours flags for bump and summary", async () => { + mockAxiosPost(mergeUrl, created); + + await new BranchCommandService(testContext).mergeApply(targetPackageKey, { + sourceKey: `${targetPackageKey}@feature-a`, + sourceVersion: "1.4.0", + bump: "MAJOR", + summary: "ship it", + }); + + const sent: MergeBranchTransport = JSON.parse(mockedPostRequestBodyByUrl.get(mergeUrl) as string); + expect(sent.versionCreate).toEqual({ + versionBumpOption: VersionBumpOption.MAJOR, + summaryOfChanges: "ship it", + }); + }); + }); + +}); diff --git a/tests/commands/configuration-management/branch/branch-settings.spec.ts b/tests/commands/configuration-management/branch/branch-settings.spec.ts new file mode 100644 index 00000000..30987013 --- /dev/null +++ b/tests/commands/configuration-management/branch/branch-settings.spec.ts @@ -0,0 +1,36 @@ +import { mockAxiosPut, mockedPostRequestBodyByUrl } from "../../../utls/http-requests-mock"; +import { BranchCommandService } from "../../../../src/commands/configuration-management/branch/branch.command.service"; +import { testContext } from "../../../utls/test-context"; +import { loggingTestTransport } from "../../../jest.setup"; +import { getJsonFromDownloadedFile } from "../../../utls/fs-utils"; + +describe("branch settings set", () => { + const packageKey = "my-package"; + const apiUrl = `https://myTeam.celonis.cloud/pacman/api/core/packages/${packageKey}/branch-settings`; + + it("enables branching and logs status", async () => { + mockAxiosPut(apiUrl, { branchingEnabled: true }); + + await new BranchCommandService(testContext).setBranchingEnabled(packageKey, true, false); + + expect(JSON.parse(mockedPostRequestBodyByUrl.get(apiUrl))).toEqual({ branchingEnabled: true }); + expect(loggingTestTransport.logMessages[0].message).toContain(`Branching enabled for package ${packageKey}.`); + }); + + it("disables branching", async () => { + mockAxiosPut(apiUrl, { branchingEnabled: false }); + + await new BranchCommandService(testContext).setBranchingEnabled(packageKey, false, false); + + expect(JSON.parse(mockedPostRequestBodyByUrl.get(apiUrl))).toEqual({ branchingEnabled: false }); + expect(loggingTestTransport.logMessages[0].message).toContain(`Branching disabled for package ${packageKey}.`); + }); + + it("writes raw transport to JSON when jsonResponse=true", async () => { + mockAxiosPut(apiUrl, { branchingEnabled: true }); + + await new BranchCommandService(testContext).setBranchingEnabled(packageKey, true, true); + + expect(getJsonFromDownloadedFile()).toEqual({ branchingEnabled: true }); + }); +}); From 8f762a921c8682872c2820da27fc09ed524723a8 Mon Sep 17 00:00:00 2001 From: Laberion Ajvazi Date: Tue, 28 Jul 2026 14:31:04 +0200 Subject: [PATCH 2/8] SP-1447: Validate --enabled and apply static-analysis fixes Includes-AI-Code: true --- .../branch/api/branch.api.ts | 3 +- .../branch/branch.command.service.ts | 2 +- .../configuration-management/module.ts | 7 +++- .../commands/configuration-management.spec.ts | 38 +++++++++++++++++++ 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/commands/configuration-management/branch/api/branch.api.ts b/src/commands/configuration-management/branch/api/branch.api.ts index f3705192..6a9f927b 100644 --- a/src/commands/configuration-management/branch/api/branch.api.ts +++ b/src/commands/configuration-management/branch/api/branch.api.ts @@ -1,4 +1,3 @@ -import { URLSearchParams } from "url"; import { HttpClient } from "../../../../core/http/http-client"; import { Context } from "../../../../core/command/cli-context"; import { FatalError } from "../../../../core/utils/logger"; @@ -13,7 +12,7 @@ import { } from "../interfaces/branch.interfaces"; export class BranchApi { - private httpClient: () => HttpClient; + private readonly httpClient: () => HttpClient; constructor(context: Context) { this.httpClient = () => context.httpClient; diff --git a/src/commands/configuration-management/branch/branch.command.service.ts b/src/commands/configuration-management/branch/branch.command.service.ts index 98260b6e..f0a87a8b 100644 --- a/src/commands/configuration-management/branch/branch.command.service.ts +++ b/src/commands/configuration-management/branch/branch.command.service.ts @@ -17,7 +17,7 @@ import { } from "./interfaces/branch.interfaces"; export class BranchCommandService { - private branchApi: BranchApi; + private readonly branchApi: BranchApi; constructor(context: Context) { this.branchApi = new BranchApi(context); diff --git a/src/commands/configuration-management/module.ts b/src/commands/configuration-management/module.ts index 65375123..97ab7463 100644 --- a/src/commands/configuration-management/module.ts +++ b/src/commands/configuration-management/module.ts @@ -299,8 +299,11 @@ class Module extends IModule { } private async setBranchSettings(context: Context, command: Command, options: OptionValues): Promise { - const enabled = options.enabled === "true"; - await new BranchCommandService(context).setBranchingEnabled(options.packageKey, enabled, !!options.json); + const enabled = String(options.enabled).toLowerCase(); + if (enabled !== "true" && enabled !== "false") { + throw new Error("Please provide either 'true' or 'false' for --enabled."); + } + await new BranchCommandService(context).setBranchingEnabled(options.packageKey, enabled === "true", !!options.json); } private async createBranch(context: Context, command: Command, options: OptionValues): Promise { diff --git a/tests/integration/commands/configuration-management.spec.ts b/tests/integration/commands/configuration-management.spec.ts index 7864e492..987f1ff5 100644 --- a/tests/integration/commands/configuration-management.spec.ts +++ b/tests/integration/commands/configuration-management.spec.ts @@ -8,6 +8,7 @@ import { PackageVersionCommandService } from "../../../src/commands/configuratio import { NodeDiffService } from "../../../src/commands/configuration-management/node-diff.service"; import { SinglePackageImportService } from "../../../src/commands/configuration-management/single-package-import.service"; import { SinglePackageExportService } from "../../../src/commands/configuration-management/single-package-export.service"; +import { BranchCommandService } from "../../../src/commands/configuration-management/branch/branch.command.service"; import { CliRunResult, runCli as runCliProcess } from "../../utls/cli-runner"; jest.mock("../../../src/commands/configuration-management/config-command.service"); @@ -19,6 +20,7 @@ jest.mock("../../../src/commands/configuration-management/node-diff.service"); jest.mock("../../../src/commands/configuration-management/package-version-command.service"); jest.mock("../../../src/commands/configuration-management/single-package-import.service"); jest.mock("../../../src/commands/configuration-management/single-package-export.service"); +jest.mock("../../../src/commands/configuration-management/branch/branch.command.service"); describe("configuration-management command integration", () => { let mockConfigCommandService: jest.Mocked; @@ -30,6 +32,7 @@ describe("configuration-management command integration", () => { let mockPackageVersionCommandService: jest.Mocked; let mockSinglePackageImportService: jest.Mocked; let mockSinglePackageExportService: jest.Mocked; + let mockBranchCommandService: jest.Mocked; beforeEach(() => { mockConfigCommandService = { @@ -73,6 +76,10 @@ describe("configuration-management command integration", () => { exportPackage: jest.fn().mockResolvedValue(undefined), } as any; + mockBranchCommandService = { + setBranchingEnabled: jest.fn().mockResolvedValue(undefined), + } as any; + (ConfigCommandService as jest.MockedClass).mockImplementation(() => mockConfigCommandService); (StagingPackageService as jest.MockedClass).mockImplementation(() => mockStagingPackageService); (MetadataService as jest.MockedClass).mockImplementation(() => mockMetadataService); @@ -82,6 +89,7 @@ describe("configuration-management command integration", () => { (PackageVersionCommandService as jest.MockedClass).mockImplementation(() => mockPackageVersionCommandService); (SinglePackageImportService as jest.MockedClass).mockImplementation(() => mockSinglePackageImportService); (SinglePackageExportService as jest.MockedClass).mockImplementation(() => mockSinglePackageExportService); + (BranchCommandService as jest.MockedClass).mockImplementation(() => mockBranchCommandService); }); let lastResult: CliRunResult; @@ -268,6 +276,36 @@ describe("configuration-management command integration", () => { }); }); + describe("config branch settings set (setBranchingEnabled)", () => { + it("forwards enabled=true", async () => { + const result = await runCli(["config", "branch", "settings", "set", "--packageKey", "myPackage", "--enabled", "true"]); + + expect(result.exitCode).toBe(0); + expect(mockBranchCommandService.setBranchingEnabled).toHaveBeenCalledWith("myPackage", true, false); + }); + + it("forwards enabled=false", async () => { + const result = await runCli(["config", "branch", "settings", "set", "--packageKey", "myPackage", "--enabled", "false"]); + + expect(result.exitCode).toBe(0); + expect(mockBranchCommandService.setBranchingEnabled).toHaveBeenCalledWith("myPackage", false, false); + }); + + it("accepts --enabled regardless of casing", async () => { + const result = await runCli(["config", "branch", "settings", "set", "--packageKey", "myPackage", "--enabled", "TRUE"]); + + expect(result.exitCode).toBe(0); + expect(mockBranchCommandService.setBranchingEnabled).toHaveBeenCalledWith("myPackage", true, false); + }); + + it("rejects a value that is neither 'true' nor 'false' instead of disabling branching", async () => { + await runCli(["config", "branch", "settings", "set", "--packageKey", "myPackage", "--enabled", "yes"]); + + expectError("Please provide either 'true' or 'false' for --enabled."); + expect(mockBranchCommandService.setBranchingEnabled).not.toHaveBeenCalled(); + }); + }); + describe("config export (deprecated batchExportPackages)", () => { it("rejects when both --packageKeys and --keysByVersion are provided", async () => { await runCli([ From 72c2d9484810451a4d230a77868742d83ef3b467 Mon Sep 17 00:00:00 2001 From: Laberion Ajvazi Date: Tue, 28 Jul 2026 14:48:01 +0200 Subject: [PATCH 3/8] SP-1447: Rename merge apply --version to --newVersion The root program registers -V, --version, which shadowed the subcommand flag: the CLI printed its own version and exited without merging. Includes-AI-Code: true --- docs/command-graph.html | 2 +- docs/user-guide/branch-commands.md | 6 ++-- .../configuration-management/module.ts | 4 +-- .../commands/configuration-management.spec.ts | 36 +++++++++++++++++++ 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/docs/command-graph.html b/docs/command-graph.html index 717b1035..582ae7a8 100644 --- a/docs/command-graph.html +++ b/docs/command-graph.html @@ -342,7 +342,7 @@ options: ["-p, --profile ", "--packageKey (Target package key: '' or '@')", "--sourceKey ", "--sourceVersion (version or \"LATEST\")", "--json (default: false)", "-h, --help"] }, { id: "config_branch_merge_apply", label: "apply (beta)", group: "command", path: "config branch merge apply", description: "Merge changes from a source into a target branch", - options: ["-p, --profile ", "--packageKey (Target package key: '' or '@')", "-f, --file (MergeBranchTransport payload; required only when the merge has conflicts)", "--sourceKey (overrides the file's 'sourceKey')", "--sourceVersion (overrides the file's 'sourceVersion'; or \"LATEST\")", "--bump (PATCH | MINOR | MAJOR)", "--version (pin an explicit semver)", "--summary ", "--json (default: false)", "-h, --help"] }, + options: ["-p, --profile ", "--packageKey (Target package key: '' or '@')", "-f, --file (MergeBranchTransport payload; required only when the merge has conflicts)", "--sourceKey (overrides the file's 'sourceKey')", "--sourceVersion (overrides the file's 'sourceVersion'; or \"LATEST\")", "--bump (PATCH | MINOR | MAJOR)", "--newVersion (pin an explicit semver)", "--summary ", "--json (default: false)", "-h, --help"] }, // config metadata { id: "config_metadata_area", label: "metadata", group: "subarea", path: "config metadata", diff --git a/docs/user-guide/branch-commands.md b/docs/user-guide/branch-commands.md index da204565..f7675a32 100644 --- a/docs/user-guide/branch-commands.md +++ b/docs/user-guide/branch-commands.md @@ -83,7 +83,7 @@ The CLI posts the merge directly. If the server detects conflicts it returns an Customise the published version without writing a file: - `--bump PATCH|MINOR|MAJOR` — pick the bump option (default `PATCH`). -- `--version 1.5.0` — pin an explicit semver. +- `--newVersion 1.5.0` — pin an explicit semver. - `--summary ""` — summary of changes (default `"Merge @"`). ### With a resolutions file @@ -97,7 +97,7 @@ content-cli config branch merge apply \ ``` - `--sourceKey` / `--sourceVersion` override the file's values when you want to keep one resolution file but target different sources. -- `--bump` / `--version` / `--summary` override the file's `versionCreate` block when set. +- `--bump` / `--newVersion` / `--summary` override the file's `versionCreate` block when set. A valid merge body looks like: @@ -118,7 +118,7 @@ A valid merge body looks like: `versionCreate` is filled in this order of precedence (highest wins): -1. `--version ` flag or `--bump PATCH|MINOR|MAJOR` flag (and `--summary`). +1. `--newVersion ` flag or `--bump PATCH|MINOR|MAJOR` flag (and `--summary`). 2. Whatever the resolutions file already has under `versionCreate`. 3. Default: `versionBumpOption: PATCH` + `summaryOfChanges: "Merge @"`. diff --git a/src/commands/configuration-management/module.ts b/src/commands/configuration-management/module.ts index 97ab7463..12d13169 100644 --- a/src/commands/configuration-management/module.ts +++ b/src/commands/configuration-management/module.ts @@ -77,7 +77,7 @@ class Module extends IModule { .option("--sourceKey ", "Source package key, main package key or '@' (overrides the file's 'sourceKey')") .option("--sourceVersion ", "Source version (overrides the file's 'sourceVersion'; or 'LATEST')") .option("--bump ", "Version bump for the new published version: PATCH | MINOR | MAJOR") - .option("--version ", "Pin the new published version to an explicit semver") + .option("--newVersion ", "Pin the new published version to an explicit semver") .option("--summary ", "Summary of changes for the new published version") .option("--json", "Write response to a JSON file", false) .action(this.applyBranchMerge); @@ -328,7 +328,7 @@ class Module extends IModule { sourceVersion: options.sourceVersion, file: options.file, bump: options.bump, - version: options.version, + version: options.newVersion, summary: options.summary, jsonResponse: !!options.json, }); diff --git a/tests/integration/commands/configuration-management.spec.ts b/tests/integration/commands/configuration-management.spec.ts index 987f1ff5..b7283ec3 100644 --- a/tests/integration/commands/configuration-management.spec.ts +++ b/tests/integration/commands/configuration-management.spec.ts @@ -78,6 +78,7 @@ describe("configuration-management command integration", () => { mockBranchCommandService = { setBranchingEnabled: jest.fn().mockResolvedValue(undefined), + mergeApply: jest.fn().mockResolvedValue(undefined), } as any; (ConfigCommandService as jest.MockedClass).mockImplementation(() => mockConfigCommandService); @@ -306,6 +307,41 @@ describe("configuration-management command integration", () => { }); }); + describe("config branch merge apply (mergeApply)", () => { + it("forwards --newVersion, which the root program's --version would otherwise shadow", async () => { + const result = await runCli([ + "config", "branch", "merge", "apply", + "--packageKey", "target", + "--sourceKey", "target@feature-a", + "--sourceVersion", "1.4.0", + "--newVersion", "1.5.0", + ]); + + expect(result.exitCode).toBe(0); + expect(mockBranchCommandService.mergeApply).toHaveBeenCalledWith("target", expect.objectContaining({ + sourceKey: "target@feature-a", + sourceVersion: "1.4.0", + version: "1.5.0", + })); + }); + + it("forwards --bump", async () => { + const result = await runCli([ + "config", "branch", "merge", "apply", + "--packageKey", "target", + "--sourceKey", "target@feature-a", + "--sourceVersion", "1.4.0", + "--bump", "MINOR", + ]); + + expect(result.exitCode).toBe(0); + expect(mockBranchCommandService.mergeApply).toHaveBeenCalledWith("target", expect.objectContaining({ + bump: "MINOR", + version: undefined, + })); + }); + }); + describe("config export (deprecated batchExportPackages)", () => { it("rejects when both --packageKeys and --keysByVersion are provided", async () => { await runCli([ From 1c02a05e4b7ab6cdd2399ec1732916d846caf314 Mon Sep 17 00:00:00 2001 From: Laberion Ajvazi Date: Tue, 28 Jul 2026 16:10:00 +0200 Subject: [PATCH 4/8] SP-1472: Add config branch export and import (beta) Includes-AI-Code: true --- docs/command-graph.html | 7 + docs/user-guide/branch-commands.md | 69 ++++- docs/user-guide/index.md | 2 +- .../branch-export-import.command.service.ts | 229 +++++++++++++++ .../branch/branch-utils.ts | 27 ++ .../branch/interfaces/branch.interfaces.ts | 6 + .../configuration-management/module.ts | 69 +++++ .../branch/branch-utils.spec.ts | 56 ++++ .../config-branch-export-import.spec.ts | 271 ++++++++++++++++++ .../commands/configuration-management.spec.ts | 221 ++++++++++++++ tests/jest.setup.ts | 1 + tests/utls/fs-mock-utils.ts | 9 +- 12 files changed, 964 insertions(+), 3 deletions(-) create mode 100644 src/commands/configuration-management/branch/branch-export-import.command.service.ts create mode 100644 src/commands/configuration-management/branch/branch-utils.ts create mode 100644 tests/commands/configuration-management/branch/branch-utils.spec.ts create mode 100644 tests/commands/configuration-management/branch/config-branch-export-import.spec.ts diff --git a/docs/command-graph.html b/docs/command-graph.html index 582ae7a8..5cdda318 100644 --- a/docs/command-graph.html +++ b/docs/command-graph.html @@ -326,6 +326,12 @@ { id: "config_branch_delete", label: "delete (beta)", group: "command", path: "config branch delete", description: "Delete a branch", options: ["-p, --profile ", "--packageKey (Main package key, no '@')", "--branchKey ", "-h, --help"] }, + { id: "config_branch_export", label: "export (beta)", group: "command", path: "config branch export", + description: "Export a branch locally, or push it to a Git branch with --gitProfile", + options: ["-p, --profile ", "--packageKey (Main package key, no '@')", "--branchKey (mutually exclusive with --all)", "--all (default: false) (main package plus every branch; requires --gitProfile)", "--zip (default: false) (local export only)", "--gitProfile (when set, pushes to Git instead of writing locally)", "--json (default: false)", "-h, --help"] }, + { id: "config_branch_import", label: "import (beta)", group: "command", path: "config branch import", + description: "Import a branch from a zip, a directory, or a Git branch", + options: ["-p, --profile ", "--packageKey (Main package key, no '@')", "--branchKey ", "-f, --file (mutually exclusive with --directory and --gitProfile)", "-d, --directory (mutually exclusive with --file and --gitProfile)", "--overwrite (default: false)", "--gitProfile (when set, pulls from Git instead of reading locally)", "--json (default: false)", "-h, --help"] }, // config branch settings { id: "config_branch_settings_area", label: "settings (beta)", group: "subsubarea", path: "config branch settings", @@ -504,6 +510,7 @@ ["area_config","config_branch_area"], ["config_branch_area","config_branch_create"],["config_branch_area","config_branch_list"], ["config_branch_area","config_branch_delete"], + ["config_branch_area","config_branch_export"],["config_branch_area","config_branch_import"], ["config_branch_area","config_branch_settings_area"],["config_branch_settings_area","config_branch_settings_set"], ["config_branch_area","config_branch_merge_area"], ["config_branch_merge_area","config_branch_merge_preview"],["config_branch_merge_area","config_branch_merge_apply"], diff --git a/docs/user-guide/branch-commands.md b/docs/user-guide/branch-commands.md index f7675a32..f9fd5bc3 100644 --- a/docs/user-guide/branch-commands.md +++ b/docs/user-guide/branch-commands.md @@ -1,11 +1,12 @@ # Branch Commands (beta) -The `config branch` command group lets you author and merge branches from the CLI. +The `config branch` command group lets you author and merge branches from the CLI, and optionally mirror a branch to a Git branch one-to-one. ## Concepts - **Main package** — a regular package, identified by its plain `` (no `@`). - **Branch** — a separate package keyed `@`, created from a version of the source package. The source can be the main package or another branch. +- **Git mirror** — an optional, one-to-one mapping between a branch and a Git branch in a configured Git profile. The mirror is opt-in: it is engaged only when you pass `--gitProfile` to `config branch export` / `config branch import`. Without a Git profile these commands read and write local files instead, exactly like `config package export` / `config package import`. ## Branching Settings @@ -137,3 +138,69 @@ Worked example: the preview reports that for node `node-1`, the source set `/tit ] } ``` + +## Branch export / import + +`config branch export` and `config branch import` move a branch's contents in and out of the package. They behave like `config package export` / `config package import`, with one difference: they always rewrite `package.json#key`, so a branch's exported content lines up with the main package's. + +### Key rewriting + +A branch package's key is `@`. So that a main-vs-branch pull request diffs real content instead of key suffixes, `config branch export` rewrites that branch key down to the main package key before writing: + +- `package.json` → `key` + +Node files are **not** rewritten. The server omits `packageNodeKey` / `parentNodeKey` from the export whenever they equal the package key, so there is nothing to strip. `config branch import` applies the exact reverse on the way back in, restoring the `@` suffix in `package.json#key` so the package re-imports under its own branch identity. + +### Local vs Git + +Both commands work locally by default and only touch Git when you pass `--gitProfile`: + +| Mode | Trigger | Behaviour | +|---|---|---| +| Local | no `--gitProfile` | `export` writes a `` directory (or `.zip` with `--zip`); `import` reads `--file` / `--directory` | +| Git | `--gitProfile ` | `export` pushes to the Git branch named ``; `import` pulls that Git branch. With `--all`, the main package goes to the Git branch `main` | + +A configured default Git profile is **not** enough to trigger Git mode — you must pass `--gitProfile` explicitly on the command. See [Using Git Profiles in Getting Started](../getting-started.md#using-git-profiles) for how to create, list, and authenticate a Git profile. + +### Export a branch + +```bash +# Local: writes a ./my-package directory with package.json#key rewritten to "my-package" +content-cli config branch export \ + --packageKey my-package \ + --branchKey feature-a + +# Git: pushes the rewritten package to the Git branch "feature-a" +content-cli config branch export \ + --packageKey my-package \ + --branchKey feature-a \ + --gitProfile +``` + +Options: + +- `--zip` — write a single `.zip` instead of an unzipped directory (local export only). +- `--all` — push the main package and every one of its branches to Git in one go. Requires `--gitProfile` and is mutually exclusive with `--branchKey`. The main package goes to the Git branch `main`; each branch goes to a Git branch named after its branch key. +- `--json` — write the summary to a JSON file instead of logging it. + +### Import a branch + +```bash +# Local: reads ./feature-a-dir and imports it as my-package@feature-a +content-cli config branch import \ + --packageKey my-package \ + --branchKey feature-a \ + --directory ./feature-a-dir + +# Git: pulls the Git branch "feature-a" and imports it as my-package@feature-a +content-cli config branch import \ + --packageKey my-package \ + --branchKey feature-a \ + --gitProfile +``` + +Options: + +- `-f, --file ` / `-d, --directory ` — local import source (mutually exclusive with each other and with `--gitProfile`). +- `--overwrite` — allow overwriting an existing package with the same key. +- `--json` — write the summary to a JSON file instead of logging it. diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md index 48eb3267..5eb518f1 100644 --- a/docs/user-guide/index.md +++ b/docs/user-guide/index.md @@ -6,7 +6,7 @@ Content CLI organizes its commands into groups by area. Each group covers a spec |---|-------------------------------------------------------------------------------| | [Studio Commands](./studio-commands.md) | Pull and push packages, assets, spaces, and widgets to and from Studio | | [Config Commands](./config-commands.md) | List, batch export, and import all packages and their configurations | -| [Branch Commands](./branch-commands.md) | Create, list, merge, and delete package branches | +| [Branch Commands](./branch-commands.md) | Create, list, merge, and delete package branches, and mirror them to Git | | [Deployment Commands](./deployment-commands.md) | Create deployments, list history, check active deployments, and manage targets | | [Asset Registry Commands](./asset-registry-commands.md) | Discover registered asset types and their service descriptors | | [Data Pool Commands](./data-pool-commands.md) | Export and import Data Pools with their dependencies | diff --git a/src/commands/configuration-management/branch/branch-export-import.command.service.ts b/src/commands/configuration-management/branch/branch-export-import.command.service.ts new file mode 100644 index 00000000..c6c6e716 --- /dev/null +++ b/src/commands/configuration-management/branch/branch-export-import.command.service.ts @@ -0,0 +1,229 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { v4 as uuidv4 } from "uuid"; +import AdmZip = require("adm-zip"); +import * as FormData from "form-data"; +import { Readable } from "node:stream"; +import { resolve } from "node:path"; +import { Context } from "../../../core/command/cli-context"; +import { fileService, FileService } from "../../../core/utils/file-service"; +import { FatalError, logger } from "../../../core/utils/logger"; +import { GitService } from "../../../core/git-profile/git/git.service"; +import { SinglePackageExportApi } from "../api/single-package-export-api"; +import { SinglePackageImportApi } from "../api/single-package-import-api"; +import { BranchApi } from "./api/branch.api"; +import { BranchUtils } from "./branch-utils"; +import { BranchSyncSummary, BranchTransport } from "./interfaces/branch.interfaces"; + +const PACKAGE_FILE = "package.json"; +const MAX_UNCOMPRESSED_ZIP_SIZE = 4 * 1024 * 1024 * 1024; + +export interface BranchExportOptions { + zip?: boolean; + gitEnabled?: boolean; + jsonResponse?: boolean; +} + +export interface BranchImportOptions { + file?: string; + directory?: string; + overwrite?: boolean; + gitEnabled?: boolean; + jsonResponse?: boolean; +} + +export class BranchExportImportCommandService { + + private readonly singlePackageExportApi: SinglePackageExportApi; + private readonly singlePackageImportApi: SinglePackageImportApi; + private readonly branchApi: BranchApi; + private readonly gitService: GitService; + + constructor(context: Context) { + this.singlePackageExportApi = new SinglePackageExportApi(context); + this.singlePackageImportApi = new SinglePackageImportApi(context); + this.branchApi = new BranchApi(context); + this.gitService = new GitService(context); + } + + public async exportBranch(packageKey: string, branchKey: string, options: BranchExportOptions = {}): Promise { + const branchPackageKey = BranchUtils.constructBranchKey(packageKey, branchKey); + const sourceDir = await this.exportRewrittenPackageDir(branchPackageKey); + try { + if (options.gitEnabled) { + await this.gitService.pushToBranch(sourceDir, branchKey); + const summary: BranchSyncSummary = { packageKey: branchPackageKey, branchName: branchKey }; + this.report(summary, !!options.jsonResponse, `Exported ${branchPackageKey} to Git branch '${branchKey}'.`); + return; + } + this.writeLocalArtifact(sourceDir, packageKey, !!options.zip); + } finally { + this.removeDir(sourceDir); + } + } + + public async exportAll(packageKey: string, jsonResponse: boolean = false): Promise { + const mainKey = BranchUtils.extractProjectKey(packageKey); + const branches: BranchTransport[] = await this.branchApi.listBranches(mainKey); + + const synced: string[] = []; + await this.pushMainPackage(mainKey); + synced.push(mainKey); + // listBranches also reports the main package itself, whose packageKey carries no + // branch suffix. It is already pushed above, and it has no '@' + // package to export, so exporting it as a branch would fail. + for (const branch of branches.filter(entry => BranchUtils.isBranchPackageKey(entry.packageKey))) { + await this.exportBranch(mainKey, branch.branchKey, { gitEnabled: true }); + synced.push(branch.packageKey); + } + + const summary: BranchSyncSummary = { packageKey: mainKey, branchName: BranchUtils.MAIN_BRANCH_KEY, synced }; + this.report(summary, jsonResponse, `Exported Git mirror for ${mainKey}: ${synced.length} package(s) pushed.`); + } + + public async importBranch(packageKey: string, branchKey: string, options: BranchImportOptions = {}): Promise { + const branchPackageKey = BranchUtils.constructBranchKey(packageKey, branchKey); + + const workingDir = options.gitEnabled + ? await this.gitService.pullFromBranch(branchKey) + : this.prepareLocalWorkingDir(options.file, options.directory); + try { + this.replacePackageKey(workingDir, packageKey, branchPackageKey); + await this.importPackageSourceDir(workingDir, !!options.overwrite); + + const summary: BranchSyncSummary = { packageKey: branchPackageKey, branchName: branchKey }; + const origin = options.gitEnabled ? `Git branch '${branchKey}'` : (options.file ?? options.directory); + this.report(summary, !!options.jsonResponse, `Imported ${origin} into ${branchPackageKey}.`); + } finally { + this.removeDir(workingDir); + } + } + + private async pushMainPackage(mainKey: string): Promise { + const packageData = await this.singlePackageExportApi.exportPackage(mainKey); + const extractedDir = fileService.extractZipBufferToTempDirectory(packageData); + try { + await this.gitService.pushToBranch(extractedDir, BranchUtils.MAIN_BRANCH_KEY); + } finally { + this.removeDir(extractedDir); + } + } + + private async exportRewrittenPackageDir(branchPackageKey: string): Promise { + const packageData = await this.singlePackageExportApi.exportPackage(branchPackageKey); + const extractedDir = fileService.extractZipBufferToTempDirectory(packageData); + const projectKey = BranchUtils.extractProjectKey(branchPackageKey); + this.replacePackageKey(extractedDir, branchPackageKey, projectKey); + return extractedDir; + } + + private writeLocalArtifact(sourceDir: string, packageKey: string, zip: boolean): void { + if (zip) { + const zipPath = fileService.zipDirectoryAsSinglePackage(sourceDir); + try { + const fileName = `${packageKey}.zip`; + fileService.writeBufferToFileWithGivenName(fs.readFileSync(zipPath), resolve(process.cwd(), fileName)); + logger.info(FileService.fileDownloadedMessage + fileName); + } finally { + fs.rmSync(zipPath, { force: true }); + } + return; + } + const targetDir = resolve(process.cwd(), packageKey); + fs.rmSync(targetDir, { recursive: true, force: true }); + fs.cpSync(sourceDir, targetDir, { recursive: true }); + logger.info(`Successful export. Exported directory: ${packageKey}`); + } + + private prepareLocalWorkingDir(file: string | undefined, directory: string | undefined): string { + const workingDir = fs.mkdtempSync(path.join(os.tmpdir(), "content-cli-")); + if (directory) { + if (!fileService.isDirectory(directory)) { + fs.rmSync(workingDir, { recursive: true, force: true }); + throw new FatalError("The --directory option accepts only directories."); + } + fs.cpSync(directory, workingDir, { recursive: true }); + return workingDir; + } + if (fileService.isDirectory(file)) { + fs.rmSync(workingDir, { recursive: true, force: true }); + throw new FatalError("The --file option accepts only zip files."); + } + new AdmZip(file).extractAllTo(workingDir, true); + return workingDir; + } + + private async importPackageSourceDir(sourceDir: string, overwrite: boolean): Promise { + const zipPath = fileService.zipDirectoryAsSinglePackage(sourceDir); + try { + const packageZip = new AdmZip(zipPath); + this.assertUncompressedSizeWithinLimit(packageZip, zipPath); + const formData = new FormData(); + formData.append("packageFile", this.toReadable(packageZip), { filename: "package.zip" }); + await this.singlePackageImportApi.importPackage(formData, overwrite); + } finally { + fs.rmSync(zipPath, { force: true }); + } + } + + private replacePackageKey(dir: string, from: string, to: string): void { + if (from === to) { + return; + } + const filePath = path.join(dir, PACKAGE_FILE); + if (!fs.existsSync(filePath)) { + return; + } + const raw = fs.readFileSync(filePath, { encoding: "utf-8" }); + const json = JSON.parse(raw); + if (json.key !== from) { + return; + } + const updated = this.replaceFieldValue(raw, "key", from, to); + if (updated !== raw) { + fs.writeFileSync(filePath, updated, { encoding: "utf-8" }); + } + } + + private replaceFieldValue(content: string, field: string, from: string, to: string): string { + const pattern = new RegExp(`("${this.escapeRegExp(field)}"\\s*:\\s*)"${this.escapeRegExp(from)}"`); + return content.replace(pattern, `$1"${to}"`); + } + + private escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, match => "\\" + match); + } + + private assertUncompressedSizeWithinLimit(packageZip: AdmZip, sourcePath: string): void { + const totalBytes = packageZip.getEntries().reduce((sum, entry) => sum + entry.header.size, 0); + if (totalBytes > MAX_UNCOMPRESSED_ZIP_SIZE) { + throw new FatalError( + `Failed to handle "${sourcePath}": uncompressed size ${(totalBytes / (1024 ** 3)).toFixed(2)} GB exceeds the 4 GB limit.` + ); + } + } + + private toReadable(packageZip: AdmZip): Readable { + return new Readable({ + read(): void { + this.push(packageZip.toBuffer()); + this.push(null); + }, + }); + } + + private removeDir(dir: string): void { + fs.rmSync(dir, { recursive: true, force: true }); + } + + private report(summary: BranchSyncSummary, jsonResponse: boolean, message: string): void { + if (jsonResponse) { + const filename = `${uuidv4()}.json`; + fileService.writeToFileWithGivenName(JSON.stringify(summary, null, 2), filename); + logger.info(FileService.fileDownloadedMessage + filename); + } else { + logger.info(message); + } + } +} diff --git a/src/commands/configuration-management/branch/branch-utils.ts b/src/commands/configuration-management/branch/branch-utils.ts new file mode 100644 index 00000000..347e83ee --- /dev/null +++ b/src/commands/configuration-management/branch/branch-utils.ts @@ -0,0 +1,27 @@ +export class BranchUtils { + + public static readonly MAIN_BRANCH_KEY = "main"; + private static readonly BRANCH_SEPARATOR = "@"; + + public static isBranchPackageKey(packageKey: string): boolean { + return !!packageKey && packageKey.indexOf(BranchUtils.BRANCH_SEPARATOR) >= 0; + } + + public static constructBranchKey(projectKey: string, branchKey: string): string { + return `${projectKey}${BranchUtils.BRANCH_SEPARATOR}${branchKey}`; + } + + public static extractProjectKey(packageKey: string): string { + if (!BranchUtils.isBranchPackageKey(packageKey)) { + return packageKey; + } + return packageKey.substring(0, packageKey.indexOf(BranchUtils.BRANCH_SEPARATOR)); + } + + public static extractBranchKey(packageKey: string): string | null { + if (!BranchUtils.isBranchPackageKey(packageKey)) { + return null; + } + return packageKey.substring(packageKey.indexOf(BranchUtils.BRANCH_SEPARATOR) + 1); + } +} diff --git a/src/commands/configuration-management/branch/interfaces/branch.interfaces.ts b/src/commands/configuration-management/branch/interfaces/branch.interfaces.ts index 2ad7bfd1..fc24f238 100644 --- a/src/commands/configuration-management/branch/interfaces/branch.interfaces.ts +++ b/src/commands/configuration-management/branch/interfaces/branch.interfaces.ts @@ -155,3 +155,9 @@ export interface MergeApplyOptions { summary?: string; jsonResponse?: boolean; } + +export interface BranchSyncSummary { + packageKey: string; + branchName: string; + synced?: string[]; +} diff --git a/src/commands/configuration-management/module.ts b/src/commands/configuration-management/module.ts index 12d13169..c4d4abce 100644 --- a/src/commands/configuration-management/module.ts +++ b/src/commands/configuration-management/module.ts @@ -18,6 +18,7 @@ import { PackageValidationService } from "./package-validation.service"; import { SinglePackageImportService } from "./single-package-import.service"; import { SinglePackageExportService } from "./single-package-export.service"; import { BranchCommandService } from "./branch/branch.command.service"; +import { BranchExportImportCommandService } from "./branch/branch-export-import.command.service"; class Module extends IModule { @@ -82,6 +83,27 @@ class Module extends IModule { .option("--json", "Write response to a JSON file", false) .action(this.applyBranchMerge); + branchCommand.command("export").beta() + .description("Export a branch's package, rewriting the package.json key to the main package key. Writes an unzipped '' directory by default (or a '.zip' with --zip), or pushes to the Git branch named after --branchKey when --gitProfile is set.") + .requiredOption("--packageKey ", "Main package key (no '@')") + .option("--branchKey ", "Branch key to export. The Git branch, when pushing, is named after this value. Mutually exclusive with --all") + .option("--all", "Push the main package and every one of its branches to Git. Requires --gitProfile and is mutually exclusive with --branchKey", false) + .option("--zip", "Export the branch as a single '.zip' file instead of an unzipped directory (local export only)", false) + .option("--gitProfile ", "Git profile to use. When set, the export is pushed to Git instead of written locally") + .option("--json", "Write response to a JSON file", false) + .action(this.exportBranch); + + branchCommand.command("import").beta() + .description("Import a branch's package from a zip file, directory, or Git branch, restoring the package.json key to '@'. Pulls the Git branch named after --branchKey when --gitProfile is set; otherwise reads --file or --directory.") + .requiredOption("--packageKey ", "Main package key (no '@')") + .requiredOption("--branchKey ", "Branch key to import into. The Git branch, when pulling, is named after this value") + .option("-f, --file ", "Package zip file (relative path). Mutually exclusive with --directory and --gitProfile") + .option("-d, --directory ", "Package directory (relative path). Mutually exclusive with --file and --gitProfile") + .option("--overwrite", "Flag to allow overwriting an existing package with the same key", false) + .option("--gitProfile ", "Git profile to use. When set, the package is pulled from Git instead of read locally") + .option("--json", "Write response to a JSON file", false) + .action(this.importBranch); + configCommand.command("list") .description("[Deprecated] Use 't2tc package list' instead. List packages in the target team.") .deprecationNotice("'config list' is deprecated and will be removed in a future release. Use 't2tc package list' instead.") @@ -334,6 +356,53 @@ class Module extends IModule { }); } + private async exportBranch(context: Context, command: Command, options: OptionValues): Promise { + const gitEnabled = !!options.gitProfile; + const service = new BranchExportImportCommandService(context); + + if (options.all) { + if (options.branchKey) { + throw new Error("Please provide either --branchKey or --all, but not both."); + } + if (!gitEnabled) { + throw new Error("--all pushes to Git and requires a Git profile. Please provide --gitProfile."); + } + await service.exportAll(options.packageKey, !!options.json); + return; + } + + if (!options.branchKey) { + throw new Error("Please provide --branchKey, or use --all to export every branch."); + } + await service.exportBranch(options.packageKey, options.branchKey, { + zip: !!options.zip, + gitEnabled, + jsonResponse: !!options.json, + }); + } + + private async importBranch(context: Context, command: Command, options: OptionValues): Promise { + const gitEnabled = !!options.gitProfile; + + if (gitEnabled && (options.file || options.directory)) { + throw new Error("You cannot use --file or --directory together with --gitProfile. Only one import source can be defined."); + } + if (!gitEnabled && !options.file && !options.directory) { + throw new Error("You must provide a --file, a --directory, or a --gitProfile option to import a branch."); + } + if (options.file && options.directory) { + throw new Error("You cannot use both --file and --directory options at the same time. Only one import source can be defined."); + } + + await new BranchExportImportCommandService(context).importBranch(options.packageKey, options.branchKey, { + file: options.file, + directory: options.directory, + overwrite: !!options.overwrite, + gitEnabled, + jsonResponse: !!options.json, + }); + } + private async listPackages(context: Context, command: Command, options: OptionValues): Promise { if (options.staging && (options.withDependencies || options.packageKeys || options.keysByVersion || options.variableValue || options.variableType)) { throw new Error("Staging parameter is not compatible with --withDependencies, --packageKeys, --keysByVersion, --variableValue, --variableType"); diff --git a/tests/commands/configuration-management/branch/branch-utils.spec.ts b/tests/commands/configuration-management/branch/branch-utils.spec.ts new file mode 100644 index 00000000..7e6dd0aa --- /dev/null +++ b/tests/commands/configuration-management/branch/branch-utils.spec.ts @@ -0,0 +1,56 @@ +import { BranchUtils } from "../../../../src/commands/configuration-management/branch/branch-utils"; + +describe("BranchUtils", () => { + + describe("isBranchPackageKey", () => { + it("returns true when the key contains '@'", () => { + expect(BranchUtils.isBranchPackageKey("main@branch-123")).toBe(true); + }); + + it("returns false when the key has no '@'", () => { + expect(BranchUtils.isBranchPackageKey("my-main-package")).toBe(false); + }); + + it("returns false for an empty key", () => { + expect(BranchUtils.isBranchPackageKey("")).toBe(false); + }); + }); + + describe("constructBranchKey", () => { + it("appends the branch key to the project key with '@'", () => { + expect(BranchUtils.constructBranchKey("main-pkg", "feat-1")).toEqual("main-pkg@feat-1"); + }); + }); + + describe("extractProjectKey", () => { + it("returns the same key for a main package", () => { + expect(BranchUtils.extractProjectKey("main-pkg")).toEqual("main-pkg"); + }); + + it("returns the project key for a branch package", () => { + expect(BranchUtils.extractProjectKey("main-pkg@feat-1")).toEqual("main-pkg"); + }); + + it("splits on the first '@'", () => { + expect(BranchUtils.extractProjectKey("main-pkg@team@feat")).toEqual("main-pkg"); + }); + }); + + describe("extractBranchKey", () => { + it("returns the branch suffix for a branch package", () => { + expect(BranchUtils.extractBranchKey("main-pkg@feat-1")).toEqual("feat-1"); + }); + + it("keeps any later '@' in the branch suffix", () => { + expect(BranchUtils.extractBranchKey("main-pkg@team@feat")).toEqual("team@feat"); + }); + + it("returns null for a main package", () => { + expect(BranchUtils.extractBranchKey("main-pkg")).toBeNull(); + }); + + it("returns null for an empty key", () => { + expect(BranchUtils.extractBranchKey("")).toBeNull(); + }); + }); +}); diff --git a/tests/commands/configuration-management/branch/config-branch-export-import.spec.ts b/tests/commands/configuration-management/branch/config-branch-export-import.spec.ts new file mode 100644 index 00000000..0708a030 --- /dev/null +++ b/tests/commands/configuration-management/branch/config-branch-export-import.spec.ts @@ -0,0 +1,271 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { v4 as uuid } from "uuid"; +import { mockAxiosGet, mockAxiosPost, mockedPostRequestBodyByUrl } from "../../../utls/http-requests-mock"; +import { BranchExportImportCommandService } from "../../../../src/commands/configuration-management/branch/branch-export-import.command.service"; +import { testContext } from "../../../utls/test-context"; +import { loggingTestTransport } from "../../../jest.setup"; +import { FileService, fileService } from "../../../../src/core/utils/file-service"; +import { GitService } from "../../../../src/core/git-profile/git/git.service"; +import { BranchUtils } from "../../../../src/commands/configuration-management/branch/branch-utils"; + +jest.unmock("fs"); +jest.unmock("node:fs"); + +const TEAM = "https://myTeam.celonis.cloud"; +const MAIN_KEY = "my-package"; +const BRANCH = "feature-a"; +const BRANCH_KEY = "my-package@feature-a"; + +function exportUrl(packageKey: string): string { + return `${TEAM}/pacman/api/core/staging/packages/${packageKey}/export-file`; +} + +function branchesUrl(packageKey: string): string { + return `${TEAM}/pacman/api/core/packages/${packageKey}/branches`; +} + +const IMPORT_URL = `${TEAM}/pacman/api/core/staging/packages/import-file`; + +function makeTempDir(): string { + const dir = path.join(os.tmpdir(), `cc-git-test-${uuid()}`); + fs.mkdirSync(path.join(dir, "nodes"), { recursive: true }); + return dir; +} + +function seedPackageDir(dir: string, packageKey: string): void { + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ key: packageKey, name: "My Package" }, null, 2)); + fs.writeFileSync(path.join(dir, "variables.json"), JSON.stringify([{ key: "v1" }], null, 2)); + fs.writeFileSync(path.join(dir, "nodes", "root.json"), JSON.stringify({ + key: "root", type: "FOLDER", packageNodeKey: packageKey, parentNodeKey: null, + }, null, 2)); + fs.writeFileSync(path.join(dir, "nodes", "child.json"), JSON.stringify({ + key: "child", type: "VIEW", packageNodeKey: packageKey, parentNodeKey: packageKey, + }, null, 2)); +} + +describe("config branch export/import", () => { + + let capturedPushDir: string | undefined; + let cwdDir: string | undefined; + + afterEach(() => { + jest.restoreAllMocks(); + if (cwdDir) { + fs.rmSync(cwdDir, { recursive: true, force: true }); + cwdDir = undefined; + } + capturedPushDir = undefined; + }); + + function arrangeExport(packageKey: string): void { + mockAxiosGet(exportUrl(packageKey), Buffer.from("ignored-zip")); + const exportedDir = makeTempDir(); + seedPackageDir(exportedDir, packageKey); + jest.spyOn(FileService.prototype, "extractZipBufferToTempDirectory").mockReturnValue(exportedDir); + } + + function capturePush(): void { + jest.spyOn(GitService.prototype, "pushToBranch").mockImplementation(async (sourceDir: string) => { + const snapshot = path.join(os.tmpdir(), `cc-git-pushed-${uuid()}`); + fs.cpSync(sourceDir, snapshot, { recursive: true }); + capturedPushDir = snapshot; + }); + } + + function useTempCwd(): string { + cwdDir = path.join(os.tmpdir(), `cc-git-cwd-${uuid()}`); + fs.mkdirSync(cwdDir, { recursive: true }); + jest.spyOn(process, "cwd").mockReturnValue(cwdDir); + return cwdDir; + } + + function readNode(dir: string, name: string): any { + return JSON.parse(fs.readFileSync(path.join(dir, "nodes", name), "utf-8")); + } + + describe("export to Git", () => { + it("pushes to the Git branch named after branchKey and rewrites only package.json#key", async () => { + arrangeExport(BRANCH_KEY); + capturePush(); + + await new BranchExportImportCommandService(testContext).exportBranch(MAIN_KEY, BRANCH, { gitEnabled: true }); + + const pushToBranch = GitService.prototype.pushToBranch as jest.Mock; + expect(pushToBranch).toHaveBeenCalledTimes(1); + expect(pushToBranch.mock.calls[0][1]).toEqual(BRANCH); + + const pkg = JSON.parse(fs.readFileSync(path.join(capturedPushDir!, "package.json"), "utf-8")); + expect(pkg.key).toEqual(MAIN_KEY); + expect(loggingTestTransport.logMessages.some(m => m.message.includes(`Exported ${BRANCH_KEY} to Git branch '${BRANCH}'`))).toBe(true); + }); + + it("does not rewrite node keys (backend omits them when equal to the package key)", async () => { + arrangeExport(BRANCH_KEY); + capturePush(); + + await new BranchExportImportCommandService(testContext).exportBranch(MAIN_KEY, BRANCH, { gitEnabled: true }); + + expect(readNode(capturedPushDir!, "child.json").packageNodeKey).toEqual(BRANCH_KEY); + expect(readNode(capturedPushDir!, "child.json").parentNodeKey).toEqual(BRANCH_KEY); + }); + + it("rewrites only the key value and preserves the file's original formatting", async () => { + const exportedDir = makeTempDir(); + const originalPackageJson = [ + "{", + ` "key" : "${BRANCH_KEY}",`, + ' "name" : "My Package",', + ' "configuration" : {', + ' "variables" : [ ]', + " }", + "}", + ].join("\n"); + fs.writeFileSync(path.join(exportedDir, "package.json"), originalPackageJson); + mockAxiosGet(exportUrl(BRANCH_KEY), Buffer.from("ignored-zip")); + jest.spyOn(FileService.prototype, "extractZipBufferToTempDirectory").mockReturnValue(exportedDir); + capturePush(); + + await new BranchExportImportCommandService(testContext).exportBranch(MAIN_KEY, BRANCH, { gitEnabled: true }); + + const expected = originalPackageJson.replace(`"${BRANCH_KEY}"`, `"${MAIN_KEY}"`); + expect(fs.readFileSync(path.join(capturedPushDir!, "package.json"), "utf-8")).toEqual(expected); + }); + + it("writes a JSON summary file when jsonResponse=true", async () => { + arrangeExport(BRANCH_KEY); + capturePush(); + const writeSpy = jest.spyOn(fileService, "writeToFileWithGivenName").mockImplementation(() => undefined); + + await new BranchExportImportCommandService(testContext).exportBranch(MAIN_KEY, BRANCH, { gitEnabled: true, jsonResponse: true }); + + expect(writeSpy).toHaveBeenCalledTimes(1); + const summary = JSON.parse(writeSpy.mock.calls[0][0] as string); + expect(summary.packageKey).toEqual(BRANCH_KEY); + expect(summary.branchName).toEqual(BRANCH); + }); + }); + + describe("export locally", () => { + it("writes an unzipped directory with the key rewritten and no Git push", async () => { + arrangeExport(BRANCH_KEY); + const pushSpy = jest.spyOn(GitService.prototype, "pushToBranch").mockResolvedValue(); + const cwd = useTempCwd(); + + await new BranchExportImportCommandService(testContext).exportBranch(MAIN_KEY, BRANCH, {}); + + expect(pushSpy).not.toHaveBeenCalled(); + const outDir = path.join(cwd, MAIN_KEY); + expect(fs.existsSync(outDir)).toBe(true); + const pkg = JSON.parse(fs.readFileSync(path.join(outDir, "package.json"), "utf-8")); + expect(pkg.key).toEqual(MAIN_KEY); + expect(readNode(outDir, "child.json").parentNodeKey).toEqual(BRANCH_KEY); + expect(loggingTestTransport.logMessages.some(m => m.message.includes(`Exported directory: ${MAIN_KEY}`))).toBe(true); + }); + + it("writes a .zip when --zip is set", async () => { + arrangeExport(BRANCH_KEY); + const pushSpy = jest.spyOn(GitService.prototype, "pushToBranch").mockResolvedValue(); + const cwd = useTempCwd(); + + await new BranchExportImportCommandService(testContext).exportBranch(MAIN_KEY, BRANCH, { zip: true }); + + expect(pushSpy).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(cwd, `${MAIN_KEY}.zip`))).toBe(true); + }); + }); + + describe("export --all", () => { + it("pushes the main package (untouched) and every branch (rewritten)", async () => { + mockAxiosGet(branchesUrl(MAIN_KEY), [ + { packageKey: BRANCH_KEY, branchKey: BRANCH, projectKey: MAIN_KEY, sourcePackageKey: MAIN_KEY, sourceVersion: "1.0.0" }, + ]); + mockAxiosGet(exportUrl(MAIN_KEY), Buffer.from("zip")); + mockAxiosGet(exportUrl(BRANCH_KEY), Buffer.from("zip")); + jest.spyOn(FileService.prototype, "extractZipBufferToTempDirectory").mockImplementation(() => { + const dir = makeTempDir(); + seedPackageDir(dir, MAIN_KEY); + return dir; + }); + const pushSpy = jest.spyOn(GitService.prototype, "pushToBranch").mockResolvedValue(); + + await new BranchExportImportCommandService(testContext).exportAll(MAIN_KEY); + + expect(pushSpy).toHaveBeenCalledTimes(2); + const branches = pushSpy.mock.calls.map(call => call[1]).sort(); + expect(branches).toEqual(["feature-a", "main"]); + expect(loggingTestTransport.logMessages.some(m => m.message.includes(`Exported Git mirror for ${MAIN_KEY}: 2 package(s) pushed`))).toBe(true); + }); + + it("skips the main package entry that listBranches reports alongside the real branches", async () => { + // The real endpoint returns the main package as its own entry, keyed 'main' but + // with no branch suffix on packageKey. Exporting it as a branch would request + // '@main', which never exists. + mockAxiosGet(branchesUrl(MAIN_KEY), [ + { packageKey: MAIN_KEY, branchKey: BranchUtils.MAIN_BRANCH_KEY, projectKey: MAIN_KEY, sourcePackageKey: MAIN_KEY, sourceVersion: null }, + { packageKey: BRANCH_KEY, branchKey: BRANCH, projectKey: MAIN_KEY, sourcePackageKey: MAIN_KEY, sourceVersion: "1.0.0" }, + ]); + mockAxiosGet(exportUrl(MAIN_KEY), Buffer.from("zip")); + mockAxiosGet(exportUrl(BRANCH_KEY), Buffer.from("zip")); + jest.spyOn(FileService.prototype, "extractZipBufferToTempDirectory").mockImplementation(() => { + const dir = makeTempDir(); + seedPackageDir(dir, MAIN_KEY); + return dir; + }); + const pushSpy = jest.spyOn(GitService.prototype, "pushToBranch").mockResolvedValue(); + + await new BranchExportImportCommandService(testContext).exportAll(MAIN_KEY); + + expect(pushSpy).toHaveBeenCalledTimes(2); + expect(pushSpy.mock.calls.map(call => call[1]).sort()).toEqual([BRANCH, BranchUtils.MAIN_BRANCH_KEY]); + expect(loggingTestTransport.logMessages.some(m => m.message.includes(`Exported Git mirror for ${MAIN_KEY}: 2 package(s) pushed`))).toBe(true); + }); + }); + + describe("import from Git", () => { + it("pulls the branch, restores the branch key, and imports", async () => { + const remoteDir = makeTempDir(); + seedPackageDir(remoteDir, MAIN_KEY); + jest.spyOn(GitService.prototype, "pullFromBranch").mockResolvedValue(remoteDir); + mockAxiosPost(IMPORT_URL, { importedPackage: { key: BRANCH_KEY, name: "My Package" }, importedNodes: [] }); + + let importedPkgKey: string | undefined; + const realZip = fileService.zipDirectoryAsSinglePackage.bind(fileService); + jest.spyOn(fileService, "zipDirectoryAsSinglePackage").mockImplementation((dir: string) => { + importedPkgKey = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf-8")).key; + return realZip(dir); + }); + + await new BranchExportImportCommandService(testContext).importBranch(MAIN_KEY, BRANCH, { gitEnabled: true }); + + expect(GitService.prototype.pullFromBranch).toHaveBeenCalledWith(BRANCH); + expect(importedPkgKey).toEqual(BRANCH_KEY); + expect(mockedPostRequestBodyByUrl.get(IMPORT_URL)).toBeDefined(); + expect(loggingTestTransport.logMessages.some(m => m.message.includes(`Imported Git branch '${BRANCH}' into ${BRANCH_KEY}`))).toBe(true); + }); + }); + + describe("import locally", () => { + it("imports from a directory, restoring the branch key, without touching Git", async () => { + const localDir = makeTempDir(); + seedPackageDir(localDir, MAIN_KEY); + const pullSpy = jest.spyOn(GitService.prototype, "pullFromBranch"); + mockAxiosPost(IMPORT_URL, { importedPackage: { key: BRANCH_KEY, name: "My Package" }, importedNodes: [] }); + + let importedPkgKey: string | undefined; + const realZip = fileService.zipDirectoryAsSinglePackage.bind(fileService); + jest.spyOn(fileService, "zipDirectoryAsSinglePackage").mockImplementation((dir: string) => { + importedPkgKey = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf-8")).key; + return realZip(dir); + }); + + await new BranchExportImportCommandService(testContext).importBranch(MAIN_KEY, BRANCH, { directory: localDir }); + + expect(pullSpy).not.toHaveBeenCalled(); + expect(importedPkgKey).toEqual(BRANCH_KEY); + expect(mockedPostRequestBodyByUrl.get(IMPORT_URL)).toBeDefined(); + expect(loggingTestTransport.logMessages.some(m => m.message.includes(`into ${BRANCH_KEY}`))).toBe(true); + }); + }); +}); diff --git a/tests/integration/commands/configuration-management.spec.ts b/tests/integration/commands/configuration-management.spec.ts index b7283ec3..c538221a 100644 --- a/tests/integration/commands/configuration-management.spec.ts +++ b/tests/integration/commands/configuration-management.spec.ts @@ -9,6 +9,7 @@ import { NodeDiffService } from "../../../src/commands/configuration-management/ import { SinglePackageImportService } from "../../../src/commands/configuration-management/single-package-import.service"; import { SinglePackageExportService } from "../../../src/commands/configuration-management/single-package-export.service"; import { BranchCommandService } from "../../../src/commands/configuration-management/branch/branch.command.service"; +import { BranchExportImportCommandService } from "../../../src/commands/configuration-management/branch/branch-export-import.command.service"; import { CliRunResult, runCli as runCliProcess } from "../../utls/cli-runner"; jest.mock("../../../src/commands/configuration-management/config-command.service"); @@ -21,6 +22,7 @@ jest.mock("../../../src/commands/configuration-management/package-version-comman jest.mock("../../../src/commands/configuration-management/single-package-import.service"); jest.mock("../../../src/commands/configuration-management/single-package-export.service"); jest.mock("../../../src/commands/configuration-management/branch/branch.command.service"); +jest.mock("../../../src/commands/configuration-management/branch/branch-export-import.command.service"); describe("configuration-management command integration", () => { let mockConfigCommandService: jest.Mocked; @@ -33,6 +35,7 @@ describe("configuration-management command integration", () => { let mockSinglePackageImportService: jest.Mocked; let mockSinglePackageExportService: jest.Mocked; let mockBranchCommandService: jest.Mocked; + let mockBranchExportImportCommandService: jest.Mocked; beforeEach(() => { mockConfigCommandService = { @@ -81,6 +84,12 @@ describe("configuration-management command integration", () => { mergeApply: jest.fn().mockResolvedValue(undefined), } as any; + mockBranchExportImportCommandService = { + exportBranch: jest.fn().mockResolvedValue(undefined), + exportAll: jest.fn().mockResolvedValue(undefined), + importBranch: jest.fn().mockResolvedValue(undefined), + } as any; + (ConfigCommandService as jest.MockedClass).mockImplementation(() => mockConfigCommandService); (StagingPackageService as jest.MockedClass).mockImplementation(() => mockStagingPackageService); (MetadataService as jest.MockedClass).mockImplementation(() => mockMetadataService); @@ -91,6 +100,7 @@ describe("configuration-management command integration", () => { (SinglePackageImportService as jest.MockedClass).mockImplementation(() => mockSinglePackageImportService); (SinglePackageExportService as jest.MockedClass).mockImplementation(() => mockSinglePackageExportService); (BranchCommandService as jest.MockedClass).mockImplementation(() => mockBranchCommandService); + (BranchExportImportCommandService as jest.MockedClass).mockImplementation(() => mockBranchExportImportCommandService); }); let lastResult: CliRunResult; @@ -342,6 +352,217 @@ describe("configuration-management command integration", () => { }); }); + describe("config branch export (exportBranch)", () => { + it("exports locally when no --gitProfile is given", async () => { + const result = await runCli([ + "config", "branch", "export", + "--packageKey", "my-package", + "--branchKey", "feature-a", + ]); + + expect(result.exitCode).toBe(0); + expect(mockBranchExportImportCommandService.exportBranch).toHaveBeenCalledWith("my-package", "feature-a", { + zip: false, + gitEnabled: false, + jsonResponse: false, + }); + }); + + it("forwards --zip and --json for a local export", async () => { + await runCli([ + "config", "branch", "export", + "--packageKey", "my-package", + "--branchKey", "feature-a", + "--zip", + "--json", + ]); + + expect(mockBranchExportImportCommandService.exportBranch).toHaveBeenCalledWith("my-package", "feature-a", { + zip: true, + gitEnabled: false, + jsonResponse: true, + }); + }); + + it("switches to Git mode only when --gitProfile is passed", async () => { + await runCli([ + "config", "branch", "export", + "--packageKey", "my-package", + "--branchKey", "feature-a", + "--gitProfile", "myGitProfile", + ]); + + expect(mockBranchExportImportCommandService.exportBranch).toHaveBeenCalledWith("my-package", "feature-a", { + zip: false, + gitEnabled: true, + jsonResponse: false, + }); + }); + + it("calls exportAll for --all with a Git profile", async () => { + const result = await runCli([ + "config", "branch", "export", + "--packageKey", "my-package", + "--all", + "--gitProfile", "myGitProfile", + ]); + + expect(result.exitCode).toBe(0); + expect(mockBranchExportImportCommandService.exportAll).toHaveBeenCalledWith("my-package", false); + expect(mockBranchExportImportCommandService.exportBranch).not.toHaveBeenCalled(); + }); + + it("forwards --json to exportAll", async () => { + await runCli([ + "config", "branch", "export", + "--packageKey", "my-package", + "--all", + "--gitProfile", "myGitProfile", + "--json", + ]); + + expect(mockBranchExportImportCommandService.exportAll).toHaveBeenCalledWith("my-package", true); + }); + + it("rejects --all combined with --branchKey", async () => { + await runCli([ + "config", "branch", "export", + "--packageKey", "my-package", + "--branchKey", "feature-a", + "--all", + "--gitProfile", "myGitProfile", + ]); + + expectError("Please provide either --branchKey or --all, but not both."); + expect(mockBranchExportImportCommandService.exportAll).not.toHaveBeenCalled(); + expect(mockBranchExportImportCommandService.exportBranch).not.toHaveBeenCalled(); + }); + + it("rejects --all without --gitProfile, since it can only push to Git", async () => { + await runCli([ + "config", "branch", "export", + "--packageKey", "my-package", + "--all", + ]); + + expectError("--all pushes to Git and requires a Git profile. Please provide --gitProfile."); + expect(mockBranchExportImportCommandService.exportAll).not.toHaveBeenCalled(); + }); + + it("rejects when neither --branchKey nor --all is provided", async () => { + await runCli(["config", "branch", "export", "--packageKey", "my-package"]); + + expectError("Please provide --branchKey, or use --all to export every branch."); + expect(mockBranchExportImportCommandService.exportBranch).not.toHaveBeenCalled(); + }); + }); + + describe("config branch import (importBranch)", () => { + it("imports from a --directory without touching Git", async () => { + const result = await runCli([ + "config", "branch", "import", + "--packageKey", "my-package", + "--branchKey", "feature-a", + "--directory", "./feature-a-dir", + ]); + + expect(result.exitCode).toBe(0); + expect(mockBranchExportImportCommandService.importBranch).toHaveBeenCalledWith("my-package", "feature-a", { + file: undefined, + directory: "./feature-a-dir", + overwrite: false, + gitEnabled: false, + jsonResponse: false, + }); + }); + + it("forwards --file, --overwrite and --json", async () => { + await runCli([ + "config", "branch", "import", + "--packageKey", "my-package", + "--branchKey", "feature-a", + "--file", "feature-a.zip", + "--overwrite", + "--json", + ]); + + expect(mockBranchExportImportCommandService.importBranch).toHaveBeenCalledWith("my-package", "feature-a", { + file: "feature-a.zip", + directory: undefined, + overwrite: true, + gitEnabled: false, + jsonResponse: true, + }); + }); + + it("switches to Git mode only when --gitProfile is passed", async () => { + await runCli([ + "config", "branch", "import", + "--packageKey", "my-package", + "--branchKey", "feature-a", + "--gitProfile", "myGitProfile", + ]); + + expect(mockBranchExportImportCommandService.importBranch).toHaveBeenCalledWith("my-package", "feature-a", { + file: undefined, + directory: undefined, + overwrite: false, + gitEnabled: true, + jsonResponse: false, + }); + }); + + it("rejects --gitProfile combined with --file", async () => { + await runCli([ + "config", "branch", "import", + "--packageKey", "my-package", + "--branchKey", "feature-a", + "--file", "feature-a.zip", + "--gitProfile", "myGitProfile", + ]); + + expectError("You cannot use --file or --directory together with --gitProfile. Only one import source can be defined."); + expect(mockBranchExportImportCommandService.importBranch).not.toHaveBeenCalled(); + }); + + it("rejects --gitProfile combined with --directory", async () => { + await runCli([ + "config", "branch", "import", + "--packageKey", "my-package", + "--branchKey", "feature-a", + "--directory", "./feature-a-dir", + "--gitProfile", "myGitProfile", + ]); + + expectError("You cannot use --file or --directory together with --gitProfile. Only one import source can be defined."); + expect(mockBranchExportImportCommandService.importBranch).not.toHaveBeenCalled(); + }); + + it("rejects --file combined with --directory", async () => { + await runCli([ + "config", "branch", "import", + "--packageKey", "my-package", + "--branchKey", "feature-a", + "--file", "feature-a.zip", + "--directory", "./feature-a-dir", + ]); + + expectError("You cannot use both --file and --directory options at the same time. Only one import source can be defined."); + expect(mockBranchExportImportCommandService.importBranch).not.toHaveBeenCalled(); + }); + + it("rejects when no import source is provided at all", async () => { + await runCli([ + "config", "branch", "import", + "--packageKey", "my-package", + "--branchKey", "feature-a", + ]); + + expectError("You must provide a --file, a --directory, or a --gitProfile option to import a branch."); + expect(mockBranchExportImportCommandService.importBranch).not.toHaveBeenCalled(); + }); + }); + describe("config export (deprecated batchExportPackages)", () => { it("rejects when both --packageKeys and --keysByVersion are provided", async () => { await runCli([ diff --git a/tests/jest.setup.ts b/tests/jest.setup.ts index 35f4797b..3bb469d9 100644 --- a/tests/jest.setup.ts +++ b/tests/jest.setup.ts @@ -29,6 +29,7 @@ afterEach(() => { }); afterAll(() => { + jest.restoreAllMocks(); if (tempDir !== null) { logger.info(`Removing tempdir: ${tempDir}`); rmTempDir(tempDir); diff --git a/tests/utls/fs-mock-utils.ts b/tests/utls/fs-mock-utils.ts index 254324dc..4644825e 100644 --- a/tests/utls/fs-mock-utils.ts +++ b/tests/utls/fs-mock-utils.ts @@ -1,5 +1,12 @@ import fs = require("node:fs"); +let readdirSyncSpy: jest.SpyInstance | undefined; + export function mockReadDirSync(data: any): void { - jest.spyOn(fs, "readdirSync").mockReturnValue(data); + readdirSyncSpy = jest.spyOn(fs, "readdirSync").mockReturnValue(data); } + +afterEach(() => { + readdirSyncSpy?.mockRestore(); + readdirSyncSpy = undefined; +}); From 83808f0293fd937e49c025e7dffbae7ef40bb63d Mon Sep 17 00:00:00 2001 From: Laberion Ajvazi Date: Tue, 28 Jul 2026 16:23:51 +0200 Subject: [PATCH 5/8] SP-1472: Honour --json on all export paths and consolidate BranchUtils Local exports and per-branch progress on --all ignored --json, so a JSON summary was only produced when pushing to Git. Report through a single-purpose writeJson helper and let each call site choose, matching the existing branch command service. Fold the new branch-key helpers into the existing core BranchUtils rather than shipping a second class under the same name. Includes-AI-Code: true --- .../branch-export-import.command.service.ts | 79 +++++++++++++------ .../branch/branch-utils.ts | 27 ------- src/core/utils/branches.ts | 13 +++ .../branch/branch-utils.spec.ts | 56 ------------- .../config-branch-export-import.spec.ts | 57 ++++++++++++- tests/core/utils/branches.spec.ts | 24 ++++++ 6 files changed, 146 insertions(+), 110 deletions(-) delete mode 100644 src/commands/configuration-management/branch/branch-utils.ts delete mode 100644 tests/commands/configuration-management/branch/branch-utils.spec.ts diff --git a/src/commands/configuration-management/branch/branch-export-import.command.service.ts b/src/commands/configuration-management/branch/branch-export-import.command.service.ts index c6c6e716..5b99a882 100644 --- a/src/commands/configuration-management/branch/branch-export-import.command.service.ts +++ b/src/commands/configuration-management/branch/branch-export-import.command.service.ts @@ -13,7 +13,7 @@ import { GitService } from "../../../core/git-profile/git/git.service"; import { SinglePackageExportApi } from "../api/single-package-export-api"; import { SinglePackageImportApi } from "../api/single-package-import-api"; import { BranchApi } from "./api/branch.api"; -import { BranchUtils } from "./branch-utils"; +import { BranchUtils } from "../../../core/utils/branches"; import { BranchSyncSummary, BranchTransport } from "./interfaces/branch.interfaces"; const PACKAGE_FILE = "package.json"; @@ -48,16 +48,27 @@ export class BranchExportImportCommandService { } public async exportBranch(packageKey: string, branchKey: string, options: BranchExportOptions = {}): Promise { + const jsonResponse = !!options.jsonResponse; + + if (options.gitEnabled) { + const pushedKey = await this.pushBranchToGit(packageKey, branchKey); + if (jsonResponse) { + BranchExportImportCommandService.writeJson({ packageKey: pushedKey, branchName: branchKey }); + } else { + logger.info(`Exported ${pushedKey} to Git branch '${branchKey}'.`); + } + return; + } + const branchPackageKey = BranchUtils.constructBranchKey(packageKey, branchKey); const sourceDir = await this.exportRewrittenPackageDir(branchPackageKey); try { - if (options.gitEnabled) { - await this.gitService.pushToBranch(sourceDir, branchKey); - const summary: BranchSyncSummary = { packageKey: branchPackageKey, branchName: branchKey }; - this.report(summary, !!options.jsonResponse, `Exported ${branchPackageKey} to Git branch '${branchKey}'.`); - return; + const message = this.writeLocalArtifact(sourceDir, packageKey, !!options.zip); + if (jsonResponse) { + BranchExportImportCommandService.writeJson({ packageKey: branchPackageKey, branchName: branchKey }); + } else { + logger.info(message); } - this.writeLocalArtifact(sourceDir, packageKey, !!options.zip); } finally { this.removeDir(sourceDir); } @@ -74,12 +85,19 @@ export class BranchExportImportCommandService { // branch suffix. It is already pushed above, and it has no '@' // package to export, so exporting it as a branch would fail. for (const branch of branches.filter(entry => BranchUtils.isBranchPackageKey(entry.packageKey))) { - await this.exportBranch(mainKey, branch.branchKey, { gitEnabled: true }); - synced.push(branch.packageKey); + const pushedKey = await this.pushBranchToGit(mainKey, branch.branchKey); + synced.push(pushedKey); + if (!jsonResponse) { + logger.info(`Exported ${pushedKey} to Git branch '${branch.branchKey}'.`); + } } const summary: BranchSyncSummary = { packageKey: mainKey, branchName: BranchUtils.MAIN_BRANCH_KEY, synced }; - this.report(summary, jsonResponse, `Exported Git mirror for ${mainKey}: ${synced.length} package(s) pushed.`); + if (jsonResponse) { + BranchExportImportCommandService.writeJson(summary); + } else { + logger.info(`Exported Git mirror for ${mainKey}: ${synced.length} package(s) pushed.`); + } } public async importBranch(packageKey: string, branchKey: string, options: BranchImportOptions = {}): Promise { @@ -92,14 +110,28 @@ export class BranchExportImportCommandService { this.replacePackageKey(workingDir, packageKey, branchPackageKey); await this.importPackageSourceDir(workingDir, !!options.overwrite); - const summary: BranchSyncSummary = { packageKey: branchPackageKey, branchName: branchKey }; - const origin = options.gitEnabled ? `Git branch '${branchKey}'` : (options.file ?? options.directory); - this.report(summary, !!options.jsonResponse, `Imported ${origin} into ${branchPackageKey}.`); + if (options.jsonResponse) { + BranchExportImportCommandService.writeJson({ packageKey: branchPackageKey, branchName: branchKey }); + } else { + const origin = options.gitEnabled ? `Git branch '${branchKey}'` : (options.file ?? options.directory); + logger.info(`Imported ${origin} into ${branchPackageKey}.`); + } } finally { this.removeDir(workingDir); } } + private async pushBranchToGit(mainKey: string, branchKey: string): Promise { + const branchPackageKey = BranchUtils.constructBranchKey(mainKey, branchKey); + const sourceDir = await this.exportRewrittenPackageDir(branchPackageKey); + try { + await this.gitService.pushToBranch(sourceDir, branchKey); + return branchPackageKey; + } finally { + this.removeDir(sourceDir); + } + } + private async pushMainPackage(mainKey: string): Promise { const packageData = await this.singlePackageExportApi.exportPackage(mainKey); const extractedDir = fileService.extractZipBufferToTempDirectory(packageData); @@ -118,22 +150,21 @@ export class BranchExportImportCommandService { return extractedDir; } - private writeLocalArtifact(sourceDir: string, packageKey: string, zip: boolean): void { + private writeLocalArtifact(sourceDir: string, packageKey: string, zip: boolean): string { if (zip) { const zipPath = fileService.zipDirectoryAsSinglePackage(sourceDir); try { const fileName = `${packageKey}.zip`; fileService.writeBufferToFileWithGivenName(fs.readFileSync(zipPath), resolve(process.cwd(), fileName)); - logger.info(FileService.fileDownloadedMessage + fileName); + return FileService.fileDownloadedMessage + fileName; } finally { fs.rmSync(zipPath, { force: true }); } - return; } const targetDir = resolve(process.cwd(), packageKey); fs.rmSync(targetDir, { recursive: true, force: true }); fs.cpSync(sourceDir, targetDir, { recursive: true }); - logger.info(`Successful export. Exported directory: ${packageKey}`); + return `Successful export. Exported directory: ${packageKey}`; } private prepareLocalWorkingDir(file: string | undefined, directory: string | undefined): string { @@ -187,7 +218,7 @@ export class BranchExportImportCommandService { } private replaceFieldValue(content: string, field: string, from: string, to: string): string { - const pattern = new RegExp(`("${this.escapeRegExp(field)}"\\s*:\\s*)"${this.escapeRegExp(from)}"`); + const pattern = new RegExp(String.raw`("${this.escapeRegExp(field)}"\s*:\s*)"${this.escapeRegExp(from)}"`); return content.replace(pattern, `$1"${to}"`); } @@ -217,13 +248,9 @@ export class BranchExportImportCommandService { fs.rmSync(dir, { recursive: true, force: true }); } - private report(summary: BranchSyncSummary, jsonResponse: boolean, message: string): void { - if (jsonResponse) { - const filename = `${uuidv4()}.json`; - fileService.writeToFileWithGivenName(JSON.stringify(summary, null, 2), filename); - logger.info(FileService.fileDownloadedMessage + filename); - } else { - logger.info(message); - } + private static writeJson(payload: unknown): void { + const filename = `${uuidv4()}.json`; + fileService.writeToFileWithGivenName(JSON.stringify(payload, null, 2), filename); + logger.info(FileService.fileDownloadedMessage + filename); } } diff --git a/src/commands/configuration-management/branch/branch-utils.ts b/src/commands/configuration-management/branch/branch-utils.ts deleted file mode 100644 index 347e83ee..00000000 --- a/src/commands/configuration-management/branch/branch-utils.ts +++ /dev/null @@ -1,27 +0,0 @@ -export class BranchUtils { - - public static readonly MAIN_BRANCH_KEY = "main"; - private static readonly BRANCH_SEPARATOR = "@"; - - public static isBranchPackageKey(packageKey: string): boolean { - return !!packageKey && packageKey.indexOf(BranchUtils.BRANCH_SEPARATOR) >= 0; - } - - public static constructBranchKey(projectKey: string, branchKey: string): string { - return `${projectKey}${BranchUtils.BRANCH_SEPARATOR}${branchKey}`; - } - - public static extractProjectKey(packageKey: string): string { - if (!BranchUtils.isBranchPackageKey(packageKey)) { - return packageKey; - } - return packageKey.substring(0, packageKey.indexOf(BranchUtils.BRANCH_SEPARATOR)); - } - - public static extractBranchKey(packageKey: string): string | null { - if (!BranchUtils.isBranchPackageKey(packageKey)) { - return null; - } - return packageKey.substring(packageKey.indexOf(BranchUtils.BRANCH_SEPARATOR) + 1); - } -} diff --git a/src/core/utils/branches.ts b/src/core/utils/branches.ts index d205291b..a5c125b7 100644 --- a/src/core/utils/branches.ts +++ b/src/core/utils/branches.ts @@ -1,4 +1,5 @@ export class BranchUtils { + public static readonly MAIN_BRANCH_KEY = "main"; private static readonly BRANCH_SEPARATOR = "@"; public static isBranchPackageKey(packageKey: string): boolean { @@ -8,4 +9,16 @@ export class BranchUtils { return packageKey.includes(BranchUtils.BRANCH_SEPARATOR); } + + public static constructBranchKey(projectKey: string, branchKey: string): string { + return `${projectKey}${BranchUtils.BRANCH_SEPARATOR}${branchKey}`; + } + + public static extractProjectKey(packageKey: string): string { + if (!BranchUtils.isBranchPackageKey(packageKey)) { + return packageKey; + } + + return packageKey.substring(0, packageKey.indexOf(BranchUtils.BRANCH_SEPARATOR)); + } } \ No newline at end of file diff --git a/tests/commands/configuration-management/branch/branch-utils.spec.ts b/tests/commands/configuration-management/branch/branch-utils.spec.ts deleted file mode 100644 index 7e6dd0aa..00000000 --- a/tests/commands/configuration-management/branch/branch-utils.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { BranchUtils } from "../../../../src/commands/configuration-management/branch/branch-utils"; - -describe("BranchUtils", () => { - - describe("isBranchPackageKey", () => { - it("returns true when the key contains '@'", () => { - expect(BranchUtils.isBranchPackageKey("main@branch-123")).toBe(true); - }); - - it("returns false when the key has no '@'", () => { - expect(BranchUtils.isBranchPackageKey("my-main-package")).toBe(false); - }); - - it("returns false for an empty key", () => { - expect(BranchUtils.isBranchPackageKey("")).toBe(false); - }); - }); - - describe("constructBranchKey", () => { - it("appends the branch key to the project key with '@'", () => { - expect(BranchUtils.constructBranchKey("main-pkg", "feat-1")).toEqual("main-pkg@feat-1"); - }); - }); - - describe("extractProjectKey", () => { - it("returns the same key for a main package", () => { - expect(BranchUtils.extractProjectKey("main-pkg")).toEqual("main-pkg"); - }); - - it("returns the project key for a branch package", () => { - expect(BranchUtils.extractProjectKey("main-pkg@feat-1")).toEqual("main-pkg"); - }); - - it("splits on the first '@'", () => { - expect(BranchUtils.extractProjectKey("main-pkg@team@feat")).toEqual("main-pkg"); - }); - }); - - describe("extractBranchKey", () => { - it("returns the branch suffix for a branch package", () => { - expect(BranchUtils.extractBranchKey("main-pkg@feat-1")).toEqual("feat-1"); - }); - - it("keeps any later '@' in the branch suffix", () => { - expect(BranchUtils.extractBranchKey("main-pkg@team@feat")).toEqual("team@feat"); - }); - - it("returns null for a main package", () => { - expect(BranchUtils.extractBranchKey("main-pkg")).toBeNull(); - }); - - it("returns null for an empty key", () => { - expect(BranchUtils.extractBranchKey("")).toBeNull(); - }); - }); -}); diff --git a/tests/commands/configuration-management/branch/config-branch-export-import.spec.ts b/tests/commands/configuration-management/branch/config-branch-export-import.spec.ts index 0708a030..0ec087ef 100644 --- a/tests/commands/configuration-management/branch/config-branch-export-import.spec.ts +++ b/tests/commands/configuration-management/branch/config-branch-export-import.spec.ts @@ -8,7 +8,7 @@ import { testContext } from "../../../utls/test-context"; import { loggingTestTransport } from "../../../jest.setup"; import { FileService, fileService } from "../../../../src/core/utils/file-service"; import { GitService } from "../../../../src/core/git-profile/git/git.service"; -import { BranchUtils } from "../../../../src/commands/configuration-management/branch/branch-utils"; +import { BranchUtils } from "../../../../src/core/utils/branches"; jest.unmock("fs"); jest.unmock("node:fs"); @@ -174,6 +174,22 @@ describe("config branch export/import", () => { expect(pushSpy).not.toHaveBeenCalled(); expect(fs.existsSync(path.join(cwd, `${MAIN_KEY}.zip`))).toBe(true); }); + + it("writes a JSON summary instead of logging when jsonResponse=true", async () => { + arrangeExport(BRANCH_KEY); + jest.spyOn(GitService.prototype, "pushToBranch").mockResolvedValue(); + const cwd = useTempCwd(); + const writeSpy = jest.spyOn(fileService, "writeToFileWithGivenName").mockImplementation(() => undefined); + + await new BranchExportImportCommandService(testContext).exportBranch(MAIN_KEY, BRANCH, { jsonResponse: true }); + + expect(fs.existsSync(path.join(cwd, MAIN_KEY))).toBe(true); + expect(writeSpy).toHaveBeenCalledTimes(1); + const summary = JSON.parse(writeSpy.mock.calls[0][0] as string); + expect(summary.packageKey).toEqual(BRANCH_KEY); + expect(summary.branchName).toEqual(BRANCH); + expect(loggingTestTransport.logMessages.some(m => m.message.includes(`Exported directory: ${MAIN_KEY}`))).toBe(false); + }); }); describe("export --all", () => { @@ -221,6 +237,45 @@ describe("config branch export/import", () => { expect(pushSpy.mock.calls.map(call => call[1]).sort()).toEqual([BRANCH, BranchUtils.MAIN_BRANCH_KEY]); expect(loggingTestTransport.logMessages.some(m => m.message.includes(`Exported Git mirror for ${MAIN_KEY}: 2 package(s) pushed`))).toBe(true); }); + + it("writes only the summary file and no per-branch logs when jsonResponse=true", async () => { + mockAxiosGet(branchesUrl(MAIN_KEY), [ + { packageKey: BRANCH_KEY, branchKey: BRANCH, projectKey: MAIN_KEY, sourcePackageKey: MAIN_KEY, sourceVersion: "1.0.0" }, + ]); + mockAxiosGet(exportUrl(MAIN_KEY), Buffer.from("zip")); + mockAxiosGet(exportUrl(BRANCH_KEY), Buffer.from("zip")); + jest.spyOn(FileService.prototype, "extractZipBufferToTempDirectory").mockImplementation(() => { + const dir = makeTempDir(); + seedPackageDir(dir, MAIN_KEY); + return dir; + }); + jest.spyOn(GitService.prototype, "pushToBranch").mockResolvedValue(); + const writeSpy = jest.spyOn(fileService, "writeToFileWithGivenName").mockImplementation(() => undefined); + + await new BranchExportImportCommandService(testContext).exportAll(MAIN_KEY, true); + + expect(writeSpy).toHaveBeenCalledTimes(1); + expect(JSON.parse(writeSpy.mock.calls[0][0] as string).synced).toEqual([MAIN_KEY, BRANCH_KEY]); + expect(loggingTestTransport.logMessages.some(m => m.message.includes(`to Git branch '${BRANCH}'`))).toBe(false); + }); + + it("logs per-branch progress when jsonResponse is not set", async () => { + mockAxiosGet(branchesUrl(MAIN_KEY), [ + { packageKey: BRANCH_KEY, branchKey: BRANCH, projectKey: MAIN_KEY, sourcePackageKey: MAIN_KEY, sourceVersion: "1.0.0" }, + ]); + mockAxiosGet(exportUrl(MAIN_KEY), Buffer.from("zip")); + mockAxiosGet(exportUrl(BRANCH_KEY), Buffer.from("zip")); + jest.spyOn(FileService.prototype, "extractZipBufferToTempDirectory").mockImplementation(() => { + const dir = makeTempDir(); + seedPackageDir(dir, MAIN_KEY); + return dir; + }); + jest.spyOn(GitService.prototype, "pushToBranch").mockResolvedValue(); + + await new BranchExportImportCommandService(testContext).exportAll(MAIN_KEY); + + expect(loggingTestTransport.logMessages.some(m => m.message.includes(`Exported ${BRANCH_KEY} to Git branch '${BRANCH}'`))).toBe(true); + }); }); describe("import from Git", () => { diff --git a/tests/core/utils/branches.spec.ts b/tests/core/utils/branches.spec.ts index bfff629a..25697c8e 100644 --- a/tests/core/utils/branches.spec.ts +++ b/tests/core/utils/branches.spec.ts @@ -22,4 +22,28 @@ describe("BranchUtils", () => { expect(() => BranchUtils.isBranchPackageKey(null)).toThrow("Package key cannot be empty"); }); }); + + describe("constructBranchKey", () => { + it("should append the branch key to the project key with the separator", () => { + expect(BranchUtils.constructBranchKey("main-pkg", "feat-1")).toEqual("main-pkg@feat-1"); + }); + }); + + describe("extractProjectKey", () => { + it("should return the same key for a main package", () => { + expect(BranchUtils.extractProjectKey("main-pkg")).toEqual("main-pkg"); + }); + + it("should return the project key for a branch package", () => { + expect(BranchUtils.extractProjectKey("main-pkg@feat-1")).toEqual("main-pkg"); + }); + + it("should split on the first separator", () => { + expect(BranchUtils.extractProjectKey("main-pkg@team@feat")).toEqual("main-pkg"); + }); + + it("should throw an error if the input is empty", () => { + expect(() => BranchUtils.extractProjectKey("")).toThrow("Package key cannot be empty"); + }); + }); }); From 4cc44f06ec2d6ac56a99bf494cbc6d5a6f6c8c5a Mon Sep 17 00:00:00 2001 From: Laberion Ajvazi Date: Tue, 28 Jul 2026 16:42:52 +0200 Subject: [PATCH 6/8] SP-1472: Reject branch imports whose source names another package The import endpoint derives the target package solely from package.json#key, so silently skipping the key rewrite let a mismatched source create or overwrite an unrelated package while the CLI reported success on the intended branch. Validate the source key before upload, accepting either the project key or the branch key, and fail when the rewrite cannot be applied. Includes-AI-Code: true --- .../branch-export-import.command.service.ts | 38 +++++++++++++++-- .../config-branch-export-import.spec.ts | 41 +++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/commands/configuration-management/branch/branch-export-import.command.service.ts b/src/commands/configuration-management/branch/branch-export-import.command.service.ts index 5b99a882..adbd70fa 100644 --- a/src/commands/configuration-management/branch/branch-export-import.command.service.ts +++ b/src/commands/configuration-management/branch/branch-export-import.command.service.ts @@ -107,7 +107,7 @@ export class BranchExportImportCommandService { ? await this.gitService.pullFromBranch(branchKey) : this.prepareLocalWorkingDir(options.file, options.directory); try { - this.replacePackageKey(workingDir, packageKey, branchPackageKey); + this.restoreBranchKey(workingDir, packageKey, branchPackageKey); await this.importPackageSourceDir(workingDir, !!options.overwrite); if (options.jsonResponse) { @@ -146,7 +146,7 @@ export class BranchExportImportCommandService { const packageData = await this.singlePackageExportApi.exportPackage(branchPackageKey); const extractedDir = fileService.extractZipBufferToTempDirectory(packageData); const projectKey = BranchUtils.extractProjectKey(branchPackageKey); - this.replacePackageKey(extractedDir, branchPackageKey, projectKey); + this.normalizeToProjectKey(extractedDir, branchPackageKey, projectKey); return extractedDir; } @@ -198,7 +198,10 @@ export class BranchExportImportCommandService { } } - private replacePackageKey(dir: string, from: string, to: string): void { + // The export source is a fresh Pacman export of the branch package, so a key that does not + // match is a no-op rather than a failure: the worst case is a mirror diff that still carries + // the branch suffix, and nothing outside the local working copy is touched. + private normalizeToProjectKey(dir: string, from: string, to: string): void { if (from === to) { return; } @@ -217,6 +220,35 @@ export class BranchExportImportCommandService { } } + // The import source is caller-supplied and the import endpoint derives the target package + // solely from package.json#key, so a key that is neither the project key nor the branch key + // must abort rather than silently import into whatever package the source names. + private restoreBranchKey(dir: string, projectKey: string, branchPackageKey: string): void { + const filePath = path.join(dir, PACKAGE_FILE); + if (!fs.existsSync(filePath)) { + throw new FatalError(`The import source does not contain a ${PACKAGE_FILE} file.`); + } + + const raw = fs.readFileSync(filePath, { encoding: "utf-8" }); + const sourceKey = JSON.parse(raw).key; + if (sourceKey === branchPackageKey) { + return; + } + if (sourceKey !== projectKey) { + throw new FatalError( + `The import source declares package key '${sourceKey}', but importing into '${branchPackageKey}' requires '${projectKey}' or '${branchPackageKey}'. Nothing was imported.` + ); + } + + const updated = this.replaceFieldValue(raw, "key", projectKey, branchPackageKey); + if (updated === raw) { + throw new FatalError( + `Could not rewrite the package key in ${PACKAGE_FILE} from '${projectKey}' to '${branchPackageKey}'. Nothing was imported.` + ); + } + fs.writeFileSync(filePath, updated, { encoding: "utf-8" }); + } + private replaceFieldValue(content: string, field: string, from: string, to: string): string { const pattern = new RegExp(String.raw`("${this.escapeRegExp(field)}"\s*:\s*)"${this.escapeRegExp(from)}"`); return content.replace(pattern, `$1"${to}"`); diff --git a/tests/commands/configuration-management/branch/config-branch-export-import.spec.ts b/tests/commands/configuration-management/branch/config-branch-export-import.spec.ts index 0ec087ef..282cd07b 100644 --- a/tests/commands/configuration-management/branch/config-branch-export-import.spec.ts +++ b/tests/commands/configuration-management/branch/config-branch-export-import.spec.ts @@ -322,5 +322,46 @@ describe("config branch export/import", () => { expect(mockedPostRequestBodyByUrl.get(IMPORT_URL)).toBeDefined(); expect(loggingTestTransport.logMessages.some(m => m.message.includes(`into ${BRANCH_KEY}`))).toBe(true); }); + + it("imports a source that already carries the branch key without rewriting it", async () => { + const localDir = makeTempDir(); + seedPackageDir(localDir, BRANCH_KEY); + mockAxiosPost(IMPORT_URL, { importedPackage: { key: BRANCH_KEY, name: "My Package" }, importedNodes: [] }); + + let importedPkgKey: string | undefined; + const realZip = fileService.zipDirectoryAsSinglePackage.bind(fileService); + jest.spyOn(fileService, "zipDirectoryAsSinglePackage").mockImplementation((dir: string) => { + importedPkgKey = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf-8")).key; + return realZip(dir); + }); + + await new BranchExportImportCommandService(testContext).importBranch(MAIN_KEY, BRANCH, { directory: localDir }); + + expect(importedPkgKey).toEqual(BRANCH_KEY); + expect(mockedPostRequestBodyByUrl.get(IMPORT_URL)).toBeDefined(); + }); + + it("refuses to import a source whose package key belongs to an unrelated package", async () => { + const localDir = makeTempDir(); + seedPackageDir(localDir, "some-other-package"); + mockAxiosPost(IMPORT_URL, { importedPackage: { key: BRANCH_KEY, name: "My Package" }, importedNodes: [] }); + + await expect( + new BranchExportImportCommandService(testContext).importBranch(MAIN_KEY, BRANCH, { directory: localDir }) + ).rejects.toThrow(/declares package key 'some-other-package'/); + + expect(mockedPostRequestBodyByUrl.get(IMPORT_URL)).toBeUndefined(); + }); + + it("refuses to import a source with no package.json", async () => { + const localDir = makeTempDir(); + mockAxiosPost(IMPORT_URL, { importedPackage: { key: BRANCH_KEY, name: "My Package" }, importedNodes: [] }); + + await expect( + new BranchExportImportCommandService(testContext).importBranch(MAIN_KEY, BRANCH, { directory: localDir }) + ).rejects.toThrow(/does not contain a package.json file/); + + expect(mockedPostRequestBodyByUrl.get(IMPORT_URL)).toBeUndefined(); + }); }); }); From 55babe55a4e3d37ec6f09533c33556d66a8144a9 Mon Sep 17 00:00:00 2001 From: Laberion Ajvazi Date: Tue, 28 Jul 2026 16:56:11 +0200 Subject: [PATCH 7/8] SP-1472: Reject 'main' as a branch key for export and import Exporting or importing --branchKey main resolves to '@main', which Pacman never creates because the key is reserved, so the command only failed once the backend returned a 404. Reject it locally with a message pointing at --all, and skip any branch keyed 'main' in --all so it cannot push over the main package's mirror. Includes-AI-Code: true --- .../branch-export-import.command.service.ts | 10 ++++++-- .../configuration-management/module.ts | 7 ++++++ .../config-branch-export-import.spec.ts | 23 +++++++++++++++++++ .../commands/configuration-management.spec.ts | 19 +++++++++++++++ 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/commands/configuration-management/branch/branch-export-import.command.service.ts b/src/commands/configuration-management/branch/branch-export-import.command.service.ts index adbd70fa..6232dff8 100644 --- a/src/commands/configuration-management/branch/branch-export-import.command.service.ts +++ b/src/commands/configuration-management/branch/branch-export-import.command.service.ts @@ -83,8 +83,9 @@ export class BranchExportImportCommandService { synced.push(mainKey); // listBranches also reports the main package itself, whose packageKey carries no // branch suffix. It is already pushed above, and it has no '@' - // package to export, so exporting it as a branch would fail. - for (const branch of branches.filter(entry => BranchUtils.isBranchPackageKey(entry.packageKey))) { + // package to export, so exporting it as a branch would fail. A branch keyed 'main' + // is skipped as well: it would push over the main package's mirror above. + for (const branch of branches.filter(entry => BranchExportImportCommandService.isExportableBranch(entry))) { const pushedKey = await this.pushBranchToGit(mainKey, branch.branchKey); synced.push(pushedKey); if (!jsonResponse) { @@ -280,6 +281,11 @@ export class BranchExportImportCommandService { fs.rmSync(dir, { recursive: true, force: true }); } + private static isExportableBranch(branch: BranchTransport): boolean { + return BranchUtils.isBranchPackageKey(branch.packageKey) + && branch.branchKey?.toLowerCase() !== BranchUtils.MAIN_BRANCH_KEY; + } + private static writeJson(payload: unknown): void { const filename = `${uuidv4()}.json`; fileService.writeToFileWithGivenName(JSON.stringify(payload, null, 2), filename); diff --git a/src/commands/configuration-management/module.ts b/src/commands/configuration-management/module.ts index c4d4abce..4e7c66e5 100644 --- a/src/commands/configuration-management/module.ts +++ b/src/commands/configuration-management/module.ts @@ -19,6 +19,7 @@ import { SinglePackageImportService } from "./single-package-import.service"; import { SinglePackageExportService } from "./single-package-export.service"; import { BranchCommandService } from "./branch/branch.command.service"; import { BranchExportImportCommandService } from "./branch/branch-export-import.command.service"; +import { BranchUtils } from "../../core/utils/branches"; class Module extends IModule { @@ -374,6 +375,9 @@ class Module extends IModule { if (!options.branchKey) { throw new Error("Please provide --branchKey, or use --all to export every branch."); } + if (options.branchKey.toLowerCase() === BranchUtils.MAIN_BRANCH_KEY) { + throw new Error(`'${BranchUtils.MAIN_BRANCH_KEY}' is a reserved branch key. The main package is exported only with --all.`); + } await service.exportBranch(options.packageKey, options.branchKey, { zip: !!options.zip, gitEnabled, @@ -384,6 +388,9 @@ class Module extends IModule { private async importBranch(context: Context, command: Command, options: OptionValues): Promise { const gitEnabled = !!options.gitProfile; + if (options.branchKey.toLowerCase() === BranchUtils.MAIN_BRANCH_KEY) { + throw new Error(`'${BranchUtils.MAIN_BRANCH_KEY}' is a reserved branch key. Use 'config package import' to import into the main package.`); + } if (gitEnabled && (options.file || options.directory)) { throw new Error("You cannot use --file or --directory together with --gitProfile. Only one import source can be defined."); } diff --git a/tests/commands/configuration-management/branch/config-branch-export-import.spec.ts b/tests/commands/configuration-management/branch/config-branch-export-import.spec.ts index 282cd07b..87406f83 100644 --- a/tests/commands/configuration-management/branch/config-branch-export-import.spec.ts +++ b/tests/commands/configuration-management/branch/config-branch-export-import.spec.ts @@ -238,6 +238,29 @@ describe("config branch export/import", () => { expect(loggingTestTransport.logMessages.some(m => m.message.includes(`Exported Git mirror for ${MAIN_KEY}: 2 package(s) pushed`))).toBe(true); }); + it("skips a branch keyed 'main' so it cannot push over the main package's mirror", async () => { + // Pacman reserves 'main' as a branch key, so this should be unreachable. Guard it + // anyway: such an entry would push to Git branch 'main' and clobber the main package. + mockAxiosGet(branchesUrl(MAIN_KEY), [ + { packageKey: `${MAIN_KEY}@${BranchUtils.MAIN_BRANCH_KEY}`, branchKey: BranchUtils.MAIN_BRANCH_KEY, projectKey: MAIN_KEY, sourcePackageKey: MAIN_KEY, sourceVersion: "1.0.0" }, + { packageKey: BRANCH_KEY, branchKey: BRANCH, projectKey: MAIN_KEY, sourcePackageKey: MAIN_KEY, sourceVersion: "1.0.0" }, + ]); + mockAxiosGet(exportUrl(MAIN_KEY), Buffer.from("zip")); + mockAxiosGet(exportUrl(BRANCH_KEY), Buffer.from("zip")); + jest.spyOn(FileService.prototype, "extractZipBufferToTempDirectory").mockImplementation(() => { + const dir = makeTempDir(); + seedPackageDir(dir, MAIN_KEY); + return dir; + }); + const pushSpy = jest.spyOn(GitService.prototype, "pushToBranch").mockResolvedValue(); + + await new BranchExportImportCommandService(testContext).exportAll(MAIN_KEY); + + // Only the main package itself may occupy Git branch 'main'. + expect(pushSpy.mock.calls.map(call => call[1])).toEqual([BranchUtils.MAIN_BRANCH_KEY, BRANCH]); + expect(pushSpy).toHaveBeenCalledTimes(2); + }); + it("writes only the summary file and no per-branch logs when jsonResponse=true", async () => { mockAxiosGet(branchesUrl(MAIN_KEY), [ { packageKey: BRANCH_KEY, branchKey: BRANCH, projectKey: MAIN_KEY, sourcePackageKey: MAIN_KEY, sourceVersion: "1.0.0" }, diff --git a/tests/integration/commands/configuration-management.spec.ts b/tests/integration/commands/configuration-management.spec.ts index c538221a..c5a48ec7 100644 --- a/tests/integration/commands/configuration-management.spec.ts +++ b/tests/integration/commands/configuration-management.spec.ts @@ -455,6 +455,13 @@ describe("configuration-management command integration", () => { expectError("Please provide --branchKey, or use --all to export every branch."); expect(mockBranchExportImportCommandService.exportBranch).not.toHaveBeenCalled(); }); + + it.each(["main", "Main"])("rejects --branchKey %s as reserved", async branchKey => { + await runCli(["config", "branch", "export", "--packageKey", "my-package", "--branchKey", branchKey]); + + expectError("'main' is a reserved branch key. The main package is exported only with --all."); + expect(mockBranchExportImportCommandService.exportBranch).not.toHaveBeenCalled(); + }); }); describe("config branch import (importBranch)", () => { @@ -561,6 +568,18 @@ describe("configuration-management command integration", () => { expectError("You must provide a --file, a --directory, or a --gitProfile option to import a branch."); expect(mockBranchExportImportCommandService.importBranch).not.toHaveBeenCalled(); }); + + it.each(["main", "Main"])("rejects --branchKey %s as reserved", async branchKey => { + await runCli([ + "config", "branch", "import", + "--packageKey", "my-package", + "--branchKey", branchKey, + "--directory", "./some-dir", + ]); + + expectError("'main' is a reserved branch key. Use 'config package import' to import into the main package."); + expect(mockBranchExportImportCommandService.importBranch).not.toHaveBeenCalled(); + }); }); describe("config export (deprecated batchExportPackages)", () => { From 3e014adc4376882538cb7234736e5aa0e0de9e4c Mon Sep 17 00:00:00 2001 From: Laberion Ajvazi Date: Wed, 29 Jul 2026 13:20:01 +0200 Subject: [PATCH 8/8] SP-1472: Reuse the single package import zip helpers Drop the duplicated uncompressed-size limit, readable-stream helper and multipart assembly in favour of SinglePackageImportService, so the size limit and the "packageFile" contract have a single owner. Validate the user-supplied zip before extracting it rather than checking the archive we build afterwards, so an oversized source is rejected before it expands onto disk. Adds coverage for the --file import path, which had none. Includes-AI-Code: true --- .../branch-export-import.command.service.ts | 36 ++++-------- .../single-package-import.service.ts | 12 ++-- .../config-branch-export-import.spec.ts | 57 +++++++++++++++++++ 3 files changed, 73 insertions(+), 32 deletions(-) diff --git a/src/commands/configuration-management/branch/branch-export-import.command.service.ts b/src/commands/configuration-management/branch/branch-export-import.command.service.ts index 6232dff8..e70e4c37 100644 --- a/src/commands/configuration-management/branch/branch-export-import.command.service.ts +++ b/src/commands/configuration-management/branch/branch-export-import.command.service.ts @@ -3,8 +3,6 @@ import * as os from "node:os"; import * as path from "node:path"; import { v4 as uuidv4 } from "uuid"; import AdmZip = require("adm-zip"); -import * as FormData from "form-data"; -import { Readable } from "node:stream"; import { resolve } from "node:path"; import { Context } from "../../../core/command/cli-context"; import { fileService, FileService } from "../../../core/utils/file-service"; @@ -12,12 +10,12 @@ import { FatalError, logger } from "../../../core/utils/logger"; import { GitService } from "../../../core/git-profile/git/git.service"; import { SinglePackageExportApi } from "../api/single-package-export-api"; import { SinglePackageImportApi } from "../api/single-package-import-api"; +import { SinglePackageImportService } from "../single-package-import.service"; import { BranchApi } from "./api/branch.api"; import { BranchUtils } from "../../../core/utils/branches"; import { BranchSyncSummary, BranchTransport } from "./interfaces/branch.interfaces"; const PACKAGE_FILE = "package.json"; -const MAX_UNCOMPRESSED_ZIP_SIZE = 4 * 1024 * 1024 * 1024; export interface BranchExportOptions { zip?: boolean; @@ -182,17 +180,21 @@ export class BranchExportImportCommandService { fs.rmSync(workingDir, { recursive: true, force: true }); throw new FatalError("The --file option accepts only zip files."); } - new AdmZip(file).extractAllTo(workingDir, true); + const packageZip = new AdmZip(file); + try { + SinglePackageImportService.assertUncompressedSizeWithinLimit(packageZip, file); + packageZip.extractAllTo(workingDir, true); + } catch (error) { + fs.rmSync(workingDir, { recursive: true, force: true }); + throw error; + } return workingDir; } private async importPackageSourceDir(sourceDir: string, overwrite: boolean): Promise { const zipPath = fileService.zipDirectoryAsSinglePackage(sourceDir); try { - const packageZip = new AdmZip(zipPath); - this.assertUncompressedSizeWithinLimit(packageZip, zipPath); - const formData = new FormData(); - formData.append("packageFile", this.toReadable(packageZip), { filename: "package.zip" }); + const formData = SinglePackageImportService.buildBodyForImport(new AdmZip(zipPath), zipPath); await this.singlePackageImportApi.importPackage(formData, overwrite); } finally { fs.rmSync(zipPath, { force: true }); @@ -259,24 +261,6 @@ export class BranchExportImportCommandService { return value.replace(/[.*+?^${}()|[\]\\]/g, match => "\\" + match); } - private assertUncompressedSizeWithinLimit(packageZip: AdmZip, sourcePath: string): void { - const totalBytes = packageZip.getEntries().reduce((sum, entry) => sum + entry.header.size, 0); - if (totalBytes > MAX_UNCOMPRESSED_ZIP_SIZE) { - throw new FatalError( - `Failed to handle "${sourcePath}": uncompressed size ${(totalBytes / (1024 ** 3)).toFixed(2)} GB exceeds the 4 GB limit.` - ); - } - } - - private toReadable(packageZip: AdmZip): Readable { - return new Readable({ - read(): void { - this.push(packageZip.toBuffer()); - this.push(null); - }, - }); - } - private removeDir(dir: string): void { fs.rmSync(dir, { recursive: true, force: true }); } diff --git a/src/commands/configuration-management/single-package-import.service.ts b/src/commands/configuration-management/single-package-import.service.ts index 2b10eef0..fe32151e 100644 --- a/src/commands/configuration-management/single-package-import.service.ts +++ b/src/commands/configuration-management/single-package-import.service.ts @@ -42,7 +42,7 @@ export class SinglePackageImportService { try { const packageZip = new AdmZip(resolvedSource.zipPath); - const formData = this.buildBodyForImport(packageZip, resolvedSource.zipPath); + const formData = SinglePackageImportService.buildBodyForImport(packageZip, resolvedSource.zipPath); const result = await this.singlePackageImportApi.importPackage(formData, overwrite); this.outputResult(result, jsonResponse); } finally { @@ -73,15 +73,15 @@ export class SinglePackageImportService { return { zipPath: fileService.zipDirectoryAsSinglePackage(directory), isTemporary: true }; } - private buildBodyForImport(packageZip: AdmZip, sourcePath: string): FormData { - this.assertUncompressedSizeWithinLimit(packageZip, sourcePath); + public static buildBodyForImport(packageZip: AdmZip, sourcePath: string): FormData { + SinglePackageImportService.assertUncompressedSizeWithinLimit(packageZip, sourcePath); const formData = new FormData(); - formData.append("packageFile", this.getReadableStream(packageZip), { filename: "package.zip" }); + formData.append("packageFile", SinglePackageImportService.getReadableStream(packageZip), { filename: "package.zip" }); return formData; } - private assertUncompressedSizeWithinLimit(packageZip: AdmZip, sourcePath: string): void { + public static assertUncompressedSizeWithinLimit(packageZip: AdmZip, sourcePath: string): void { const totalUncompressedBytes = packageZip.getEntries().reduce((sum, entry) => sum + entry.header.size, 0); if (totalUncompressedBytes > SinglePackageImportService.MAX_UNCOMPRESSED_ZIP_SIZE) { throw new Error( @@ -90,7 +90,7 @@ export class SinglePackageImportService { } } - private getReadableStream(packageZip: AdmZip): Readable { + private static getReadableStream(packageZip: AdmZip): Readable { return new Readable({ read(): void { this.push(packageZip.toBuffer()); diff --git a/tests/commands/configuration-management/branch/config-branch-export-import.spec.ts b/tests/commands/configuration-management/branch/config-branch-export-import.spec.ts index 87406f83..b0ee4951 100644 --- a/tests/commands/configuration-management/branch/config-branch-export-import.spec.ts +++ b/tests/commands/configuration-management/branch/config-branch-export-import.spec.ts @@ -1,3 +1,9 @@ +import AdmZip = require("adm-zip"); + +jest.mock("adm-zip", () => { + const realAdmZip = jest.requireActual("adm-zip"); + return jest.fn((...args: any[]) => realAdmZip(...args)); +}); import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -34,6 +40,14 @@ function makeTempDir(): string { return dir; } +function zipDirToTempFile(sourceDir: string): string { + const zip = new AdmZip(); + zip.addLocalFolder(sourceDir); + const zipPath = path.join(os.tmpdir(), `cc-git-test-${uuid()}.zip`); + zip.writeZip(zipPath); + return zipPath; +} + function seedPackageDir(dir: string, packageKey: string): void { fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ key: packageKey, name: "My Package" }, null, 2)); fs.writeFileSync(path.join(dir, "variables.json"), JSON.stringify([{ key: "v1" }], null, 2)); @@ -386,5 +400,48 @@ describe("config branch export/import", () => { expect(mockedPostRequestBodyByUrl.get(IMPORT_URL)).toBeUndefined(); }); + + it("imports from a zip file, restoring the branch key", async () => { + const localDir = makeTempDir(); + seedPackageDir(localDir, MAIN_KEY); + const zipPath = zipDirToTempFile(localDir); + mockAxiosPost(IMPORT_URL, { importedPackage: { key: BRANCH_KEY, name: "My Package" }, importedNodes: [] }); + + let importedPkgKey: string | undefined; + const realZip = fileService.zipDirectoryAsSinglePackage.bind(fileService); + jest.spyOn(fileService, "zipDirectoryAsSinglePackage").mockImplementation((dir: string) => { + importedPkgKey = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf-8")).key; + return realZip(dir); + }); + + await new BranchExportImportCommandService(testContext).importBranch(MAIN_KEY, BRANCH, { file: zipPath }); + + expect(importedPkgKey).toEqual(BRANCH_KEY); + expect(mockedPostRequestBodyByUrl.get(IMPORT_URL)).toBeDefined(); + }); + + it("rejects an oversized zip before extracting it", async () => { + const localDir = makeTempDir(); + seedPackageDir(localDir, MAIN_KEY); + const zipPath = zipDirToTempFile(localDir); + mockAxiosPost(IMPORT_URL, { importedPackage: { key: BRANCH_KEY, name: "My Package" }, importedNodes: [] }); + + const extractAllTo = jest.fn(); + const FIVE_GB = 5 * 1024 * 1024 * 1024; + (AdmZip as unknown as jest.Mock).mockImplementationOnce((...args: any[]) => { + const instance = jest.requireActual("adm-zip")(...args); + instance.getEntries = () => [{ header: { size: FIVE_GB } }]; + instance.extractAllTo = extractAllTo; + return instance; + }); + + await expect( + new BranchExportImportCommandService(testContext).importBranch(MAIN_KEY, BRANCH, { file: zipPath }) + ).rejects.toThrow(/uncompressed size 5.00 GB exceeds the 4 GB limit/); + + // The guard has to run before the archive is expanded onto disk, not after. + expect(extractAllTo).not.toHaveBeenCalled(); + expect(mockedPostRequestBodyByUrl.get(IMPORT_URL)).toBeUndefined(); + }); }); });