diff --git a/docs/command-graph.html b/docs/command-graph.html
index 2245c415..582ae7a8 100644
--- a/docs/command-graph.html
+++ b/docs/command-graph.html
@@ -314,6 +314,36 @@
description: "List packages in the target team. Lists staging packages by default.",
options: ["-p, --profile ", "--json", "--flavors ", "-h, --help"] },
+ // config branch
+ { id: "config_branch_area", label: "branch (beta)", group: "subarea", path: "config branch",
+ description: "Author, merge, and mirror package branches", options: ["-p, --profile ", "-h, --help"] },
+ { id: "config_branch_create", label: "create (beta)", group: "command", path: "config branch create",
+ description: "Create a new branch from a source version",
+ options: ["-p, --profile ", "--packageKey (Main package key to branch from)", "--branchKey ", "--sourceVersion ", "--validate (default: false)", "--json (default: false)", "-h, --help"] },
+ { id: "config_branch_list", label: "list (beta)", group: "command", path: "config branch list",
+ description: "List branches of a main package",
+ options: ["-p, --profile ", "--packageKey ", "--json (default: false)", "-h, --help"] },
+ { id: "config_branch_delete", label: "delete (beta)", group: "command", path: "config branch delete",
+ description: "Delete a branch",
+ options: ["-p, --profile ", "--packageKey (Main package key, no '@')", "--branchKey ", "-h, --help"] },
+
+ // config branch settings
+ { id: "config_branch_settings_area", label: "settings (beta)", group: "subsubarea", path: "config branch settings",
+ description: "Commands to manage branch settings on a package", options: ["-p, --profile ", "-h, --help"] },
+ { id: "config_branch_settings_set", label: "set (beta)", group: "command", path: "config branch settings set",
+ description: "Configure branch settings on a main package",
+ options: ["-p, --profile ", "--packageKey ", "--enabled (true|false)", "--json (default: false)", "-h, --help"] },
+
+ // config branch merge
+ { id: "config_branch_merge_area", label: "merge (beta)", group: "subsubarea", path: "config branch merge",
+ description: "Commands to preview and apply branch merges", options: ["-p, --profile ", "-h, --help"] },
+ { id: "config_branch_merge_preview", label: "preview (beta)", group: "command", path: "config branch merge preview",
+ description: "Preview the changes a merge would apply to a target branch",
+ options: ["-p, --profile ", "--packageKey (Target package key: '' or '@')", "--sourceKey ", "--sourceVersion (version or \"LATEST\")", "--json (default: false)", "-h, --help"] },
+ { id: "config_branch_merge_apply", label: "apply (beta)", group: "command", path: "config branch merge apply",
+ description: "Merge changes from a source into a target branch",
+ options: ["-p, --profile ", "--packageKey (Target package key: '' or '@')", "-f, --file (MergeBranchTransport payload; required only when the merge has conflicts)", "--sourceKey (overrides the file's 'sourceKey')", "--sourceVersion (overrides the file's 'sourceVersion'; or \"LATEST\")", "--bump (PATCH | MINOR | MAJOR)", "--newVersion (pin an explicit semver)", "--summary ", "--json (default: false)", "-h, --help"] },
+
// config metadata
{ id: "config_metadata_area", label: "metadata", group: "subarea", path: "config metadata",
description: "Commands related to package metadata", options: ["-p, --profile ", "-h, --help"] },
@@ -471,6 +501,12 @@
["area_config","config_package_area"],
["config_package_area","config_package_import"],["config_package_area","config_package_export"],
["config_package_area","config_package_validate"],["config_package_area","config_package_list"],
+ ["area_config","config_branch_area"],
+ ["config_branch_area","config_branch_create"],["config_branch_area","config_branch_list"],
+ ["config_branch_area","config_branch_delete"],
+ ["config_branch_area","config_branch_settings_area"],["config_branch_settings_area","config_branch_settings_set"],
+ ["config_branch_area","config_branch_merge_area"],
+ ["config_branch_merge_area","config_branch_merge_preview"],["config_branch_merge_area","config_branch_merge_apply"],
["area_config","config_metadata_area"],["config_metadata_area","config_metadata_export"],
["area_config","config_versions_area"],["config_versions_area","config_versions_get"],
["config_versions_area","config_versions_create"],
diff --git a/docs/user-guide/branch-commands.md b/docs/user-guide/branch-commands.md
new file mode 100644
index 00000000..f7675a32
--- /dev/null
+++ b/docs/user-guide/branch-commands.md
@@ -0,0 +1,139 @@
+# Branch Commands (beta)
+
+The `config branch` command group lets you author and merge branches from the CLI.
+
+## Concepts
+
+- **Main package** — a regular package, identified by its plain `` (no `@`).
+- **Branch** — a separate package keyed `@`, created from a version of the source package. The source can be the main package or another branch.
+
+## Branching Settings
+
+Configure branching settings on a main package. Currently the only option is whether branching is enabled.
+
+```bash
+content-cli config branch settings set --packageKey --enabled true
+content-cli config branch settings set --packageKey --enabled false
+```
+
+The `--packageKey` must be the main package key, not a `@` value.
+
+## Create a Branch
+
+```bash
+content-cli config branch create \
+ --packageKey \
+ --branchKey \
+ --sourceVersion 1.4.0
+```
+
+`--validate` runs the server-side validator without persisting and prints a success message instead of the created branch.
+
+## List Branches
+
+```bash
+content-cli config branch list --packageKey
+content-cli config branch list --packageKey --json
+```
+
+`--json` writes the raw `BranchTransport[]` payload to a file in the working directory.
+
+Each entry includes `sourcePackageKey` and `sourceVersion`, which together form the join key back to the version the branch was cut from. Multiple branches may share the same source.
+
+## Delete a Branch
+
+```bash
+content-cli config branch delete \
+ --packageKey \
+ --branchKey
+```
+
+- `--packageKey` must be the main package key (no `@`).
+- `--branchKey` is the branch to delete.
+
+## Preview and Apply Merges
+
+You can inspect the changes a merge would apply with `merge preview`, then apply them with `merge apply`.
+
+```bash
+content-cli config branch merge preview \
+ --packageKey \
+ --sourceKey \
+ --sourceVersion 1.4.0 \
+ --json
+```
+
+`sourcePackageKey` is typically `@`. `` may be a version or `LATEST`.
+
+The JSON preview contains the conflict layout (`changes.configuration.conflicts`, `changes.metadata.conflicts`) and auto-merge results.
+
+### Quick merge
+
+When you do not need to override any node-level resolutions, the resolutions file is optional:
+
+```bash
+content-cli config branch merge apply \
+ --packageKey \
+ --sourceKey \
+ --sourceVersion 1.4.0
+```
+
+The CLI posts the merge directly. If the server detects conflicts it returns an error — at that point run `config branch merge preview` to inspect them and re-run `apply` with `-f `.
+
+Customise the published version without writing a file:
+
+- `--bump PATCH|MINOR|MAJOR` — pick the bump option (default `PATCH`).
+- `--newVersion 1.5.0` — pin an explicit semver.
+- `--summary ""` — summary of changes (default `"Merge @"`).
+
+### With a resolutions file
+
+Build a `MergeBranchTransport` resolution file from the preview (one entry per node where a custom resolution is needed), then apply:
+
+```bash
+content-cli config branch merge apply \
+ --packageKey \
+ --file merge-request.json
+```
+
+- `--sourceKey` / `--sourceVersion` override the file's values when you want to keep one resolution file but target different sources.
+- `--bump` / `--newVersion` / `--summary` override the file's `versionCreate` block when set.
+
+A valid merge body looks like:
+
+```json
+{
+ "sourceKey": "my-pkg@feature-a",
+ "sourceVersion": "1.4.0",
+ "resolvedPackageConflict": null,
+ "resolvedNodeConflicts": [
+ { "nodeKey": "node-1", "resolution": "ACCEPT_SOURCE" }
+ ],
+ "versionCreate": {
+ "versionBumpOption": "PATCH",
+ "summaryOfChanges": "Merge feature-a into main"
+ }
+}
+```
+
+`versionCreate` is filled in this order of precedence (highest wins):
+
+1. `--newVersion ` flag or `--bump PATCH|MINOR|MAJOR` flag (and `--summary`).
+2. Whatever the resolutions file already has under `versionCreate`.
+3. Default: `versionBumpOption: PATCH` + `summaryOfChanges: "Merge @"`.
+
+`resolution` per node accepts `ACCEPT_SOURCE`, `ACCEPT_TARGET`, or `CUSTOM`. `CUSTOM` requires the matching `customConfigurationChanges` / `customMetadataChanges` JSON-Patch ops.
+
+> **Custom changes are restricted to paths the preview already touched.** Each op in `customConfigurationChanges` / `customMetadataChanges` must reference a `path` that appears in either the preview's `sourceChanges` or `targetChanges` for that node — you cannot introduce edits at paths neither side modified. The server rejects ops at unrelated paths.
+
+Worked example: the preview reports that for node `node-1`, the source set `/title` to `"Source title"` and the target set `/title` to `"Target title"` (a conflict at `/title`). A valid CUSTOM resolution can pick a third value for `/title`, but it cannot also touch `/description` (which neither side changed):
+
+```json
+{
+ "nodeKey": "node-1",
+ "resolution": "CUSTOM",
+ "customConfigurationChanges": [
+ { "op": "replace", "path": "/title", "value": "Negotiated title" }
+ ]
+}
+```
diff --git a/docs/user-guide/index.md b/docs/user-guide/index.md
index 04805f70..48eb3267 100644
--- a/docs/user-guide/index.md
+++ b/docs/user-guide/index.md
@@ -6,6 +6,7 @@ Content CLI organizes its commands into groups by area. Each group covers a spec
|---|-------------------------------------------------------------------------------|
| [Studio Commands](./studio-commands.md) | Pull and push packages, assets, spaces, and widgets to and from Studio |
| [Config Commands](./config-commands.md) | List, batch export, and import all packages and their configurations |
+| [Branch Commands](./branch-commands.md) | Create, list, merge, and delete package branches |
| [Deployment Commands](./deployment-commands.md) | Create deployments, list history, check active deployments, and manage targets |
| [Asset Registry Commands](./asset-registry-commands.md) | Discover registered asset types and their service descriptors |
| [Data Pool Commands](./data-pool-commands.md) | Export and import Data Pools with their dependencies |
diff --git a/mkdocs.yaml b/mkdocs.yaml
index 7b0bea30..4648ae2f 100644
--- a/mkdocs.yaml
+++ b/mkdocs.yaml
@@ -13,6 +13,7 @@ nav:
- Overview: './user-guide/index.md'
- Studio Commands: './user-guide/studio-commands.md'
- Config Commands: './user-guide/config-commands.md'
+ - Branch Commands: './user-guide/branch-commands.md'
- T2TC Commands: './user-guide/t2tc-commands.md'
- Deployment Commands: './user-guide/deployment-commands.md'
- Asset Registry Commands: './user-guide/asset-registry-commands.md'
diff --git a/src/commands/configuration-management/branch/api/branch.api.ts b/src/commands/configuration-management/branch/api/branch.api.ts
new file mode 100644
index 00000000..6a9f927b
--- /dev/null
+++ b/src/commands/configuration-management/branch/api/branch.api.ts
@@ -0,0 +1,74 @@
+import { HttpClient } from "../../../../core/http/http-client";
+import { Context } from "../../../../core/command/cli-context";
+import { FatalError } from "../../../../core/utils/logger";
+import {
+ BranchingSettingsTransport,
+ BranchTransport,
+ CreateBranchTransport,
+ MergeBranchTransport,
+ MergePreviewRequestTransport,
+ MergePreviewTransport,
+ PackageVersionCreatedTransport,
+} from "../interfaces/branch.interfaces";
+
+export class BranchApi {
+ private readonly httpClient: () => HttpClient;
+
+ constructor(context: Context) {
+ this.httpClient = () => context.httpClient;
+ }
+
+ public async configureBranchingSettings(packageKey: string, transport: BranchingSettingsTransport): Promise {
+ return this.httpClient()
+ .put(`/pacman/api/core/packages/${packageKey}/branch-settings`, transport)
+ .catch(e => {
+ throw new FatalError(`Problem updating branch settings for package ${packageKey}: ${e}`);
+ });
+ }
+
+ public async createBranch(packageKey: string, transport: CreateBranchTransport, validate: boolean = false): Promise {
+ const params = new URLSearchParams();
+ if (validate) {
+ params.set("validate", "true");
+ }
+ const query = params.toString().length ? `?${params.toString()}` : "";
+
+ return this.httpClient()
+ .post(`/pacman/api/core/packages/${packageKey}/branches${query}`, transport)
+ .catch(e => {
+ throw new FatalError(`Problem creating branch '${transport.branchKey}' from package ${packageKey}: ${e}`);
+ });
+ }
+
+ public async listBranches(packageKey: string): Promise {
+ return this.httpClient()
+ .get(`/pacman/api/core/packages/${packageKey}/branches`)
+ .catch(e => {
+ throw new FatalError(`Problem listing branches for package ${packageKey}: ${e}`);
+ });
+ }
+
+ public async mergePreview(packageKey: string, transport: MergePreviewRequestTransport): Promise {
+ return this.httpClient()
+ .post(`/pacman/api/core/staging/packages/${packageKey}/merge/preview`, transport)
+ .catch(e => {
+ throw new FatalError(`Problem previewing merge into package ${packageKey} from ${transport.sourceKey}@${transport.sourceVersion}: ${e}`);
+ });
+ }
+
+ public async merge(packageKey: string, transport: MergeBranchTransport): Promise {
+ return this.httpClient()
+ .post(`/pacman/api/core/staging/packages/${packageKey}/merge`, transport)
+ .catch(e => {
+ throw new FatalError(`Problem merging into package ${packageKey} from ${transport.sourceKey}@${transport.sourceVersion}: ${e}`);
+ });
+ }
+
+ public async deleteBranch(branchPackageKey: string): Promise {
+ return this.httpClient()
+ .delete(`/pacman/api/core/staging/packages/${branchPackageKey}/purge`)
+ .catch(e => {
+ throw new FatalError(`Problem deleting branch ${branchPackageKey}: ${e}`);
+ });
+ }
+}
diff --git a/src/commands/configuration-management/branch/branch.command.service.ts b/src/commands/configuration-management/branch/branch.command.service.ts
new file mode 100644
index 00000000..f0a87a8b
--- /dev/null
+++ b/src/commands/configuration-management/branch/branch.command.service.ts
@@ -0,0 +1,183 @@
+import { v4 as uuidv4 } from "uuid";
+import { Context } from "../../../core/command/cli-context";
+import { fileService, FileService } from "../../../core/utils/file-service";
+import { logger } from "../../../core/utils/logger";
+import { BranchApi } from "./api/branch.api";
+import {
+ BranchTransport,
+ BranchingSettingsTransport,
+ CreateBranchTransport,
+ MergeApplyOptions,
+ MergeBranchTransport,
+ MergePreviewRequestTransport,
+ MergePreviewTransport,
+ PackageVersionCreatedTransport,
+ SavePackageVersionTransport,
+ VersionBumpOption,
+} from "./interfaces/branch.interfaces";
+
+export class BranchCommandService {
+ private readonly branchApi: BranchApi;
+
+ constructor(context: Context) {
+ this.branchApi = new BranchApi(context);
+ }
+
+ public async setBranchingEnabled(packageKey: string, enabled: boolean, jsonResponse: boolean): Promise {
+ const transport: BranchingSettingsTransport = { branchingEnabled: enabled };
+ const result = await this.branchApi.configureBranchingSettings(packageKey, transport);
+
+ if (jsonResponse) {
+ BranchCommandService.writeJson(result);
+ } else {
+ logger.info(`Branching ${result.branchingEnabled ? "enabled" : "disabled"} for package ${packageKey}.`);
+ }
+ return result;
+ }
+
+ public async createBranch(packageKey: string, branchKey: string, sourceVersion: string, validate: boolean, jsonResponse: boolean): Promise {
+ const transport: CreateBranchTransport = { branchKey, version: sourceVersion };
+ const result = await this.branchApi.createBranch(packageKey, transport, validate);
+
+ if (validate) {
+ logger.info(`Validation successful for branch '${branchKey}' from ${packageKey}@${sourceVersion}.`);
+ return;
+ }
+
+ if (jsonResponse) {
+ BranchCommandService.writeJson(result);
+ } else if (result) {
+ BranchCommandService.printBranch(result);
+ }
+ return result;
+ }
+
+ public async listBranches(packageKey: string, jsonResponse: boolean): Promise {
+ const branches = await this.branchApi.listBranches(packageKey);
+
+ if (jsonResponse) {
+ BranchCommandService.writeJson(branches);
+ } else if (branches.length === 0) {
+ logger.info(`No branches found for ${packageKey}.`);
+ } else {
+ branches.forEach(branch => {
+ logger.info(
+ `${branch.branchKey} (package: ${branch.packageKey}, source: ${branch.sourcePackageKey}@${branch.sourceVersion})`
+ );
+ });
+ }
+ return branches;
+ }
+
+ public async deleteBranch(packageKey: string, branchKey: string): Promise {
+ const branchPackageKey = `${packageKey}@${branchKey}`;
+ await this.branchApi.deleteBranch(branchPackageKey);
+ logger.info(`Branch ${branchPackageKey} deleted.`);
+ }
+
+ public async mergePreview(targetPackageKey: string, sourceKey: string, sourceVersion: string, jsonResponse: boolean): Promise {
+ const transport: MergePreviewRequestTransport = { sourceKey, sourceVersion };
+ const preview = await this.branchApi.mergePreview(targetPackageKey, transport);
+
+ if (jsonResponse) {
+ BranchCommandService.writeJson(preview);
+ } else {
+ BranchCommandService.printPreviewSummary(targetPackageKey, sourceKey, sourceVersion, preview);
+ }
+ return preview;
+ }
+
+ public async mergeApply(targetPackageKey: string, options: MergeApplyOptions): Promise {
+ const rawBody: Partial = options.file
+ ? JSON.parse(fileService.readFile(options.file))
+ : {};
+
+ const effectiveSourceKey = options.sourceKey ?? rawBody.sourceKey;
+ const effectiveSourceVersion = options.sourceVersion ?? rawBody.sourceVersion;
+
+ const versionCreate = BranchCommandService.buildVersionCreate(
+ rawBody.versionCreate,
+ options.bump,
+ options.version,
+ options.summary,
+ effectiveSourceKey,
+ effectiveSourceVersion,
+ );
+
+ const transport: MergeBranchTransport = {
+ ...rawBody,
+ sourceKey: effectiveSourceKey,
+ sourceVersion: effectiveSourceVersion,
+ versionCreate,
+ };
+ const result = await this.branchApi.merge(targetPackageKey, transport);
+
+ if (options.jsonResponse) {
+ BranchCommandService.writeJson(result);
+ } else {
+ logger.info(
+ `Merge applied: published ${result.packageKey}@${result.version} from ${effectiveSourceKey}@${effectiveSourceVersion}.`
+ );
+ }
+ return result;
+ }
+
+ private static buildVersionCreate(
+ fromFile: SavePackageVersionTransport | undefined,
+ flagBump: string | undefined,
+ flagVersion: string | undefined,
+ flagSummary: string | undefined,
+ sourceKey: string | undefined,
+ sourceVersion: string | undefined,
+ ): SavePackageVersionTransport {
+ const versionCreate: SavePackageVersionTransport = fromFile ? { ...fromFile } : {};
+
+ if (flagVersion !== undefined) {
+ versionCreate.version = flagVersion;
+ versionCreate.versionBumpOption = undefined;
+ } else if (flagBump !== undefined) {
+ versionCreate.versionBumpOption = flagBump.toUpperCase() as SavePackageVersionTransport["versionBumpOption"];
+ versionCreate.version = undefined;
+ } else if (!versionCreate.version && !versionCreate.versionBumpOption) {
+ versionCreate.versionBumpOption = VersionBumpOption.PATCH;
+ }
+
+ if (flagSummary !== undefined) {
+ versionCreate.summaryOfChanges = flagSummary;
+ } else if (!versionCreate.summaryOfChanges && sourceKey && sourceVersion) {
+ versionCreate.summaryOfChanges = `Merge ${sourceKey}@${sourceVersion}`;
+ }
+
+ return versionCreate;
+ }
+
+ private static writeJson(payload: unknown): void {
+ const filename = `${uuidv4()}.json`;
+ fileService.writeToFileWithGivenName(JSON.stringify(payload, null, 2), filename);
+ logger.info(FileService.fileDownloadedMessage + filename);
+ }
+
+ private static printBranch(branch: BranchTransport): void {
+ logger.info(`Main Package Key: ${branch.projectKey}`);
+ logger.info(`Branch Key: ${branch.branchKey}`);
+ logger.info(`Package Key: ${branch.packageKey}`);
+ logger.info(`Source Package Key: ${branch.sourcePackageKey}`);
+ logger.info(`Source Version: ${branch.sourceVersion}`);
+ }
+
+ private static printPreviewSummary(targetPackageKey: string, sourceKey: string, sourceVersion: string, preview: MergePreviewTransport): void {
+ logger.info(`Preview: merge ${sourceKey}@${sourceVersion} into ${targetPackageKey}`);
+ logger.info(`Common ancestor: ${preview.commonPackageKey}@${preview.commonVersion}`);
+ const totalNodes = preview.nodeChanges?.length ?? 0;
+ const packageConflicts =
+ (preview.packageChanges?.changes?.configuration?.conflicts?.length ?? 0) +
+ (preview.packageChanges?.changes?.metadata?.conflicts?.length ?? 0);
+ const nodeConflicts = preview.nodeChanges?.reduce((sum, node) => {
+ const config = node.changes?.configuration?.conflicts?.length ?? 0;
+ const metadata = node.changes?.metadata?.conflicts?.length ?? 0;
+ return sum + config + metadata;
+ }, 0) ?? 0;
+ const conflicts = packageConflicts + nodeConflicts;
+ logger.info(`Nodes touched: ${totalNodes}, conflicting paths: ${conflicts}.`);
+ }
+}
diff --git a/src/commands/configuration-management/branch/interfaces/branch.interfaces.ts b/src/commands/configuration-management/branch/interfaces/branch.interfaces.ts
new file mode 100644
index 00000000..2ad7bfd1
--- /dev/null
+++ b/src/commands/configuration-management/branch/interfaces/branch.interfaces.ts
@@ -0,0 +1,157 @@
+export enum MergeResolution {
+ CUSTOM = "CUSTOM",
+ ACCEPT_SOURCE = "ACCEPT_SOURCE",
+ ACCEPT_TARGET = "ACCEPT_TARGET",
+}
+
+export enum MergeStatus {
+ UNKNOWN = "UNKNOWN",
+ UNCHANGED = "UNCHANGED",
+ SOURCE_ONLY = "SOURCE_ONLY",
+ TARGET_ONLY = "TARGET_ONLY",
+ AUTO_MERGE = "AUTO_MERGE",
+ CONFLICT = "CONFLICT",
+}
+
+export enum ChangeType {
+ UNKNOWN = "UNKNOWN",
+ ADDED = "ADDED",
+ DELETED = "DELETED",
+ CHANGED = "CHANGED",
+ UNCHANGED = "UNCHANGED",
+}
+
+export enum VersionBumpOption {
+ NONE = "NONE",
+ PATCH = "PATCH",
+ MINOR = "MINOR",
+ MAJOR = "MAJOR",
+}
+
+export interface BranchingSettingsTransport {
+ branchingEnabled: boolean;
+}
+
+export interface CreateBranchTransport {
+ branchKey: string;
+ version: string;
+}
+
+export interface BranchTransport {
+ projectKey: string;
+ branchKey: string;
+ sourcePackageKey: string;
+ sourceVersion: string;
+ packageKey: string;
+ packageId?: string;
+}
+
+export interface ConfigurationChangeTransport {
+ op: string;
+ path: string;
+ from?: string;
+ value?: unknown;
+ fromValue?: unknown;
+}
+
+export interface MergePackageResolutionTransport {
+ resolution: MergeResolution;
+ customConfigurationChanges?: ConfigurationChangeTransport[];
+ customMetadataChanges?: ConfigurationChangeTransport[];
+}
+
+export interface MergeNodeResolutionTransport {
+ nodeKey: string;
+ resolution: MergeResolution;
+ customConfigurationChanges?: ConfigurationChangeTransport[];
+ customMetadataChanges?: ConfigurationChangeTransport[];
+}
+
+export interface NodeFilterTransport {
+ nodeKeys?: string[];
+}
+
+export interface SavePackageVersionTransport {
+ version?: string | null;
+ versionBumpOption?: VersionBumpOption;
+ summaryOfChanges?: string;
+ nodeFilter?: NodeFilterTransport | null;
+}
+
+export interface MergePreviewRequestTransport {
+ sourceKey: string;
+ sourceVersion: string;
+}
+
+export interface MergeBranchTransport {
+ sourceKey: string;
+ sourceVersion: string;
+ resolvedPackageConflict?: MergePackageResolutionTransport | null;
+ resolvedNodeConflicts?: MergeNodeResolutionTransport[];
+ versionCreate: SavePackageVersionTransport;
+}
+
+export interface MergePathConflictTransport {
+ path: string;
+ sourceChange: ConfigurationChangeTransport;
+ targetChange: ConfigurationChangeTransport;
+}
+
+export interface MergeDiffDetailTransport {
+ status: MergeStatus;
+ sourceChanges: ConfigurationChangeTransport[];
+ targetChanges: ConfigurationChangeTransport[];
+ conflicts: MergePathConflictTransport[];
+ autoMergedChanges: ConfigurationChangeTransport[];
+}
+
+export interface MergeDiffTransport {
+ configuration: MergeDiffDetailTransport;
+ metadata: MergeDiffDetailTransport;
+}
+
+export interface MergePreviewPackageTransport {
+ sourceChange: ChangeType;
+ targetChange: ChangeType;
+ changeDate: string;
+ updatedBy: string;
+ changes: MergeDiffTransport;
+}
+
+export interface MergePreviewNodeTransport {
+ nodeKey: string;
+ name: string;
+ type: string;
+ parentNodeKey?: string;
+ invalidContent: boolean;
+ sourceChange: ChangeType;
+ targetChange: ChangeType;
+ changeDate: string;
+ updatedBy: string;
+ changes: MergeDiffTransport;
+}
+
+export interface MergePreviewTransport {
+ commonPackageKey: string;
+ commonVersion: string;
+ packageChanges: MergePreviewPackageTransport;
+ nodeChanges: MergePreviewNodeTransport[];
+}
+
+export interface PackageVersionCreatedTransport {
+ packageKey: string;
+ version: string;
+ summaryOfChanges?: string;
+ creationDate: string;
+ createdBy: string;
+}
+
+export interface MergeApplyOptions {
+ sourceKey?: string;
+ sourceVersion?: string;
+ file?: string;
+ bump?: string;
+ version?: string;
+ summary?: string;
+ jsonResponse?: boolean;
+}
diff --git a/src/commands/configuration-management/module.ts b/src/commands/configuration-management/module.ts
index a1aee91d..12d13169 100644
--- a/src/commands/configuration-management/module.ts
+++ b/src/commands/configuration-management/module.ts
@@ -17,12 +17,71 @@ import { PackageVersionCommandService } from "./package-version-command.service"
import { PackageValidationService } from "./package-validation.service";
import { SinglePackageImportService } from "./single-package-import.service";
import { SinglePackageExportService } from "./single-package-export.service";
+import { BranchCommandService } from "./branch/branch.command.service";
class Module extends IModule {
public register(context: Context, configurator: Configurator): void {
const configCommand = configurator.command("config")
.description("Manage package configurations and their resources (package, nodes, versions, variables, metadata). Note: 'config list/export/import/diff' are deprecated — bulk Team-to-Team Copy operations have moved to the 't2tc package' group, and single-package operations live under 'config package'.");
+
+ const branchCommand = configCommand.command("branch").beta()
+ .description("Author, merge, and mirror package branches");
+
+ const branchSettingsCommand = branchCommand.command("settings").beta()
+ .description("Commands to manage branch settings on a package");
+
+ branchSettingsCommand.command("set").beta()
+ .description("Configure branch settings on a main package")
+ .requiredOption("--packageKey ", "Main package key")
+ .requiredOption("--enabled ", "true|false")
+ .option("--json", "Write response to a JSON file", false)
+ .action(this.setBranchSettings);
+
+ branchCommand.command("create").beta()
+ .description("Create a new branch from a source version")
+ .requiredOption("--packageKey ", "Main package key to branch from")
+ .requiredOption("--branchKey ", "New branch key")
+ .requiredOption("--sourceVersion ", "Source version to branch from")
+ .option("--validate", "Only validate the request without creating the branch", false)
+ .option("--json", "Write response to a JSON file", false)
+ .action(this.createBranch);
+
+ branchCommand.command("list").beta()
+ .description("List branches of a main package")
+ .requiredOption("--packageKey ", "Main package key")
+ .option("--json", "Write response to a JSON file", false)
+ .action(this.listBranches);
+
+ branchCommand.command("delete").beta()
+ .description("Delete a branch")
+ .requiredOption("--packageKey ", "Main package key (no '@')")
+ .requiredOption("--branchKey ", "Branch key to delete")
+ .action(this.deleteBranch);
+
+ const branchMergeCommand = branchCommand.command("merge").beta()
+ .description("Commands to preview and apply branch merges");
+
+ branchMergeCommand.command("preview").beta()
+ .description("Preview the changes a merge would apply to a target branch")
+ .requiredOption("--packageKey ", "Target package key (main package key or '@')")
+ .requiredOption("--sourceKey ", "Source package key (main package key or '@')")
+ .requiredOption("--sourceVersion ", "Source version (or 'LATEST')")
+ .option("--json", "Write response to a JSON file", false)
+ .action(this.previewBranchMerge);
+
+ branchMergeCommand.command("apply").beta()
+ .description("Merge changes from a source into a target branch")
+ .requiredOption("--packageKey ", "Target package key (main package key or '@')")
+ .option("-f, --file ", "Path to a JSON file containing the MergeBranchTransport payload")
+ .option("--sourceKey ", "Source package key, main package key or '@' (overrides the file's 'sourceKey')")
+ .option("--sourceVersion ", "Source version (overrides the file's 'sourceVersion'; or 'LATEST')")
+ .option("--bump ", "Version bump for the new published version: PATCH | MINOR | MAJOR")
+ .option("--newVersion ", "Pin the new published version to an explicit semver")
+ .option("--summary ", "Summary of changes for the new published version")
+ .option("--json", "Write response to a JSON file", false)
+ .action(this.applyBranchMerge);
+
configCommand.command("list")
.description("[Deprecated] Use 't2tc package list' instead. List packages in the target team.")
.deprecationNotice("'config list' is deprecated and will be removed in a future release. Use 't2tc package list' instead.")
@@ -239,6 +298,42 @@ class Module extends IModule {
.action(this.listAssignments);
}
+ private async setBranchSettings(context: Context, command: Command, options: OptionValues): Promise {
+ const enabled = String(options.enabled).toLowerCase();
+ if (enabled !== "true" && enabled !== "false") {
+ throw new Error("Please provide either 'true' or 'false' for --enabled.");
+ }
+ await new BranchCommandService(context).setBranchingEnabled(options.packageKey, enabled === "true", !!options.json);
+ }
+
+ private async createBranch(context: Context, command: Command, options: OptionValues): Promise {
+ await new BranchCommandService(context).createBranch(options.packageKey, options.branchKey, options.sourceVersion, !!options.validate, !!options.json);
+ }
+
+ private async listBranches(context: Context, command: Command, options: OptionValues): Promise {
+ await new BranchCommandService(context).listBranches(options.packageKey, !!options.json);
+ }
+
+ private async deleteBranch(context: Context, command: Command, options: OptionValues): Promise {
+ await new BranchCommandService(context).deleteBranch(options.packageKey, options.branchKey);
+ }
+
+ private async previewBranchMerge(context: Context, command: Command, options: OptionValues): Promise {
+ await new BranchCommandService(context).mergePreview(options.packageKey, options.sourceKey, options.sourceVersion, !!options.json);
+ }
+
+ private async applyBranchMerge(context: Context, command: Command, options: OptionValues): Promise {
+ await new BranchCommandService(context).mergeApply(options.packageKey, {
+ sourceKey: options.sourceKey,
+ sourceVersion: options.sourceVersion,
+ file: options.file,
+ bump: options.bump,
+ version: options.newVersion,
+ summary: options.summary,
+ jsonResponse: !!options.json,
+ });
+ }
+
private async listPackages(context: Context, command: Command, options: OptionValues): Promise {
if (options.staging && (options.withDependencies || options.packageKeys || options.keysByVersion || options.variableValue || options.variableType)) {
throw new Error("Staging parameter is not compatible with --withDependencies, --packageKeys, --keysByVersion, --variableValue, --variableType");
diff --git a/tests/commands/configuration-management/branch/branch-create.spec.ts b/tests/commands/configuration-management/branch/branch-create.spec.ts
new file mode 100644
index 00000000..06ae72ff
--- /dev/null
+++ b/tests/commands/configuration-management/branch/branch-create.spec.ts
@@ -0,0 +1,55 @@
+import { mockAxiosPost, mockedPostRequestBodyByUrl } from "../../../utls/http-requests-mock";
+import { BranchCommandService } from "../../../../src/commands/configuration-management/branch/branch.command.service";
+import { testContext } from "../../../utls/test-context";
+import { loggingTestTransport } from "../../../jest.setup";
+import { getJsonFromDownloadedFile } from "../../../utls/fs-utils";
+import { FileService } from "../../../../src/core/utils/file-service";
+import { BranchTransport, CreateBranchTransport } from "../../../../src/commands/configuration-management/branch/interfaces/branch.interfaces";
+
+describe("branch create", () => {
+ const packageKey = "my-package";
+ const apiUrl = `https://myTeam.celonis.cloud/pacman/api/core/packages/${packageKey}/branches`;
+ const validateUrl = `${apiUrl}?validate=true`;
+
+ const created: BranchTransport = {
+ projectKey: packageKey,
+ branchKey: "feature-a",
+ sourcePackageKey: packageKey,
+ sourceVersion: "1.4.0",
+ packageKey: `${packageKey}@feature-a`,
+ };
+
+ it("creates a branch and prints summary", async () => {
+ mockAxiosPost(apiUrl, created);
+
+ await new BranchCommandService(testContext).createBranch(packageKey, "feature-a", "1.4.0", false, false);
+
+ const requestBody: CreateBranchTransport = JSON.parse(mockedPostRequestBodyByUrl.get(apiUrl) as string);
+ expect(requestBody).toEqual({ branchKey: "feature-a", version: "1.4.0" });
+ expect(loggingTestTransport.logMessages.some((m) => m.message.includes("Branch Key: feature-a"))).toBe(true);
+ });
+
+ it("hits the validate endpoint when --validate is set", async () => {
+ mockAxiosPost(validateUrl, undefined);
+
+ await new BranchCommandService(testContext).createBranch(packageKey, "feature-a", "1.4.0", true, false);
+
+ expect(mockedPostRequestBodyByUrl.get(validateUrl)).toBeDefined();
+ expect(
+ loggingTestTransport.logMessages.some((m) => m.message.includes(FileService.fileDownloadedMessage))
+ ).toBe(false);
+ expect(
+ loggingTestTransport.logMessages.some((m) =>
+ m.message.includes(`Validation successful for branch 'feature-a' from ${packageKey}@1.4.0.`)
+ )
+ ).toBe(true);
+ });
+
+ it("writes JSON file when jsonResponse=true", async () => {
+ mockAxiosPost(apiUrl, created);
+
+ await new BranchCommandService(testContext).createBranch(packageKey, "feature-a", "1.4.0", false, true);
+
+ expect(getJsonFromDownloadedFile()).toEqual(created);
+ });
+});
diff --git a/tests/commands/configuration-management/branch/branch-delete.spec.ts b/tests/commands/configuration-management/branch/branch-delete.spec.ts
new file mode 100644
index 00000000..0d4a732d
--- /dev/null
+++ b/tests/commands/configuration-management/branch/branch-delete.spec.ts
@@ -0,0 +1,21 @@
+import { mockAxiosDelete, mockedAxiosInstance } from "../../../utls/http-requests-mock";
+import { BranchCommandService } from "../../../../src/commands/configuration-management/branch/branch.command.service";
+import { testContext } from "../../../utls/test-context";
+import { loggingTestTransport } from "../../../jest.setup";
+
+describe("branch delete", () => {
+ const packageKey = "my-package";
+ const branchKey = "feature-a";
+ const branchPackageKey = `${packageKey}@${branchKey}`;
+ const purgeUrl = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${branchPackageKey}/purge`;
+
+ it("DELETEs the branch's package and logs a success message", async () => {
+ mockAxiosDelete(purgeUrl);
+
+ await new BranchCommandService(testContext).deleteBranch(packageKey, branchKey);
+
+ expect(mockedAxiosInstance.delete).toHaveBeenCalledTimes(1);
+ expect((mockedAxiosInstance.delete as jest.Mock).mock.calls[0][0]).toBe(purgeUrl);
+ expect(loggingTestTransport.logMessages.some((m) => m.message.includes(`Branch ${branchPackageKey} deleted.`))).toBe(true);
+ });
+});
diff --git a/tests/commands/configuration-management/branch/branch-list.spec.ts b/tests/commands/configuration-management/branch/branch-list.spec.ts
new file mode 100644
index 00000000..8786c973
--- /dev/null
+++ b/tests/commands/configuration-management/branch/branch-list.spec.ts
@@ -0,0 +1,54 @@
+import { mockAxiosGet } from "../../../utls/http-requests-mock";
+import { BranchCommandService } from "../../../../src/commands/configuration-management/branch/branch.command.service";
+import { testContext } from "../../../utls/test-context";
+import { loggingTestTransport } from "../../../jest.setup";
+import { getJsonFromDownloadedFile } from "../../../utls/fs-utils";
+import { BranchTransport } from "../../../../src/commands/configuration-management/branch/interfaces/branch.interfaces";
+
+describe("branch list", () => {
+ const packageKey = "my-package";
+ const apiUrl = `https://myTeam.celonis.cloud/pacman/api/core/packages/${packageKey}/branches`;
+
+ const branches: BranchTransport[] = [
+ {
+ projectKey: packageKey,
+ branchKey: "feature-a",
+ sourcePackageKey: packageKey,
+ sourceVersion: "1.4.0",
+ packageKey: `${packageKey}@feature-a`,
+ },
+ {
+ projectKey: packageKey,
+ branchKey: "release",
+ sourcePackageKey: packageKey,
+ sourceVersion: "1.5.0",
+ packageKey: `${packageKey}@release`,
+ },
+ ];
+
+ it("lists branches and prints one line per branch", async () => {
+ mockAxiosGet(apiUrl, branches);
+
+ await new BranchCommandService(testContext).listBranches(packageKey, false);
+
+ const messages = loggingTestTransport.logMessages.map((m) => m.message);
+ expect(messages.some((m) => m.includes(`feature-a (package: ${packageKey}@feature-a`))).toBe(true);
+ expect(messages.some((m) => m.includes(`release (package: ${packageKey}@release`))).toBe(true);
+ });
+
+ it("logs a friendly message when there are no branches", async () => {
+ mockAxiosGet(apiUrl, []);
+
+ await new BranchCommandService(testContext).listBranches(packageKey, false);
+
+ expect(loggingTestTransport.logMessages[0].message).toContain(`No branches found for ${packageKey}.`);
+ });
+
+ it("writes the raw transport list to a JSON file when jsonResponse=true", async () => {
+ mockAxiosGet(apiUrl, branches);
+
+ await new BranchCommandService(testContext).listBranches(packageKey, true);
+
+ expect(getJsonFromDownloadedFile()).toEqual(branches);
+ });
+});
diff --git a/tests/commands/configuration-management/branch/branch-merge.spec.ts b/tests/commands/configuration-management/branch/branch-merge.spec.ts
new file mode 100644
index 00000000..bd2616b9
--- /dev/null
+++ b/tests/commands/configuration-management/branch/branch-merge.spec.ts
@@ -0,0 +1,234 @@
+import { mockAxiosPost, mockedPostRequestBodyByUrl } from "../../../utls/http-requests-mock";
+import { BranchCommandService } from "../../../../src/commands/configuration-management/branch/branch.command.service";
+import { testContext } from "../../../utls/test-context";
+import { loggingTestTransport } from "../../../jest.setup";
+import { getJsonFromDownloadedFile, writeJsonTempFile } from "../../../utls/fs-utils";
+import {
+ ChangeType,
+ MergeBranchTransport,
+ MergePreviewTransport,
+ MergeResolution,
+ MergeStatus,
+ PackageVersionCreatedTransport,
+ VersionBumpOption,
+} from "../../../../src/commands/configuration-management/branch/interfaces/branch.interfaces";
+
+describe("branch merge preview", () => {
+ const targetPackageKey = "my-package";
+ const previewUrl = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${targetPackageKey}/merge/preview`;
+
+ const preview: MergePreviewTransport = {
+ commonPackageKey: targetPackageKey,
+ commonVersion: "1.2.0",
+ packageChanges: {
+ sourceChange: ChangeType.UNCHANGED,
+ targetChange: ChangeType.UNCHANGED,
+ changeDate: new Date().toISOString(),
+ updatedBy: "user",
+ changes: {
+ configuration: {
+ status: MergeStatus.UNCHANGED,
+ sourceChanges: [],
+ targetChanges: [],
+ conflicts: [],
+ autoMergedChanges: [],
+ },
+ metadata: {
+ status: MergeStatus.UNCHANGED,
+ sourceChanges: [],
+ targetChanges: [],
+ conflicts: [],
+ autoMergedChanges: [],
+ },
+ },
+ },
+ nodeChanges: [
+ {
+ nodeKey: "node-1",
+ name: "Node 1",
+ type: "VIEW",
+ invalidContent: false,
+ sourceChange: ChangeType.CHANGED,
+ targetChange: ChangeType.UNCHANGED,
+ changeDate: new Date().toISOString(),
+ updatedBy: "user",
+ changes: {
+ configuration: {
+ status: MergeStatus.CONFLICT,
+ sourceChanges: [],
+ targetChanges: [],
+ conflicts: [
+ { path: "/title", sourceChange: { op: "replace", path: "/title", value: "A" }, targetChange: { op: "replace", path: "/title", value: "B" } },
+ ],
+ autoMergedChanges: [],
+ },
+ metadata: {
+ status: MergeStatus.UNCHANGED,
+ sourceChanges: [],
+ targetChanges: [],
+ conflicts: [],
+ autoMergedChanges: [],
+ },
+ },
+ },
+ ],
+ };
+
+ it("posts the source key/version and prints a summary with conflict count", async () => {
+ mockAxiosPost(previewUrl, preview);
+
+ await new BranchCommandService(testContext).mergePreview(
+ targetPackageKey,
+ `${targetPackageKey}@feature-a`,
+ "1.4.0",
+ false,
+ );
+
+ expect(JSON.parse(mockedPostRequestBodyByUrl.get(previewUrl) as string)).toEqual({
+ sourceKey: `${targetPackageKey}@feature-a`,
+ sourceVersion: "1.4.0",
+ });
+ const messages = loggingTestTransport.logMessages.map((m) => m.message);
+ expect(messages.some((m) => m.includes("conflicting paths: 1"))).toBe(true);
+ });
+
+ it("writes raw transport to JSON when jsonResponse=true", async () => {
+ mockAxiosPost(previewUrl, preview);
+
+ await new BranchCommandService(testContext).mergePreview(targetPackageKey, "src", "1.4.0", true);
+
+ expect(getJsonFromDownloadedFile()).toEqual(preview);
+ });
+});
+
+describe("branch merge apply", () => {
+ const targetPackageKey = "my-package";
+ const mergeUrl = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${targetPackageKey}/merge`;
+ const previewUrl = `https://myTeam.celonis.cloud/pacman/api/core/staging/packages/${targetPackageKey}/merge/preview`;
+
+ const created: PackageVersionCreatedTransport = {
+ packageKey: targetPackageKey,
+ version: "1.5.0",
+ summaryOfChanges: "merge from feature-a",
+ creationDate: new Date().toISOString(),
+ createdBy: "user",
+ };
+
+ const baseBody: MergeBranchTransport = {
+ sourceKey: `${targetPackageKey}@feature-a`,
+ sourceVersion: "1.4.0",
+ resolvedNodeConflicts: [
+ { nodeKey: "node-1", resolution: MergeResolution.ACCEPT_SOURCE },
+ ],
+ versionCreate: { versionBumpOption: VersionBumpOption.MAJOR, summaryOfChanges: "merge from feature-a" },
+ };
+
+ beforeEach(() => {
+ writeJsonTempFile("merge-request.json", baseBody);
+ });
+
+ it("merges using values from the body when flags are omitted", async () => {
+ mockAxiosPost(mergeUrl, created);
+
+ await new BranchCommandService(testContext).mergeApply(targetPackageKey, {
+ file: "merge-request.json",
+ });
+
+ const sent: MergeBranchTransport = JSON.parse(mockedPostRequestBodyByUrl.get(mergeUrl) as string);
+ expect(sent.sourceKey).toBe(baseBody.sourceKey);
+ expect(sent.sourceVersion).toBe(baseBody.sourceVersion);
+ expect(sent.resolvedNodeConflicts).toEqual(baseBody.resolvedNodeConflicts);
+ expect(sent.versionCreate).toEqual(baseBody.versionCreate);
+ expect(loggingTestTransport.logMessages.some((m) => m.message.includes(`Merge applied: published ${targetPackageKey}@1.5.0`))).toBe(true);
+ });
+
+ it("overrides source from flags when provided", async () => {
+ mockAxiosPost(mergeUrl, created);
+
+ await new BranchCommandService(testContext).mergeApply(targetPackageKey, {
+ sourceKey: "override-key",
+ sourceVersion: "LATEST",
+ file: "merge-request.json",
+ });
+
+ const sent: MergeBranchTransport = JSON.parse(mockedPostRequestBodyByUrl.get(mergeUrl) as string);
+ expect(sent.sourceKey).toBe("override-key");
+ expect(sent.sourceVersion).toBe("LATEST");
+ });
+
+ it("flag --bump overrides versionCreate.versionBumpOption from the file", async () => {
+ mockAxiosPost(mergeUrl, created);
+
+ await new BranchCommandService(testContext).mergeApply(targetPackageKey, {
+ file: "merge-request.json",
+ bump: "minor",
+ });
+
+ const sent: MergeBranchTransport = JSON.parse(mockedPostRequestBodyByUrl.get(mergeUrl) as string);
+ expect(sent.versionCreate.versionBumpOption).toBe(VersionBumpOption.MINOR);
+ expect(sent.versionCreate.version).toBeUndefined();
+ });
+
+ it("flag --version overrides versionCreate.version and clears bump", async () => {
+ mockAxiosPost(mergeUrl, created);
+
+ await new BranchCommandService(testContext).mergeApply(targetPackageKey, {
+ file: "merge-request.json",
+ version: "2.0.0",
+ });
+
+ const sent: MergeBranchTransport = JSON.parse(mockedPostRequestBodyByUrl.get(mergeUrl) as string);
+ expect(sent.versionCreate.version).toBe("2.0.0");
+ expect(sent.versionCreate.versionBumpOption).toBeUndefined();
+ });
+
+ it("writes raw transport to JSON when jsonResponse=true", async () => {
+ mockAxiosPost(mergeUrl, created);
+
+ await new BranchCommandService(testContext).mergeApply(targetPackageKey, {
+ file: "merge-request.json",
+ jsonResponse: true,
+ });
+
+ expect(getJsonFromDownloadedFile()).toEqual(created);
+ });
+
+ describe("without a resolutions file", () => {
+ it("posts to the merge endpoint directly with default versionCreate", async () => {
+ mockAxiosPost(mergeUrl, created);
+
+ await new BranchCommandService(testContext).mergeApply(targetPackageKey, {
+ sourceKey: `${targetPackageKey}@feature-a`,
+ sourceVersion: "1.4.0",
+ });
+
+ expect(mockedPostRequestBodyByUrl.get(previewUrl)).toBeUndefined();
+ const sent: MergeBranchTransport = JSON.parse(mockedPostRequestBodyByUrl.get(mergeUrl) as string);
+ expect(sent.sourceKey).toBe(`${targetPackageKey}@feature-a`);
+ expect(sent.sourceVersion).toBe("1.4.0");
+ expect(sent.resolvedNodeConflicts).toBeUndefined();
+ expect(sent.versionCreate).toEqual({
+ versionBumpOption: VersionBumpOption.PATCH,
+ summaryOfChanges: `Merge ${targetPackageKey}@feature-a@1.4.0`,
+ });
+ });
+
+ it("honours flags for bump and summary", async () => {
+ mockAxiosPost(mergeUrl, created);
+
+ await new BranchCommandService(testContext).mergeApply(targetPackageKey, {
+ sourceKey: `${targetPackageKey}@feature-a`,
+ sourceVersion: "1.4.0",
+ bump: "MAJOR",
+ summary: "ship it",
+ });
+
+ const sent: MergeBranchTransport = JSON.parse(mockedPostRequestBodyByUrl.get(mergeUrl) as string);
+ expect(sent.versionCreate).toEqual({
+ versionBumpOption: VersionBumpOption.MAJOR,
+ summaryOfChanges: "ship it",
+ });
+ });
+ });
+
+});
diff --git a/tests/commands/configuration-management/branch/branch-settings.spec.ts b/tests/commands/configuration-management/branch/branch-settings.spec.ts
new file mode 100644
index 00000000..30987013
--- /dev/null
+++ b/tests/commands/configuration-management/branch/branch-settings.spec.ts
@@ -0,0 +1,36 @@
+import { mockAxiosPut, mockedPostRequestBodyByUrl } from "../../../utls/http-requests-mock";
+import { BranchCommandService } from "../../../../src/commands/configuration-management/branch/branch.command.service";
+import { testContext } from "../../../utls/test-context";
+import { loggingTestTransport } from "../../../jest.setup";
+import { getJsonFromDownloadedFile } from "../../../utls/fs-utils";
+
+describe("branch settings set", () => {
+ const packageKey = "my-package";
+ const apiUrl = `https://myTeam.celonis.cloud/pacman/api/core/packages/${packageKey}/branch-settings`;
+
+ it("enables branching and logs status", async () => {
+ mockAxiosPut(apiUrl, { branchingEnabled: true });
+
+ await new BranchCommandService(testContext).setBranchingEnabled(packageKey, true, false);
+
+ expect(JSON.parse(mockedPostRequestBodyByUrl.get(apiUrl))).toEqual({ branchingEnabled: true });
+ expect(loggingTestTransport.logMessages[0].message).toContain(`Branching enabled for package ${packageKey}.`);
+ });
+
+ it("disables branching", async () => {
+ mockAxiosPut(apiUrl, { branchingEnabled: false });
+
+ await new BranchCommandService(testContext).setBranchingEnabled(packageKey, false, false);
+
+ expect(JSON.parse(mockedPostRequestBodyByUrl.get(apiUrl))).toEqual({ branchingEnabled: false });
+ expect(loggingTestTransport.logMessages[0].message).toContain(`Branching disabled for package ${packageKey}.`);
+ });
+
+ it("writes raw transport to JSON when jsonResponse=true", async () => {
+ mockAxiosPut(apiUrl, { branchingEnabled: true });
+
+ await new BranchCommandService(testContext).setBranchingEnabled(packageKey, true, true);
+
+ expect(getJsonFromDownloadedFile()).toEqual({ branchingEnabled: true });
+ });
+});
diff --git a/tests/integration/commands/configuration-management.spec.ts b/tests/integration/commands/configuration-management.spec.ts
index 7864e492..b7283ec3 100644
--- a/tests/integration/commands/configuration-management.spec.ts
+++ b/tests/integration/commands/configuration-management.spec.ts
@@ -8,6 +8,7 @@ import { PackageVersionCommandService } from "../../../src/commands/configuratio
import { NodeDiffService } from "../../../src/commands/configuration-management/node-diff.service";
import { SinglePackageImportService } from "../../../src/commands/configuration-management/single-package-import.service";
import { SinglePackageExportService } from "../../../src/commands/configuration-management/single-package-export.service";
+import { BranchCommandService } from "../../../src/commands/configuration-management/branch/branch.command.service";
import { CliRunResult, runCli as runCliProcess } from "../../utls/cli-runner";
jest.mock("../../../src/commands/configuration-management/config-command.service");
@@ -19,6 +20,7 @@ jest.mock("../../../src/commands/configuration-management/node-diff.service");
jest.mock("../../../src/commands/configuration-management/package-version-command.service");
jest.mock("../../../src/commands/configuration-management/single-package-import.service");
jest.mock("../../../src/commands/configuration-management/single-package-export.service");
+jest.mock("../../../src/commands/configuration-management/branch/branch.command.service");
describe("configuration-management command integration", () => {
let mockConfigCommandService: jest.Mocked;
@@ -30,6 +32,7 @@ describe("configuration-management command integration", () => {
let mockPackageVersionCommandService: jest.Mocked;
let mockSinglePackageImportService: jest.Mocked;
let mockSinglePackageExportService: jest.Mocked;
+ let mockBranchCommandService: jest.Mocked;
beforeEach(() => {
mockConfigCommandService = {
@@ -73,6 +76,11 @@ describe("configuration-management command integration", () => {
exportPackage: jest.fn().mockResolvedValue(undefined),
} as any;
+ mockBranchCommandService = {
+ setBranchingEnabled: jest.fn().mockResolvedValue(undefined),
+ mergeApply: jest.fn().mockResolvedValue(undefined),
+ } as any;
+
(ConfigCommandService as jest.MockedClass).mockImplementation(() => mockConfigCommandService);
(StagingPackageService as jest.MockedClass).mockImplementation(() => mockStagingPackageService);
(MetadataService as jest.MockedClass).mockImplementation(() => mockMetadataService);
@@ -82,6 +90,7 @@ describe("configuration-management command integration", () => {
(PackageVersionCommandService as jest.MockedClass).mockImplementation(() => mockPackageVersionCommandService);
(SinglePackageImportService as jest.MockedClass).mockImplementation(() => mockSinglePackageImportService);
(SinglePackageExportService as jest.MockedClass).mockImplementation(() => mockSinglePackageExportService);
+ (BranchCommandService as jest.MockedClass).mockImplementation(() => mockBranchCommandService);
});
let lastResult: CliRunResult;
@@ -268,6 +277,71 @@ describe("configuration-management command integration", () => {
});
});
+ describe("config branch settings set (setBranchingEnabled)", () => {
+ it("forwards enabled=true", async () => {
+ const result = await runCli(["config", "branch", "settings", "set", "--packageKey", "myPackage", "--enabled", "true"]);
+
+ expect(result.exitCode).toBe(0);
+ expect(mockBranchCommandService.setBranchingEnabled).toHaveBeenCalledWith("myPackage", true, false);
+ });
+
+ it("forwards enabled=false", async () => {
+ const result = await runCli(["config", "branch", "settings", "set", "--packageKey", "myPackage", "--enabled", "false"]);
+
+ expect(result.exitCode).toBe(0);
+ expect(mockBranchCommandService.setBranchingEnabled).toHaveBeenCalledWith("myPackage", false, false);
+ });
+
+ it("accepts --enabled regardless of casing", async () => {
+ const result = await runCli(["config", "branch", "settings", "set", "--packageKey", "myPackage", "--enabled", "TRUE"]);
+
+ expect(result.exitCode).toBe(0);
+ expect(mockBranchCommandService.setBranchingEnabled).toHaveBeenCalledWith("myPackage", true, false);
+ });
+
+ it("rejects a value that is neither 'true' nor 'false' instead of disabling branching", async () => {
+ await runCli(["config", "branch", "settings", "set", "--packageKey", "myPackage", "--enabled", "yes"]);
+
+ expectError("Please provide either 'true' or 'false' for --enabled.");
+ expect(mockBranchCommandService.setBranchingEnabled).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("config branch merge apply (mergeApply)", () => {
+ it("forwards --newVersion, which the root program's --version would otherwise shadow", async () => {
+ const result = await runCli([
+ "config", "branch", "merge", "apply",
+ "--packageKey", "target",
+ "--sourceKey", "target@feature-a",
+ "--sourceVersion", "1.4.0",
+ "--newVersion", "1.5.0",
+ ]);
+
+ expect(result.exitCode).toBe(0);
+ expect(mockBranchCommandService.mergeApply).toHaveBeenCalledWith("target", expect.objectContaining({
+ sourceKey: "target@feature-a",
+ sourceVersion: "1.4.0",
+ version: "1.5.0",
+ }));
+ });
+
+ it("forwards --bump", async () => {
+ const result = await runCli([
+ "config", "branch", "merge", "apply",
+ "--packageKey", "target",
+ "--sourceKey", "target@feature-a",
+ "--sourceVersion", "1.4.0",
+ "--bump", "MINOR",
+ ]);
+
+ expect(result.exitCode).toBe(0);
+ expect(mockBranchCommandService.mergeApply).toHaveBeenCalledWith("target", expect.objectContaining({
+ bump: "MINOR",
+ version: undefined,
+ }));
+ });
+ });
+
describe("config export (deprecated batchExportPackages)", () => {
it("rejects when both --packageKeys and --keysByVersion are provided", async () => {
await runCli([