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..e70e4c37 --- /dev/null +++ b/src/commands/configuration-management/branch/branch-export-import.command.service.ts @@ -0,0 +1,278 @@ +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 { 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 { 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"; + +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 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 { + const message = this.writeLocalArtifact(sourceDir, packageKey, !!options.zip); + if (jsonResponse) { + BranchExportImportCommandService.writeJson({ packageKey: branchPackageKey, branchName: branchKey }); + } else { + logger.info(message); + } + } 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. 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) { + logger.info(`Exported ${pushedKey} to Git branch '${branch.branchKey}'.`); + } + } + + const summary: BranchSyncSummary = { packageKey: mainKey, branchName: BranchUtils.MAIN_BRANCH_KEY, synced }; + 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 { + const branchPackageKey = BranchUtils.constructBranchKey(packageKey, branchKey); + + const workingDir = options.gitEnabled + ? await this.gitService.pullFromBranch(branchKey) + : this.prepareLocalWorkingDir(options.file, options.directory); + try { + this.restoreBranchKey(workingDir, packageKey, branchPackageKey); + await this.importPackageSourceDir(workingDir, !!options.overwrite); + + 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); + 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.normalizeToProjectKey(extractedDir, branchPackageKey, projectKey); + return extractedDir; + } + + 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)); + return FileService.fileDownloadedMessage + fileName; + } finally { + fs.rmSync(zipPath, { force: true }); + } + } + const targetDir = resolve(process.cwd(), packageKey); + fs.rmSync(targetDir, { recursive: true, force: true }); + fs.cpSync(sourceDir, targetDir, { recursive: true }); + return `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."); + } + 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 formData = SinglePackageImportService.buildBodyForImport(new AdmZip(zipPath), zipPath); + await this.singlePackageImportApi.importPackage(formData, overwrite); + } finally { + fs.rmSync(zipPath, { force: true }); + } + } + + // 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; + } + 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" }); + } + } + + // 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}"`); + } + + private escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, match => "\\" + match); + } + + private removeDir(dir: string): void { + 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); + logger.info(FileService.fileDownloadedMessage + filename); + } +} 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..4e7c66e5 100644 --- a/src/commands/configuration-management/module.ts +++ b/src/commands/configuration-management/module.ts @@ -18,6 +18,8 @@ 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"; +import { BranchUtils } from "../../core/utils/branches"; class Module extends IModule { @@ -82,6 +84,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 +357,59 @@ 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."); + } + 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, + jsonResponse: !!options.json, + }); + } + + 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."); + } + 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/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/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/config-branch-export-import.spec.ts b/tests/commands/configuration-management/branch/config-branch-export-import.spec.ts new file mode 100644 index 00000000..b0ee4951 --- /dev/null +++ b/tests/commands/configuration-management/branch/config-branch-export-import.spec.ts @@ -0,0 +1,447 @@ +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"; +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/core/utils/branches"; + +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 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)); + 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); + }); + + 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", () => { + 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); + }); + + 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" }, + ]); + 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", () => { + 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); + }); + + 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(); + }); + + 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(); + }); + }); +}); 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"); + }); + }); }); diff --git a/tests/integration/commands/configuration-management.spec.ts b/tests/integration/commands/configuration-management.spec.ts index b7283ec3..c5a48ec7 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,236 @@ 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(); + }); + + 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)", () => { + 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(); + }); + + 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)", () => { 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; +});