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;
+});