From bda091560da88edb96181900e8861d60498573b1 Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 3 Nov 2026 11:15:10 -0500 Subject: [PATCH 1/3] Sync: keep a target-only change with a warning instead of a conflict An entity changed on the target since the last sync, with the source unchanged, is now skipped with a warning rather than reported as a conflict, for content, pages, models, containers, assets and galleries. A change on both sides is still a conflict, and --overwrite replaces either with the source. The mapping isn't refreshed, so a later source change is caught as a conflict. Kept changes are collected per run (keptTargetChanges): a warning line per entity, a "Target changes kept" section at the end of the run, a preflight warning, and one `targetChangeKept` entry per entity in the --jsonSummary warnings. None of it affects the exit code. Treating these as conflicts was too aggressive: mappings written by older CLI versions often record a target version older than the target's real one, with identical content on both sides. On a clone pair synced by an older CLI, that gave 120 false conflicts on unchanged items. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/core/json-summary.ts | 7 + src/core/push.ts | 20 +- src/lib/pushers/asset-pusher.ts | 34 +++- src/lib/pushers/container-pusher.ts | 24 ++- .../content-pusher/util/change-detection.ts | 23 +-- .../filter-content-items-for-processing.ts | 25 ++- .../util/find-content-in-target-instance.ts | 2 + .../util/tests/change-detection.test.ts | 22 ++- ...ilter-content-items-for-processing.test.ts | 89 +++++++++ src/lib/pushers/gallery-pusher.ts | 29 ++- src/lib/pushers/kept-target-changes.ts | 137 ++++++++++++++ src/lib/pushers/model-pusher.ts | 21 ++- src/lib/pushers/page-pusher/process-page.ts | 42 ++++- .../page-pusher/tests/process-page.test.ts | 75 ++++++++ .../tests/asset-pusher-stale-mapping.test.ts | 24 ++- .../pushers/tests/container-pusher.test.ts | 80 ++++++++ src/lib/pushers/tests/gallery-pusher.test.ts | 59 ++++++ .../pushers/tests/kept-target-changes.test.ts | 174 ++++++++++++++++++ src/lib/pushers/tests/model-pusher.test.ts | 72 ++++++++ 20 files changed, 914 insertions(+), 47 deletions(-) create mode 100644 src/lib/pushers/kept-target-changes.ts create mode 100644 src/lib/pushers/tests/kept-target-changes.test.ts diff --git a/README.md b/README.md index d13fd211..62f1e653 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ agility sync [options] | Option | Type | Default | Description | | --------------- | ------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--overwrite` | boolean | `false` | Conflict-scoped override. By default, when a target item has its own changes that conflict with the source, the CLI skips it to avoid data loss. With `--overwrite`, those conflicting target items are overwritten with the source version. Non-conflicting updates are applied either way. Also covers a mapped target item that no longer exists (deleted or unpublished on the target): by default that is reported as a conflict and skipped, with `--overwrite` the item is recreated and the mapping repointed. | +| `--overwrite` | boolean | `false` | Conflict-scoped override. By default, when a target item has its own changes that conflict with the source (both sides changed since the last sync), the CLI skips it as a conflict to avoid data loss. An item changed only on the target, with the source unchanged, is kept with a warning instead (listed under "Target changes kept" and as `targetChangeKept` warnings in `--jsonSummary`); it isn't a conflict, and a later source change to it is. This applies to content, pages, models, containers, assets and galleries. With `--overwrite`, both kinds are overwritten with the source version. Non-conflicting updates are applied either way. Also covers a mapped target item that no longer exists (deleted or unpublished on the target): by default that is reported as a conflict and skipped, with `--overwrite` the item is recreated and the mapping repointed. | | `--syncDeletes` | boolean | `false` | After syncing, delete from the target the entities that were deleted in the source since the last sync (URL redirections, pages, content, containers, templates, models, assets, galleries — dependents first). Only entities the CLI previously synced (that have a mapping) are considered; nothing created directly on the target is touched. A target entity edited since the last sync is reported as a conflict and kept unless `--overwrite` is also set. An entity that something kept on the target still uses (a child page, a page or item that links to the content, a container's items, ...) is kept and reported. Content and page deletes are confirmed against the source first, so an item that was only unpublished is never deleted. Cannot be combined with `--pages`, `--containers`, `--models` or `--models-with-deps`; use `--elements` to limit the types. Works with `--preflight`. Not read from `.env`. See [Syncing deletes](#syncing-deletes). | | `--allowMassDeletes` | boolean | `false` | With `--syncDeletes`: allow a run to delete more than half (and more than 10) of the mapped entities of one type. Without it such a run deletes nothing and fails, because a stale or partial local cache looks the same as a mass delete. | | `--autoPublish` | string | _(disabled)_ | Automatically publish synced items that were published in the source instance. Values: `content`, `pages`, `both`. If flag is provided without a value, defaults to `both`. Items that are only in staging (not published) in the source are skipped. | diff --git a/src/core/json-summary.ts b/src/core/json-summary.ts index c421a8c1..1572f968 100644 --- a/src/core/json-summary.ts +++ b/src/core/json-summary.ts @@ -59,6 +59,13 @@ export interface JsonSummaryDetail { field: string; kind: "attachment" | "text"; }>; + /** + * Additive, on "targetChangeKept" warnings: an entity changed only on the target since the last + * sync, which the sync kept. `guid` is the target; `targetUrl` links to it when there is a CMS page. + */ + sourceID?: number; + targetID?: number; + targetUrl?: string; } /** diff --git a/src/core/push.ts b/src/core/push.ts index 37fc063d..7b154584 100644 --- a/src/core/push.ts +++ b/src/core/push.ts @@ -22,6 +22,7 @@ import { preflightReport } from "../lib/preflight/preflight-report"; import { buildJsonSummary, writeJsonSummary, JsonSummaryDetail, JsonSummaryPhase } from "./json-summary"; import type { FailureDetail } from "../types/sourceData"; import { foreignCdnReport } from "../lib/content/foreign-cdn-report"; +import { keptTargetChanges } from "../lib/pushers/kept-target-changes"; import { resetForeignCdnDetectorCache } from "../lib/pushers/content-pusher/util/report-foreign-cdn-references"; import type { OtherLocaleStagingEntry } from "./state"; @@ -112,6 +113,8 @@ export class Push { // PROD-2773: foreign CDN references are reported once per URL per run foreignCdnReport.reset(); resetForeignCdnDetectorCache(); + // Entities whose target-only change the sync kept, reported once per entity per run + keptTargetChanges.reset(); // Initialize logger for push operation // Determine if this is a sync operation by checking if both source and target GUIDs exist @@ -364,6 +367,13 @@ export class Push { rest.forEach((line) => console.log(ansiColors.yellow(` ${line}`))); } + // Target-only changes the sync kept instead of overwriting: non-blocking, --overwrite replaces them + if (keptTargetChanges.hasEntries()) { + const [headline, ...rest] = keptTargetChanges.renderSummaryLines(); + console.log(ansiColors.yellow(`\n ${headline}`)); + rest.forEach((line) => console.log(ansiColors.yellow(` ${line}`))); + } + // PROD-2311: post-publish mapping/version bookkeeping notices are NOT publish // failures — show them under a non-blocking header so they don't read as broken. if (mappingWarnings.length > 0) { @@ -398,6 +408,9 @@ export class Push { if (foreignCdnReport.hasEntries()) { preflightReport.recordWarning(`${foreignCdnReport.headline()} — see the list above`); } + if (keptTargetChanges.hasEntries()) { + preflightReport.recordWarning(`${keptTargetChanges.headline()} — see the list above`); + } preflightReport.print(); if (preflightReport.hasConflicts()) { process.exitCode = 1; @@ -413,7 +426,12 @@ export class Push { phases: results.flatMap((r) => r.phases ?? []), failures: syncFailureDetails, // PROD-2773: one additive entry per foreign CDN URL (code "foreignCdnReference") - warnings: [...syncWarningDetails, ...foreignCdnReport.toWarningDetails(sourceGuid)], + // One entry per entity whose target-only change was kept (code "targetChangeKept") + warnings: [ + ...syncWarningDetails, + ...foreignCdnReport.toWarningDetails(sourceGuid), + ...keptTargetChanges.toWarningDetails(targetGuid), + ], operationErrors: syncErrors, autoPublishErrors, startedAt, diff --git a/src/lib/pushers/asset-pusher.ts b/src/lib/pushers/asset-pusher.ts index ea034afd..8ee9a5db 100644 --- a/src/lib/pushers/asset-pusher.ts +++ b/src/lib/pushers/asset-pusher.ts @@ -9,6 +9,7 @@ import { fileOperations } from "../../core/fileOperations"; import path from "path"; import { GalleryMapper } from "lib/mappers/gallery-mapper"; import { preflightReport } from "../preflight/preflight-report"; +import { keptTargetChanges } from "./kept-target-changes"; import { FailureDetail, PusherResult } from "../../types/sourceData"; /** @@ -262,9 +263,10 @@ export async function pushAssets( const hasTargetChanges = existingMapping !== null && referenceMapper.hasTargetChanged(targetAsset); const hasSourceChanges = existingMapping !== null && referenceMapper.hasSourceChanged(media); - // The target changed since the last sync: a conflict whether or not the source changed too, so - // a target-only edit is kept (and reported) unless --overwrite says to replace it. - const isConflict = existingMapping !== null && hasTargetChanges; + // Both sides changed since the last sync: a conflict. Only the target changed: kept, with a + // warning (see kept-target-changes.ts). Either is replaced with the source under --overwrite. + const isConflict = existingMapping !== null && hasTargetChanges && hasSourceChanges; + const isKeptTargetChange = existingMapping !== null && hasTargetChanges && !hasSourceChanges; const isOverwrite = state.overwrite; const shouldSkip = existingMapping !== null && !hasTargetChanges && !hasSourceChanges; // Update if: @@ -273,7 +275,8 @@ export async function pushAssets( // AND Either IF // We have no conflict // OR we are in overwrite mode (force update even if conflict) - const shouldUpdate = existingMapping !== null && !shouldSkip && (!isConflict || isOverwrite); + const shouldUpdate = + existingMapping !== null && !shouldSkip && ((!isConflict && !isKeptTargetChange) || isOverwrite); if (shouldCreate) { // Asset needs to be created (doesn't exist in target) @@ -323,18 +326,35 @@ export async function pushAssets( logger.asset.skipped(media, "up to date, skipping", targetGuid); preflightReport.record({ phase: "Assets", action: "skip", name: media.fileName, detail: "up to date" }); skipped++; + } else if (isKeptTargetChange) { + const change = { + type: "asset" as const, + name: media.fileName, + sourceID: media.mediaID, + targetID: targetAsset?.mediaID, + }; + keptTargetChanges.record(change); + console.warn(ansiColors.yellow(keptTargetChanges.formatLine(change))); + logger.asset.skipped(media, "target changed since the last sync, kept", targetGuid); + preflightReport.record({ + phase: "Assets", + action: "skip", + name: media.fileName, + detail: "target changed since the last sync and the source didn't; kept the target's version", + }); + skipped++; } else if (isConflict) { - // Conflict: the target changed since the last sync and --overwrite was not set. + // Conflict: both sides changed since the last sync and --overwrite was not set. logger.asset.skipped( media, - "conflict: target changed since the last sync; use --overwrite to replace it", + "conflict: source and target both changed since the last sync; use --overwrite to replace the target", targetGuid ); preflightReport.record({ phase: "Assets", action: "conflict", name: media.fileName, - detail: "target changed; use --overwrite to force", + detail: "source and target both changed; use --overwrite to force", }); skipped++; } diff --git a/src/lib/pushers/container-pusher.ts b/src/lib/pushers/container-pusher.ts index 246dfbeb..468a33aa 100644 --- a/src/lib/pushers/container-pusher.ts +++ b/src/lib/pushers/container-pusher.ts @@ -6,6 +6,8 @@ import { ModelMapper } from "lib/mappers/model-mapper"; import { Logs } from "core/logs"; import { FailureDetail, PusherResult } from "types/sourceData"; import { preflightReport } from "../preflight/preflight-report"; +import { keptTargetChanges } from "./kept-target-changes"; +import ansiColors from "ansi-colors"; /** * Container pusher with enhanced version-based comparison @@ -172,14 +174,32 @@ export async function pushContainers( detail: "up to date", }); skipped++; + } else if (hasTargetChanges && !hasSourceChanges && !overwrite) { + // Only the target changed: keep its version and warn (see kept-target-changes.ts) + const change = { + type: "container" as const, + name: sourceContainer.referenceName, + sourceID: sourceContainer.contentViewID, + targetID: targetContainer?.contentViewID, + }; + keptTargetChanges.record(change); + console.warn(ansiColors.yellow(keptTargetChanges.formatLine(change))); + logger.container.skipped(sourceContainer, "target changed since the last sync, kept", targetGuid); + preflightReport.record({ + phase: "Containers", + action: "skip", + name: sourceContainer.referenceName, + detail: "target changed since the last sync and the source didn't; kept the target's version", + }); + skipped++; } else if (hasTargetChanges && !overwrite) { - // Container exists and is up to date - skip + // Both sides changed since the last sync: a conflict logger.container.error(sourceContainer, "Conflict detected, use --overwrite to force changes", targetGuid); preflightReport.record({ phase: "Containers", action: "conflict", name: sourceContainer.referenceName, - detail: "target changed; use --overwrite to force", + detail: "source and target both changed; use --overwrite to force", }); skipped++; } else if (shouldUpdate && state.preflight) { diff --git a/src/lib/pushers/content-pusher/util/change-detection.ts b/src/lib/pushers/content-pusher/util/change-detection.ts index e1502859..b96f64f5 100644 --- a/src/lib/pushers/content-pusher/util/change-detection.ts +++ b/src/lib/pushers/content-pusher/util/change-detection.ts @@ -12,6 +12,8 @@ export interface ChangeDetection { shouldSkip: boolean; isConflict: boolean; reason: string; + /** Skipped because only the target changed: the caller warns and records it in keptTargetChanges */ + keptTargetChange?: boolean; } export function changeDetection( @@ -136,11 +138,12 @@ export function changeDetection( } } - // TARGET UPDATE ONLY: edited on the target since the last sync, the source unchanged. A conflict, - // like a change on both sides (and like pages, models and containers): reported and skipped, or - // replaced with the source under --overwrite. It used to fall through to "up to date", so the - // target kept a version the source never had and nothing said so. A mapping with no recorded - // target version (older mapping files) can't tell, so it is left alone. + // TARGET UPDATE ONLY: edited on the target since the last sync, the source unchanged. Skipped with a + // warning (kept in keptTargetChanges), or replaced with the source under --overwrite. Not a + // conflict: mappings written by older CLI versions often hold a target version older than the + // target's real one, with the same content on both sides, and failing every such item would block + // syncs that have nothing to do. If the source changes later, the branch above reports a conflict. + // A mapping with no recorded target version (older mapping files) can't tell, so it is left alone. if ( sourceVersion > 0 && targetVersion > 0 && @@ -158,17 +161,15 @@ export function changeDetection( reason: "Overwrite mode enabled: replacing the target's own changes with the source", }; } - const sourceUrl = `https://app.agilitycms.com/instance/${state.sourceGuid}/${locale}/content/listitem-${sourceEntity.contentID}`; const targetUrl = `https://app.agilitycms.com/instance/${state.targetGuid}/${locale}/content/listitem-${targetEntity.contentID}`; return { entity: targetEntity, shouldUpdate: false, shouldCreate: false, - shouldSkip: false, - isConflict: true, - reason: - `The target changed since the last sync and the source didn't. Use --overwrite to replace it with the source.\n` + - ` - source: ${sourceUrl} \n - target: ${targetUrl}`, + shouldSkip: true, + isConflict: false, + keptTargetChange: true, + reason: `The target changed since the last sync and the source didn't; kept the target's version. ${targetUrl}`, }; } diff --git a/src/lib/pushers/content-pusher/util/filter-content-items-for-processing.ts b/src/lib/pushers/content-pusher/util/filter-content-items-for-processing.ts index 563883ab..0faca2a7 100644 --- a/src/lib/pushers/content-pusher/util/filter-content-items-for-processing.ts +++ b/src/lib/pushers/content-pusher/util/filter-content-items-for-processing.ts @@ -5,6 +5,7 @@ import { ApiClient, ContentItem } from "@agility/management-sdk"; import { Logs } from "core/logs"; import { state } from "core"; import { preflightReport } from "../../../preflight/preflight-report"; +import { keptTargetChanges } from "../../kept-target-changes"; import { RequiredFieldIssue, formatMissingRequiredFields, @@ -122,16 +123,14 @@ export async function filterContentItemsForProcessing({ referenceMapper, }); - const { content, shouldUpdate, shouldCreate, shouldSkip, isConflict, reason } = findResult; + const { content, shouldUpdate, shouldCreate, shouldSkip, isConflict, reason, keptTargetChange } = findResult; if (isConflict) { // CONFLICT DETECTED - log warning and skip // PROD-2603: a mapped target item that no longer exists is also reported as a conflict, so // pick the headline from the reason rather than hard-coding "changes detected in both". const headline = /no longer exists/i.test(reason || "") ? "mapped target item no longer exists" - : /source didn't/i.test(reason || "") - ? "the target changed since the last sync" - : "changes detected in both source and target"; + : "changes detected in both source and target"; console.warn( `⚠️ Conflict detected content ${ansiColors.underline(itemName)} ${ansiColors.bold.grey(headline)}. Please resolve manually.` ); @@ -154,6 +153,24 @@ export async function filterContentItemsForProcessing({ } else if (shouldUpdate) { // Content exists but needs updating (unless it fails model validation) if (queueForWrite(contentItem, itemName, "update")) updateCount++; + } else if (shouldSkip && keptTargetChange) { + // Only the target changed: keep its version and warn (not a conflict, see change-detection.ts) + const change = { + type: "content" as const, + name: itemName, + locale, + sourceID: contentItem.contentID, + targetID: content?.contentID, + targetUrl: content?.contentID + ? `https://app.agilitycms.com/instance/${targetGuid}/${locale}/content/listitem-${content.contentID}` + : undefined, + }; + keptTargetChanges.record(change); + console.warn(ansiColors.yellow(keptTargetChanges.formatLine(change))); + logger.content.skipped(contentItem, "target changed since the last sync, kept", locale, targetGuid); + itemsToSkip.push(contentItem); + skipCount++; + preflightReport.record({ phase: "Content", action: "skip", name: itemName, locale, detail: reason }); } else if (shouldSkip) { // Content exists and is up to date - skip logger.content.skipped(contentItem, "up to date, skipping", locale, targetGuid); diff --git a/src/lib/pushers/content-pusher/util/find-content-in-target-instance.ts b/src/lib/pushers/content-pusher/util/find-content-in-target-instance.ts index 281dd06a..73e9e4cf 100644 --- a/src/lib/pushers/content-pusher/util/find-content-in-target-instance.ts +++ b/src/lib/pushers/content-pusher/util/find-content-in-target-instance.ts @@ -17,6 +17,7 @@ interface FindResult { isConflict: boolean; decision?: ChangeDetection; reason?: string; + keptTargetChange?: boolean; } /** @@ -56,6 +57,7 @@ export function findContentInTargetInstance({ sourceContent, referenceMapper }: shouldSkip: decision.shouldSkip, isConflict: decision.isConflict, reason: decision.reason, + keptTargetChange: decision.keptTargetChange, decision: decision, }; } diff --git a/src/lib/pushers/content-pusher/util/tests/change-detection.test.ts b/src/lib/pushers/content-pusher/util/tests/change-detection.test.ts index 9e26073f..6bd3f73a 100644 --- a/src/lib/pushers/content-pusher/util/tests/change-detection.test.ts +++ b/src/lib/pushers/content-pusher/util/tests/change-detection.test.ts @@ -103,17 +103,31 @@ describe("changeDetection — conflict path", () => { expect(result.isConflict).toBe(false); }); - it("conflicts when only the target version increased (an edit on the target)", () => { + it("keeps the target's version, with a warning, when only the target version increased", () => { setState({ sourceGuid: "src-guid", targetGuid: "tgt-guid" }); const source = makeContent(1, 5); const target = makeContent(100, 10); const mapping = makeMapping({ sourceVersionID: 5, targetVersionID: 5 }); const result = changeDetection(source, target, mapping, "en-us"); - expect(result.isConflict).toBe(true); + expect(result.isConflict).toBe(false); expect(result.shouldUpdate).toBe(false); - expect(result.shouldSkip).toBe(false); + expect(result.shouldSkip).toBe(true); + expect(result.keptTargetChange).toBe(true); expect(result.reason).toMatch(/target changed since the last sync and the source didn't/); - expect(result.reason).toMatch(/--overwrite/); + expect(result.reason).toContain("/instance/tgt-guid/en-us/content/listitem-100"); + }); + + it("is a conflict when the source changes after a kept target change", () => { + setState({ sourceGuid: "src-guid", targetGuid: "tgt-guid" }); + // the kept change didn't refresh the mapping, so a later source edit finds both sides changed + const result = changeDetection( + makeContent(1, 6), + makeContent(100, 10), + makeMapping({ sourceVersionID: 5, targetVersionID: 5 }), + "en-us" + ); + expect(result.isConflict).toBe(true); + expect(result.keptTargetChange).toBeFalsy(); }); it("replaces a target-only edit with the source under --overwrite", () => { diff --git a/src/lib/pushers/content-pusher/util/tests/filter-content-items-for-processing.test.ts b/src/lib/pushers/content-pusher/util/tests/filter-content-items-for-processing.test.ts index 6e887c1a..c16eed4b 100644 --- a/src/lib/pushers/content-pusher/util/tests/filter-content-items-for-processing.test.ts +++ b/src/lib/pushers/content-pusher/util/tests/filter-content-items-for-processing.test.ts @@ -171,6 +171,95 @@ describe("filterContentItemsForProcessing — isConflict", () => { }); }); +// ─── target-only change is kept with a warning ──────────────────────────────── + +describe("filterContentItemsForProcessing — target changed since the last sync", () => { + const keptDecision = (content: any) => ({ + content, + shouldCreate: false, + shouldUpdate: false, + shouldSkip: true, + isConflict: false, + keptTargetChange: true, + reason: "The target changed since the last sync and the source didn't; kept the target's version.", + }); + + beforeEach(async () => { + const { keptTargetChanges } = await import("lib/pushers/kept-target-changes"); + keptTargetChanges.reset(); + preflightReport.reset(); + setState({ preflight: true }); + }); + + it("target-only change without --overwrite is skipped, recorded, and reported as a skip", async () => { + const { keptTargetChanges } = await import("lib/pushers/kept-target-changes"); + const source = makeContentItem(1, "my-item"); + const target = makeContentItem(77, "my-item"); + mockFind.mockReturnValue(keptDecision(target)); + + const result = await filterContentItemsForProcessing(makeBaseProps([source])); + + expect(result.itemsToSkip).toContain(source); + expect(result.itemsToProcess).toHaveLength(0); + expect(result.skippedCount).toBe(1); + expect(keptTargetChanges.getEntries()).toEqual([ + expect.objectContaining({ + type: "content", + name: "my-item", + locale: "en-us", + sourceID: 1, + targetID: 77, + targetUrl: "https://app.agilitycms.com/instance/tgt-guid/en-us/content/listitem-77", + }), + ]); + expect(preflightReport.getEntries()).toEqual([ + expect.objectContaining({ + phase: "Content", + action: "skip", + name: "my-item", + detail: expect.stringMatching(/kept the target's version/), + }), + ]); + }); + + it("change on both sides is still a conflict and records nothing", async () => { + const { keptTargetChanges } = await import("lib/pushers/kept-target-changes"); + const source = makeContentItem(1, "my-item"); + mockFind.mockReturnValue({ + content: makeContentItem(77, "my-item"), + shouldCreate: false, + shouldUpdate: false, + shouldSkip: false, + isConflict: true, + reason: "changes in both", + }); + + const result = await filterContentItemsForProcessing(makeBaseProps([source])); + + expect(result.itemsToSkip).toContain(source); + expect(keptTargetChanges.hasEntries()).toBe(false); + expect(preflightReport.getEntries()).toEqual([expect.objectContaining({ action: "conflict", name: "my-item" })]); + }); + + it("target-only change with --overwrite arrives as an update, is processed, and records nothing", async () => { + const { keptTargetChanges } = await import("lib/pushers/kept-target-changes"); + const source = makeContentItem(1, "my-item"); + mockFind.mockReturnValue({ + content: makeContentItem(77, "my-item"), + shouldCreate: false, + shouldUpdate: true, + shouldSkip: false, + isConflict: false, + reason: "Overwrite mode enabled: replacing the target's own changes with the source", + }); + + const result = await filterContentItemsForProcessing(makeBaseProps([source])); + + expect(result.itemsToProcess).toContain(source); + expect(keptTargetChanges.hasEntries()).toBe(false); + }); +}); + // ─── error handling ─────────────────────────────────────────────────────────── describe("filterContentItemsForProcessing — error handling", () => { diff --git a/src/lib/pushers/gallery-pusher.ts b/src/lib/pushers/gallery-pusher.ts index 22573dbf..2c41b236 100644 --- a/src/lib/pushers/gallery-pusher.ts +++ b/src/lib/pushers/gallery-pusher.ts @@ -4,6 +4,7 @@ import { Logs } from "core/logs"; import { state, getState, getApiClient, getLoggerForGuid, recordSavedTarget } from "core/state"; import { GalleryMapper } from "lib/mappers/gallery-mapper"; import { preflightReport } from "../preflight/preflight-report"; +import { keptTargetChanges } from "./kept-target-changes"; import { FailureDetail, PusherResult } from "types/sourceData"; /** @@ -103,10 +104,11 @@ export async function pushGalleries( const targetGallery = targetGalleryById || targetGalleryByName; const hasTargetChanges = referenceMapper.hasTargetChanged(targetGallery); const hasSourceChanges = referenceMapper.hasSourceChanged(sourceGallery); - // Source changed alone: update. Target changed (alone or with the source): a conflict, - // replaced only with --overwrite. Neither: up to date. + // Source changed alone: update. Both changed: a conflict. Target changed alone: kept, with a + // warning (see kept-target-changes.ts). --overwrite replaces either with the source. const shouldUpdate = (hasSourceChanges && !hasTargetChanges) || (overwrite && hasTargetChanges); const shouldSkip = !hasSourceChanges && !hasTargetChanges; + const isKeptTargetChange = hasTargetChanges && !hasSourceChanges && !overwrite; if (shouldUpdate) { // Gallery exists but needs updating @@ -133,11 +135,28 @@ export async function pushGalleries( detail: "up to date", }); skipped++; + } else if (isKeptTargetChange) { + const change = { + type: "gallery" as const, + name: sourceGallery.name, + sourceID: sourceGallery.mediaGroupingID, + targetID: existingMapping.targetMediaGroupingID, + }; + keptTargetChanges.record(change); + console.warn(ansiColors.yellow(keptTargetChanges.formatLine(change))); + logger.gallery.skipped(sourceGallery, "target changed since the last sync, kept", targetGuid); + skipped++; + preflightReport.record({ + phase: "Galleries", + action: "skip", + name: sourceGallery.name, + detail: "target changed since the last sync and the source didn't; kept the target's version", + }); } else { - // Conflict: the target changed since the last sync and --overwrite was not set. + // Conflict: both sides changed since the last sync and --overwrite was not set. logger.gallery.skipped( sourceGallery, - "conflict: target changed since the last sync; use --overwrite to replace it", + "conflict: source and target both changed since the last sync; use --overwrite to replace the target", targetGuid ); skipped++; @@ -145,7 +164,7 @@ export async function pushGalleries( phase: "Galleries", action: "conflict", name: sourceGallery.name, - detail: "target changed; use --overwrite to force", + detail: "source and target both changed; use --overwrite to force", }); } } diff --git a/src/lib/pushers/kept-target-changes.ts b/src/lib/pushers/kept-target-changes.ts new file mode 100644 index 00000000..b75c9e3f --- /dev/null +++ b/src/lib/pushers/kept-target-changes.ts @@ -0,0 +1,137 @@ +/** + * Per-run collector for target-only changes: entities edited on the target since the last sync + * while the source didn't change. The sync keeps the target's version and warns, instead of + * reporting a conflict, because a newer target version alone doesn't mean an editor changed it: + * mappings written by older CLI versions often record a target version older than the target's + * real one, with identical content on both sides. --overwrite replaces them with the source. + * + * Reporting only: nothing here affects counts, `success` or the exit code. A change on both sides + * is still a conflict, decided by each pusher. A process-level singleton, like preflightReport. + */ + +export type KeptTargetChangeType = "content" | "asset" | "gallery" | "container" | "model" | "page"; + +export interface KeptTargetChange { + type: KeptTargetChangeType; + name: string; + locale?: string; + sourceID?: number; + targetID?: number; + /** CMS link to the target entity, when there is one (content and pages) */ + targetUrl?: string; +} + +/** Shape of a --jsonSummary `warnings` entry (JsonSummaryDetail plus additive fields). */ +export interface KeptTargetChangeWarningDetail { + name: string; + error: string; + type: KeptTargetChangeType; + guid?: string; + locale?: string; + contentID?: number; + pageID?: number; + code: "targetChangeKept"; + sourceID?: number; + targetID?: number; + targetUrl?: string; +} + +export const KEPT_TARGET_CHANGE_WARNING_CODE = "targetChangeKept"; + +/** Why the entity was left alone and how to replace it; shared by every surface. */ +export const KEPT_TARGET_CHANGE_MESSAGE = + "changed on the target since the last sync, and the source didn't change, so the target's version was kept. " + + "Use --overwrite to replace it with the source"; + +const TYPE_LABELS: Record = { + content: "content item", + asset: "asset", + gallery: "gallery", + container: "container", + model: "model", + page: "page", +}; + +const TYPE_PLURALS: Record = { + content: "content items", + asset: "assets", + gallery: "galleries", + container: "containers", + model: "models", + page: "pages", +}; + +class KeptTargetChanges { + private entries: KeptTargetChange[] = []; + + reset(): void { + this.entries = []; + } + + /** Record one kept change. A repeat of the same entity (same type, locale and ID or name) is ignored. */ + record(change: KeptTargetChange): void { + const key = (c: KeptTargetChange) => `${c.type}|${c.locale ?? ""}|${c.sourceID ?? c.name}`; + if (this.entries.some((e) => key(e) === key(change))) return; + this.entries.push(change); + } + + getEntries(): KeptTargetChange[] { + return [...this.entries]; + } + + hasEntries(): boolean { + return this.entries.length > 0; + } + + /** "Target changes kept: 3 content items, 1 page" */ + headline(): string { + const counts = new Map(); + for (const e of this.entries) counts.set(e.type, (counts.get(e.type) ?? 0) + 1); + const parts = Array.from(counts.entries()).map( + ([type, n]) => `${n} ${n === 1 ? TYPE_LABELS[type] : TYPE_PLURALS[type]}` + ); + return `Target changes kept: ${parts.join(", ")}`; + } + + /** One console/log line for an entity, when it is skipped. */ + formatLine(change: KeptTargetChange): string { + const where = change.locale ? ` [${change.locale}]` : ""; + const id = change.targetID ? ` (target ID ${change.targetID})` : ""; + return `⚠️ Kept target change: ${TYPE_LABELS[change.type]} ${change.name}${where}${id} ${KEPT_TARGET_CHANGE_MESSAGE}.`; + } + + /** Lines for the end-of-run summary: headline, then each entity. */ + renderSummaryLines(): string[] { + if (!this.hasEntries()) return []; + const lines = [`${this.headline()} (non-blocking):`]; + for (const e of this.entries) { + const where = e.locale ? `[${e.locale}] ` : ""; + lines.push(` • ${where}${TYPE_LABELS[e.type]} ${e.name}${e.targetUrl ? ` ${e.targetUrl}` : ""}`); + } + lines.push( + " Each changed on the target since the last sync while the source didn't, so the target's version was kept. " + + "Use --overwrite to replace them with the source. If the source changes later, the sync reports a conflict." + ); + return lines; + } + + /** One --jsonSummary warning per kept entity. */ + toWarningDetails(targetGuid?: string): KeptTargetChangeWarningDetail[] { + return this.entries.map((e) => ({ + name: e.name, + error: `${TYPE_LABELS[e.type]} ${e.name} ${KEPT_TARGET_CHANGE_MESSAGE}.`, + type: e.type, + guid: targetGuid, + locale: e.locale, + ...(e.type === "content" && e.targetID ? { contentID: e.targetID } : {}), + ...(e.type === "page" && e.targetID ? { pageID: e.targetID } : {}), + code: KEPT_TARGET_CHANGE_WARNING_CODE, + sourceID: e.sourceID, + targetID: e.targetID, + targetUrl: e.targetUrl, + })); + } +} + +/** Process-level singleton shared by the pushers and the end-of-run summary. */ +export const keptTargetChanges = new KeptTargetChanges(); diff --git a/src/lib/pushers/model-pusher.ts b/src/lib/pushers/model-pusher.ts index 354cf73e..11ecaf18 100644 --- a/src/lib/pushers/model-pusher.ts +++ b/src/lib/pushers/model-pusher.ts @@ -4,6 +4,8 @@ import { PusherResult, FailureDetail } from "../../types/sourceData"; import { ModelMapper } from "lib/mappers/model-mapper"; import { Logs } from "core/logs"; import { preflightReport } from "../preflight/preflight-report"; +import { keptTargetChanges } from "./kept-target-changes"; +import ansiColors from "ansi-colors"; /** * Two Agility models can share a `referenceName` while differing in `contentDefinitionTypeID` @@ -381,7 +383,24 @@ export async function pushModels(sourceData: mgmtApi.Model[], targetData: mgmtAp continue; } - // if the mapping exists, and the target has changed, we need to skip the model, not safe to update + // Only the target changed: keep its version and warn (see kept-target-changes.ts) + if (sourceMapping && hasTargetChanged && !hasSourceChanged) { + const change = { + type: "model" as const, + name: sourceModel.referenceName, + sourceID: sourceModel.id, + targetID: targetModel?.id, + }; + keptTargetChanges.record(change); + console.warn(ansiColors.yellow(keptTargetChanges.formatLine(change))); + shouldSkip.push({ + model: sourceModel, + reason: "Target model changed since the last sync and the source didn't; kept the target's version.", + }); + continue; + } + + // if the mapping exists, and both sides have changed, we need to skip the model, not safe to update if (sourceMapping && hasTargetChanged) { shouldSkip.push({ model: sourceModel, diff --git a/src/lib/pushers/page-pusher/process-page.ts b/src/lib/pushers/page-pusher/process-page.ts index ea4363b7..3f1bf596 100644 --- a/src/lib/pushers/page-pusher/process-page.ts +++ b/src/lib/pushers/page-pusher/process-page.ts @@ -17,6 +17,7 @@ import { } from "core/state"; import { FailureDetail, PageModuleExtended } from "types/sourceData"; import { preflightReport } from "../../preflight/preflight-report"; +import { keptTargetChanges } from "../kept-target-changes"; interface Props { channel: string; @@ -131,11 +132,14 @@ export async function processPage({ const hasSourceChanged = pageMapper.hasSourceChanged(page); const hasTargetChanged = pageMapper.hasTargetChanged(existingPage, pageMapping); - // A conflict exists whenever the target has changed independently — regardless of whether - // the source also changed. Even if source is unchanged today, a future source push would - // silently overwrite the target's independent changes without this guard. - const isConflict = hasTargetChanged !== null; - const updateRequired = (hasSourceChanged && !isConflict) || (overwrite && isConflict); + // The target changed since the last sync. With the source changed too (or the target page + // missing from the pull, so its state can't be checked) it's a conflict. With the source + // unchanged, the target's version is kept with a warning (see kept-target-changes.ts); the + // mapping isn't refreshed, so a later source change is still caught as a conflict. + // --overwrite replaces either with the source. + const isKeptTargetChange = hasTargetChanged !== null && hasTargetChanged !== "file_missing" && !hasSourceChanged; + const isConflict = hasTargetChanged !== null && !isKeptTargetChange; + const updateRequired = (hasSourceChanged && !isConflict) || (overwrite && hasTargetChanged !== null); const createRequired = !existingPage; const pageTypeDisplay = @@ -145,7 +149,29 @@ export async function processPage({ folder: "Folder", }[page.pageType] || page.pageType; - if (isConflict && !overwrite) { + if (isKeptTargetChange && !overwrite) { + const targetPageID = existingPage?.pageID ?? pageMapping?.targetPageID; + const change = { + type: "page" as const, + name: page.name, + locale, + sourceID: page.pageID, + targetID: targetPageID, + targetUrl: targetPageID + ? `https://app.agilitycms.com/instance/${targetGuid}/${locale}/pages/${targetPageID}` + : undefined, + }; + keptTargetChanges.record(change); + console.warn(ansiColors.yellow(keptTargetChanges.formatLine(change))); + preflightReport.record({ + phase: "Pages", + action: "skip", + name: page.name, + locale, + detail: "target changed since the last sync and the source didn't; kept the target's version", + }); + return { status: "skip" }; // Keep the target's page; don't auto-publish it either + } else if (isConflict && !overwrite) { // CONFLICT: Target has independent changes. // Use mapping's targetPageID as fallback in case existingPage wasn't loaded. const targetPageID = existingPage?.pageID ?? pageMapping?.targetPageID; @@ -155,10 +181,8 @@ export async function processPage({ let reason: string; if (hasTargetChanged === "file_missing") { reason = "target page may have been unpublished or deleted — cannot verify its current state"; - } else if (hasSourceChanged) { - reason = "changes detected in both source and target"; } else { - reason = "target has been changed independently"; + reason = "changes detected in both source and target"; } console.warn( diff --git a/src/lib/pushers/page-pusher/tests/process-page.test.ts b/src/lib/pushers/page-pusher/tests/process-page.test.ts index 01ae18aa..7011024d 100644 --- a/src/lib/pushers/page-pusher/tests/process-page.test.ts +++ b/src/lib/pushers/page-pusher/tests/process-page.test.ts @@ -304,6 +304,81 @@ describe("processPage — conflict detection", () => { }); }); +// ─── target-only change is kept with a warning ──────────────────────────────── + +describe("processPage — page changed on the target", () => { + async function run(opts: { sourceChanged: boolean; targetChanged: string | null; overwrite?: boolean }) { + const { TemplateMapper } = require("lib/mappers/template-mapper"); + TemplateMapper.mockImplementation(() => ({ + getTemplateMappingByPageTemplateName: jest.fn().mockReturnValue({ ref: "Main" }), + getMappedEntity: jest.fn().mockReturnValue({ contentSectionDefinitions: [] }), + })); + const { keptTargetChanges } = await import("lib/pushers/kept-target-changes"); + keptTargetChanges.reset(); + preflightReport.reset(); + setState({ preflight: true }); + + const pageMapper = makePageMapper({ + getPageMapping: jest.fn().mockReturnValue({ targetPageID: 99, sourcePageID: 1 }), + getMappedEntity: jest.fn().mockReturnValue(makePage({ pageID: 99 })), + hasSourceChanged: jest.fn().mockReturnValue(opts.sourceChanged), + hasTargetChanged: jest.fn().mockReturnValue(opts.targetChanged), + }); + const props = makeProps({ pageMapper, overwrite: !!opts.overwrite, page: makePage({ pageID: 1, name: "About Us" }) }); + const result = await processPage(props); + const entry = preflightReport.getEntries().find((e) => e.name === "About Us"); + return { result, entry, kept: keptTargetChanges, pageMapper, props }; + } + + it("target-only change without --overwrite is a skip that records a kept change", async () => { + const { result, entry, kept, pageMapper } = await run({ sourceChanged: false, targetChanged: "changed" }); + + expect(result.status).toBe("skip"); + expect(entry).toEqual( + expect.objectContaining({ + action: "skip", + locale: "en-us", + detail: expect.stringMatching(/kept the target's version/), + }) + ); + expect(kept.getEntries()).toEqual([ + expect.objectContaining({ + type: "page", + name: "About Us", + locale: "en-us", + sourceID: 1, + targetID: 99, + targetUrl: "https://app.agilitycms.com/instance/tgt/en-us/pages/99", + }), + ]); + expect(pageMapper.addMapping).not.toHaveBeenCalled(); + }); + + it("change on both sides without --overwrite is still a conflict and records nothing", async () => { + const { result, entry, kept } = await run({ sourceChanged: true, targetChanged: "changed" }); + + expect(result.status).toBe("skip"); + expect(entry).toEqual(expect.objectContaining({ action: "conflict", detail: "changes detected in both source and target" })); + expect(kept.hasEntries()).toBe(false); + }); + + it("a target page missing from the pull stays a conflict even when the source is unchanged", async () => { + const { result, entry, kept } = await run({ sourceChanged: false, targetChanged: "file_missing" }); + + expect(result.status).toBe("skip"); + expect(entry).toEqual(expect.objectContaining({ action: "conflict", detail: expect.stringMatching(/cannot verify/) })); + expect(kept.hasEntries()).toBe(false); + }); + + it("target-only change with --overwrite updates the page and records nothing", async () => { + const { result, entry, kept } = await run({ sourceChanged: false, targetChanged: "changed", overwrite: true }); + + expect(result.status).toBe("success"); + expect(entry).toEqual(expect.objectContaining({ action: "update" })); + expect(kept.hasEntries()).toBe(false); + }); +}); + // ─── folder pages (no template required) ────────────────────────────────────── describe("processPage — folder pages", () => { diff --git a/src/lib/pushers/tests/asset-pusher-stale-mapping.test.ts b/src/lib/pushers/tests/asset-pusher-stale-mapping.test.ts index d0651cca..3501bf6e 100644 --- a/src/lib/pushers/tests/asset-pusher-stale-mapping.test.ts +++ b/src/lib/pushers/tests/asset-pusher-stale-mapping.test.ts @@ -284,10 +284,12 @@ describe("pushAssets — asset moved to another folder (PROD-2627)", () => { // ─── an edit on the target only ─────────────────────────────────────────────── describe("pushAssets — mapped asset changed on the target only", () => { - it("is a conflict without --overwrite: the target's version is kept, nothing uploaded", async () => { + it("without --overwrite keeps the target's version, uploads nothing, and records a warning", async () => { new AssetMapper(SRC, TGT).addMapping(asset("source", 1, "a/x.png", OLD), asset("target", 2, "a/x.png", OLD)); writeLocalFile("a/x.png"); mockApi(); + const { keptTargetChanges } = await import("../kept-target-changes"); + keptTargetChanges.reset(); const { pushAssets } = await import("../asset-pusher"); const result = await pushAssets([asset("source", 1, "a/x.png", OLD)], [asset("target", 2, "a/x.png", NEW)]); @@ -295,9 +297,12 @@ describe("pushAssets — mapped asset changed on the target only", () => { expect(axios.post).not.toHaveBeenCalled(); expect(result.skipped).toBe(1); expect(result.successful).toBe(0); + expect(keptTargetChanges.getEntries()).toEqual([ + expect.objectContaining({ type: "asset", name: "x.png", sourceID: 1, targetID: 2 }), + ]); }); - it("is reported as a preflight conflict", async () => { + it("is a preflight skip, not a conflict", async () => { // seed before enabling preflight: preflight never persists mapping files new AssetMapper(SRC, TGT).addMapping(asset("source", 1, "a/x.png", OLD), asset("target", 2, "a/x.png", OLD)); state.preflight = true; @@ -308,6 +313,21 @@ describe("pushAssets — mapped asset changed on the target only", () => { const { pushAssets } = await import("../asset-pusher"); await pushAssets([asset("source", 1, "a/x.png", OLD)], [asset("target", 2, "a/x.png", NEW)]); + expect(await preflightEntries()).toEqual([ + expect.objectContaining({ action: "skip", name: "x.png", detail: expect.stringMatching(/kept the target's version/) }), + ]); + }); + + it("is a conflict when the source changed too", async () => { + new AssetMapper(SRC, TGT).addMapping(asset("source", 1, "a/x.png", OLD), asset("target", 2, "a/x.png", OLD)); + state.preflight = true; + const { preflightReport } = await import("lib/preflight/preflight-report"); + preflightReport.reset(); + mockApi(); + + const { pushAssets } = await import("../asset-pusher"); + await pushAssets([asset("source", 1, "a/x.png", NEW)], [asset("target", 2, "a/x.png", NEW)]); + expect(await preflightEntries()).toEqual([expect.objectContaining({ action: "conflict", name: "x.png" })]); }); diff --git a/src/lib/pushers/tests/container-pusher.test.ts b/src/lib/pushers/tests/container-pusher.test.ts index bf2ec65d..f8534b65 100644 --- a/src/lib/pushers/tests/container-pusher.test.ts +++ b/src/lib/pushers/tests/container-pusher.test.ts @@ -336,6 +336,86 @@ describe("pushContainers — saved container is what the mapper returns for the }); }); +// ─── pushContainers — changes since the last sync ───────────────────────────── + +describe("pushContainers — mapped container changed since the last sync", () => { + const T1 = "01/01/2024 10:00AM"; + const T2 = "06/01/2025 10:00AM"; + + // First run creates the container so the mapping records T1 on both sides. + async function seed(referenceName: string) { + const created = makeContainer({ contentViewID: 700, contentDefinitionID: 1, referenceName, lastModifiedDate: T1 }); + const saveContainer = jest.fn().mockResolvedValue(created); + state.cachedApiClient = { containerMethods: { saveContainer } } as any; + const { pushContainers } = await import("../container-pusher"); + const source = makeContainer({ contentViewID: 800, contentDefinitionID: 1, referenceName, lastModifiedDate: T1 }); + await pushContainers([source], []); + saveContainer.mockClear(); + return { source, created, saveContainer, pushContainers }; + } + + async function containerEntries() { + const { preflightReport } = await import("lib/preflight/preflight-report"); + return preflightReport.getEntries().filter((e) => e.phase === "Containers"); + } + + async function freshReports() { + const { keptTargetChanges } = await import("../kept-target-changes"); + const { preflightReport } = await import("lib/preflight/preflight-report"); + keptTargetChanges.reset(); + preflightReport.reset(); + return keptTargetChanges; + } + + it("target-only change without --overwrite keeps the target, skips, and records a warning", async () => { + const { source, created, saveContainer, pushContainers } = await seed("KeptContainer"); + const kept = await freshReports(); + state.preflight = true; + + const result = await pushContainers([source], [{ ...created, lastModifiedDate: T2 }]); + + expect(saveContainer).not.toHaveBeenCalled(); + expect(result.skipped).toBe(1); + expect(result.successful).toBe(0); + expect(kept.getEntries()).toEqual([ + expect.objectContaining({ type: "container", name: "KeptContainer", sourceID: 800, targetID: 700 }), + ]); + expect(await containerEntries()).toEqual([ + expect.objectContaining({ + action: "skip", + name: "KeptContainer", + detail: expect.stringMatching(/kept the target's version/), + }), + ]); + }); + + it("change on both sides without --overwrite is still a conflict and records nothing", async () => { + const { source, created, saveContainer, pushContainers } = await seed("BothContainer"); + const kept = await freshReports(); + state.preflight = true; + + const result = await pushContainers([{ ...source, lastModifiedDate: T2 }], [{ ...created, lastModifiedDate: T2 }]); + + expect(saveContainer).not.toHaveBeenCalled(); + expect(result.skipped).toBe(1); + expect(kept.hasEntries()).toBe(false); + expect(await containerEntries()).toEqual([expect.objectContaining({ action: "conflict", name: "BothContainer" })]); + }); + + it("target-only change with --overwrite updates from the source and records nothing", async () => { + const { source, created, saveContainer, pushContainers } = await seed("OverwriteContainer"); + const kept = await freshReports(); + state.overwrite = true; + saveContainer.mockResolvedValue({ ...created, lastModifiedDate: T2 }); + + const result = await pushContainers([source], [{ ...created, lastModifiedDate: T2 }]); + + expect(saveContainer).toHaveBeenCalledTimes(1); + expect(result.successful).toBe(1); + expect(kept.hasEntries()).toBe(false); + }); +}); + // ─── pushContainers — result shape ──────────────────────────────────────────── describe("pushContainers — result shape", () => { diff --git a/src/lib/pushers/tests/gallery-pusher.test.ts b/src/lib/pushers/tests/gallery-pusher.test.ts index 1f775e80..fe5bbafc 100644 --- a/src/lib/pushers/tests/gallery-pusher.test.ts +++ b/src/lib/pushers/tests/gallery-pusher.test.ts @@ -208,3 +208,62 @@ describe("pushGalleries — mapped gallery: what changed decides update, skip or expect((await run(true, true, true)).saved).toBe(1); }); }); + +// ─── pushGalleries — target-only change is kept with a warning ──────────────── + +describe("pushGalleries — mapped gallery changed on the target", () => { + async function run(sourceChanged: boolean, targetChanged: boolean, overwrite = false) { + const { GalleryMapper } = await import("lib/mappers/gallery-mapper"); + const { keptTargetChanges } = await import("../kept-target-changes"); + const { preflightReport } = await import("lib/preflight/preflight-report"); + keptTargetChanges.reset(); + preflightReport.reset(); + const source = makeGallery({ name: "Mapped Gallery", mediaGroupingID: 11 }); + const target = makeGallery({ name: "Mapped Gallery", mediaGroupingID: 500 }); + jest.spyOn(GalleryMapper.prototype, "getGalleryMapping").mockReturnValue({ targetMediaGroupingID: 500 } as any); + jest.spyOn(GalleryMapper.prototype, "hasSourceChanged").mockReturnValue(sourceChanged); + jest.spyOn(GalleryMapper.prototype, "hasTargetChanged").mockReturnValue(targetChanged); + jest.spyOn(GalleryMapper.prototype, "addMapping").mockImplementation(() => {}); + const saveMock = jest.fn().mockResolvedValue(target); + jest.spyOn(stateModule, "getApiClient").mockReturnValue(makeApiClient(saveMock)); + setState({ overwrite, preflight: !overwrite }); + + const { pushGalleries } = await import("../gallery-pusher"); + const result = await pushGalleries([source], [target]); + const entries = preflightReport.getEntries().filter((e) => e.phase === "Galleries"); + return { result, saved: saveMock.mock.calls.length, entries, kept: keptTargetChanges }; + } + + it("target-only change without --overwrite is a skip that records a kept change", async () => { + const { result, saved, entries, kept } = await run(false, true); + + expect(saved).toBe(0); + expect(result.skipped).toBe(1); + expect(entries).toEqual([ + expect.objectContaining({ + action: "skip", + name: "Mapped Gallery", + detail: expect.stringMatching(/kept the target's version/), + }), + ]); + expect(kept.getEntries()).toEqual([ + expect.objectContaining({ type: "gallery", name: "Mapped Gallery", sourceID: 11, targetID: 500 }), + ]); + }); + + it("change on both sides without --overwrite is still a conflict and records nothing", async () => { + const { saved, entries, kept } = await run(true, true); + + expect(saved).toBe(0); + expect(entries).toEqual([expect.objectContaining({ action: "conflict", name: "Mapped Gallery" })]); + expect(kept.hasEntries()).toBe(false); + }); + + it("target-only change with --overwrite updates and records nothing", async () => { + const { result, saved, kept } = await run(false, true, true); + + expect(saved).toBe(1); + expect(result.successful).toBe(1); + expect(kept.hasEntries()).toBe(false); + }); +}); diff --git a/src/lib/pushers/tests/kept-target-changes.test.ts b/src/lib/pushers/tests/kept-target-changes.test.ts new file mode 100644 index 00000000..0c599c3e --- /dev/null +++ b/src/lib/pushers/tests/kept-target-changes.test.ts @@ -0,0 +1,174 @@ +import { keptTargetChanges, KEPT_TARGET_CHANGE_MESSAGE } from "../kept-target-changes"; + +beforeEach(() => { + keptTargetChanges.reset(); +}); + +describe("keptTargetChanges.record", () => { + it("starts empty and reports no entries", () => { + expect(keptTargetChanges.hasEntries()).toBe(false); + expect(keptTargetChanges.getEntries()).toEqual([]); + expect(keptTargetChanges.renderSummaryLines()).toEqual([]); + }); + + it("ignores a repeat of the same entity (same type, locale and source ID)", () => { + keptTargetChanges.record({ type: "content", name: "a", locale: "en-us", sourceID: 1, targetID: 10 }); + keptTargetChanges.record({ type: "content", name: "a", locale: "en-us", sourceID: 1, targetID: 10 }); + + expect(keptTargetChanges.getEntries()).toHaveLength(1); + }); + + it("dedupes by name when there is no source ID", () => { + keptTargetChanges.record({ type: "model", name: "Article" }); + keptTargetChanges.record({ type: "model", name: "Article" }); + + expect(keptTargetChanges.getEntries()).toHaveLength(1); + }); + + it.each([ + ["locale", { type: "content" as const, name: "a", locale: "fr", sourceID: 1 }], + ["type", { type: "page" as const, name: "a", locale: "en-us", sourceID: 1 }], + ["source ID", { type: "content" as const, name: "a", locale: "en-us", sourceID: 2 }], + ])("keeps entities that differ by %s", (_label, other) => { + keptTargetChanges.record({ type: "content", name: "a", locale: "en-us", sourceID: 1 }); + keptTargetChanges.record(other); + + expect(keptTargetChanges.getEntries()).toHaveLength(2); + }); + + it("reset clears recorded entries", () => { + keptTargetChanges.record({ type: "asset", name: "x.png", sourceID: 1 }); + keptTargetChanges.reset(); + + expect(keptTargetChanges.hasEntries()).toBe(false); + }); + + it("getEntries returns a copy", () => { + keptTargetChanges.record({ type: "asset", name: "x.png", sourceID: 1 }); + keptTargetChanges.getEntries().pop(); + + expect(keptTargetChanges.getEntries()).toHaveLength(1); + }); +}); + +describe("keptTargetChanges.headline", () => { + it("counts by type with plurals", () => { + keptTargetChanges.record({ type: "content", name: "a", sourceID: 1 }); + keptTargetChanges.record({ type: "content", name: "b", sourceID: 2 }); + keptTargetChanges.record({ type: "page", name: "p", sourceID: 3 }); + + expect(keptTargetChanges.headline()).toBe("Target changes kept: 2 content items, 1 page"); + }); + + it.each([ + ["content", "1 content item"], + ["asset", "1 asset"], + ["gallery", "1 gallery"], + ["container", "1 container"], + ["model", "1 model"], + ["page", "1 page"], + ] as const)("uses the singular label for one %s", (type, label) => { + keptTargetChanges.record({ type, name: "n", sourceID: 1 }); + + expect(keptTargetChanges.headline()).toBe(`Target changes kept: ${label}`); + }); + + it("pluralises the label for several of one type", () => { + keptTargetChanges.record({ type: "asset", name: "a", sourceID: 1 }); + keptTargetChanges.record({ type: "asset", name: "b", sourceID: 2 }); + + expect(keptTargetChanges.headline()).toBe("Target changes kept: 2 assets"); + }); + + it("spells galleries", () => { + keptTargetChanges.record({ type: "gallery", name: "a", sourceID: 1 }); + keptTargetChanges.record({ type: "gallery", name: "b", sourceID: 2 }); + + expect(keptTargetChanges.headline()).toBe("Target changes kept: 2 galleries"); + }); +}); + +describe("keptTargetChanges.formatLine", () => { + it("includes type, name, locale, target ID and the --overwrite hint", () => { + const line = keptTargetChanges.formatLine({ type: "page", name: "About", locale: "en-us", targetID: 9 }); + + expect(line).toContain("page About [en-us] (target ID 9)"); + expect(line).toContain(KEPT_TARGET_CHANGE_MESSAGE); + }); + + it("omits locale and target ID when absent", () => { + const line = keptTargetChanges.formatLine({ type: "model", name: "Article" }); + + expect(line).toContain("model Article changed on the target"); + }); +}); + +describe("keptTargetChanges.renderSummaryLines", () => { + it("renders the headline, one line per entity, then the explanation", () => { + keptTargetChanges.record({ + type: "content", + name: "item-a", + locale: "en-us", + sourceID: 1, + targetID: 10, + targetUrl: "https://example.test/10", + }); + keptTargetChanges.record({ type: "model", name: "Article", sourceID: 2 }); + + const lines = keptTargetChanges.renderSummaryLines(); + + expect(lines[0]).toBe("Target changes kept: 1 content item, 1 model (non-blocking):"); + expect(lines[1]).toBe(" • [en-us] content item item-a https://example.test/10"); + expect(lines[2]).toBe(" • model Article"); + expect(lines[3]).toMatch(/Use --overwrite to replace them with the source/); + expect(lines).toHaveLength(4); + }); +}); + +describe("keptTargetChanges.toWarningDetails", () => { + it("sets code, guid, type, IDs and a message that mentions --overwrite", () => { + keptTargetChanges.record({ + type: "gallery", + name: "Hero", + sourceID: 1, + targetID: 2, + }); + + expect(keptTargetChanges.toWarningDetails("tgt-guid")).toEqual([ + expect.objectContaining({ + name: "Hero", + type: "gallery", + guid: "tgt-guid", + code: "targetChangeKept", + sourceID: 1, + targetID: 2, + error: expect.stringContaining("--overwrite"), + }), + ]); + }); + + it("adds contentID for a content item and pageID for a page, using the target ID", () => { + keptTargetChanges.record({ type: "content", name: "c", locale: "en-us", sourceID: 1, targetID: 11 }); + keptTargetChanges.record({ type: "page", name: "p", locale: "en-us", sourceID: 2, targetID: 22 }); + + const [content, page] = keptTargetChanges.toWarningDetails("g"); + + expect(content).toMatchObject({ contentID: 11, locale: "en-us" }); + expect(content).not.toHaveProperty("pageID"); + expect(page).toMatchObject({ pageID: 22, locale: "en-us" }); + expect(page).not.toHaveProperty("contentID"); + }); + + it("omits contentID and pageID for other types", () => { + keptTargetChanges.record({ type: "container", name: "c", sourceID: 1, targetID: 2 }); + + const [detail] = keptTargetChanges.toWarningDetails("g"); + + expect(detail).not.toHaveProperty("contentID"); + expect(detail).not.toHaveProperty("pageID"); + }); + + it("returns an empty list when nothing was kept", () => { + expect(keptTargetChanges.toWarningDetails("g")).toEqual([]); + }); +}); diff --git a/src/lib/pushers/tests/model-pusher.test.ts b/src/lib/pushers/tests/model-pusher.test.ts index b9cfbc84..a042fdfc 100644 --- a/src/lib/pushers/tests/model-pusher.test.ts +++ b/src/lib/pushers/tests/model-pusher.test.ts @@ -929,6 +929,78 @@ describe("pushModels — alternating-direction ping-pong (PROD-2604)", () => { }); }); +// ─── target-only model change is kept with a warning ────────────────────────── + +describe("pushModels — mapped model changed on the target", () => { + const JAN = new Date(2025, 0, 1).toISOString(); + const JUN = new Date(2025, 5, 1).toISOString(); + const SEP = new Date(2025, 8, 1).toISOString(); + + function fields(label: string) { + return [{ name: "Title", label, type: "Text", itemOrder: 0, settings: { Required: "True" } }]; + } + + async function run(sourceDate: string, opts: { overwrite?: boolean; preflight?: boolean } = {}) { + const { ModelMapper } = await import("lib/mappers/model-mapper"); + new ModelMapper(state.sourceGuid, state.targetGuid).addMapping( + { id: 1, referenceName: "Article", lastModifiedDate: JAN } as any, + { id: 10, referenceName: "Article", lastModifiedDate: JAN } as any + ); + setState({ overwrite: !!opts.overwrite, preflight: !!opts.preflight }); + const { keptTargetChanges } = await import("../kept-target-changes"); + const { preflightReport } = await import("lib/preflight/preflight-report"); + keptTargetChanges.reset(); + preflightReport.reset(); + const saveModel = jest.fn().mockResolvedValue(makeModel({ id: 10, referenceName: "Article", lastModifiedDate: SEP })); + jest.spyOn(stateModule, "getApiClient").mockReturnValue(makeApiClient(saveModel)); + const { pushModels } = await import("../model-pusher"); + + const source = makeModel({ id: 1, referenceName: "Article", displayName: "Article", lastModifiedDate: sourceDate, fields: fields("Headline") }); + const target = makeModel({ id: 10, referenceName: "Article", displayName: "Article", lastModifiedDate: SEP, fields: fields("Title") }); + const result = await pushModels([source], [target]); + const entry = preflightReport.getEntries().find((e) => e.name === "Article"); + return { result, saveModel, kept: keptTargetChanges, entry }; + } + + it("target-only change without --overwrite is a skip that records a kept change", async () => { + const { result, saveModel, kept, entry } = await run(JAN, { preflight: true }); + + expect(saveModel).not.toHaveBeenCalled(); + expect(result.skipped).toBe(1); + expect(entry).toEqual( + expect.objectContaining({ action: "skip", detail: expect.stringMatching(/kept the target's version/) }) + ); + expect(kept.getEntries()).toEqual([ + expect.objectContaining({ type: "model", name: "Article", sourceID: 1, targetID: 10 }), + ]); + }); + + it("target-only change without --overwrite does not refresh the mapping", async () => { + await run(JAN); + + const { ModelMapper } = await import("lib/mappers/model-mapper"); + const record = new ModelMapper(state.sourceGuid, state.targetGuid).getModelMappingByID(1, "source")!; + expect(record.targetLastModifiedDate).toBe(JAN); + }); + + it("change on both sides without --overwrite is still a conflict and records nothing", async () => { + const { saveModel, kept, entry } = await run(JUN, { preflight: true }); + + expect(saveModel).not.toHaveBeenCalled(); + expect(entry?.action).toBe("conflict"); + expect(kept.hasEntries()).toBe(false); + }); + + it("target-only change with --overwrite updates the target and records nothing", async () => { + const { result, saveModel, kept } = await run(JAN, { overwrite: true }); + + expect(saveModel).toHaveBeenCalledTimes(1); + expect(saveModel.mock.calls[0][0].id).toBe(10); + expect(result.successful).toBe(1); + expect(kept.hasEntries()).toBe(false); + }); +}); + // ─── PROD-2542: effective target model registry ─────────────────────────────── describe("pushModels — effective target model registry (PROD-2542)", () => { From ac68401aef1d303395f23471b9efa5c5f96dbfd5 Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 3 Nov 2026 11:15:10 -0500 Subject: [PATCH 2/3] CI: grade a kept target change as drift, and fix two picks that broke the run - drift passes when the receiving side's edit is kept with a target-changed warning. - model.field-rename skips fields another field names in its settings (a linked field's companion ID field). Renaming one leaves the link pointing at a missing field, and the server then drops every value saved to the link. - The conflict edit avoids the item the round's drift edited, so each check grades its own item. Co-Authored-By: Claude Opus 5.5 --- ci/README.md | 3 ++- ci/permutation/changes.mjs | 21 +++++++++++++++++---- ci/permutation/run.mjs | 26 ++++++++++++++++---------- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/ci/README.md b/ci/README.md index d2b3e1d1..8f9a4e2a 100644 --- a/ci/README.md +++ b/ci/README.md @@ -59,7 +59,8 @@ every sync it checks: - **sync:** exit 0 and no failed items in `--jsonSummary`; - **conflict:** the item edited on both sides ends at the synced side's value, or the sync reported a conflict; -- **drift:** an edit made only on the receiving side is overwritten, or reported, never silently kept; +- **drift:** an edit made only on the receiving side is overwritten, or kept with a "target changed" + warning (not a conflict), never silently kept; - **resolve:** when the sync kept such edits, the same sync with `--overwrite` settles them, as an editor would, before the checks below; - **verify:** `agility verify` finds no differences. A verify that finds some is tried once more a diff --git a/ci/permutation/changes.mjs b/ci/permutation/changes.mjs index 350043af..191b5c85 100644 --- a/ci/permutation/changes.mjs +++ b/ci/permutation/changes.mjs @@ -341,12 +341,24 @@ export const CHANGES = { const m = added ? models.find((x) => x.id === added.model) : ctx.r.pick(models); if (!m) return null; const full = await ctx.c.modelMethods.getContentModel(m.id, ctx.guid); + // Fields another field names in its settings, like a linked field's companion ID field + // (LinkeContentDropdownValueField). Renaming one leaves that setting pointing at a field that no + // longer exists, so the server drops every value saved to the link afterwards: a broken model, + // not a sync defect. + const companions = new Set( + full.fields.flatMap((x) => Object.values(x.settings ?? {})).map((v) => String(v).toLowerCase()) + ); const f = added ? full.fields.find((x) => x.name === added.name) : // Not a required field: the server drops existing values on a rename, which would leave every // item missing a required value, and the CLI rightly refuses to sync those ctx.r.pick( - full.fields.filter((x) => x.type === "Text" && String(x.settings?.Required).toLowerCase() !== "true") + full.fields.filter( + (x) => + x.type === "Text" && + String(x.settings?.Required).toLowerCase() !== "true" && + !companions.has(x.name.toLowerCase()) + ) ); if (!f) return null; const old = f.name; @@ -361,12 +373,13 @@ export async function drift(ctx) { return CHANGES["content.edit"]({ ...ctx }); } -// The same item edited on both sides with different values: a conflict. -export async function conflict(srcCtx, dstCtx, mappingLookup) { +// The same item edited on both sides with different values: a conflict. `avoidDstId` is the item +// the round's drift edited, so the two checks grade different items. +export async function conflict(srcCtx, dstCtx, mappingLookup, avoidDstId = null) { const pool = (await listsWithItems(srcCtx.c, srcCtx.guid, srcCtx.locale)).flatMap((x) => x.items); for (const it of pool) { const dstId = mappingLookup(it.contentID); - if (!dstId) continue; + if (!dstId || dstId === avoidDstId) continue; const s = await srcCtx.c.contentMethods.getContentItem(it.contentID, srcCtx.guid, srcCtx.locale); const f = textField(s); if (!f) continue; diff --git a/ci/permutation/run.mjs b/ci/permutation/run.mjs index 28b33882..a992b489 100644 --- a/ci/permutation/run.mjs +++ b/ci/permutation/run.mjs @@ -13,7 +13,7 @@ // After each sync it checks: // sync exit 0, no failed items // conflict the both-sides item ends at the synced side's value, or a preflight reported it -// drift the other side's own edit was overwritten, or reported as a conflict on that item +// drift the other side's own edit was overwritten, or kept with a "target changed" warning // resolve when the sync kept such edits: the same sync with --overwrite settles them // verify `agility verify` finds no differences (verify and noop: up to 3 tries, 90s apart) // noop a preflight in the same direction plans no creates, updates, deletes or conflicts @@ -121,11 +121,14 @@ function syncStep(round, direction, step = "sync", extra = []) { } const after = step === "sync" ? cli(`r${round}-after`, [cmd, ...pair, ...PLAN]) : null; const entries = [before, after].flatMap((x) => x?.json?.preflight?.entries ?? []); - const details = entries.filter((e) => e.action === "conflict").map((e) => e.detail ?? ""); + const conflicts = entries.filter((e) => e.action === "conflict").map((e) => e.detail ?? ""); + const kept = entries.filter((e) => e.action === "skip" && /kept the target's version/.test(e.detail ?? "")); + const names = (guid, id) => new RegExp(`/instance/${guid}/[^/]+/content/listitem-${id}(?!\\d)`); return { // Whether a preflight reported a conflict on this content item (its URL is in the detail) - reported: (guid, id) => - details.some((d) => new RegExp(`/instance/${guid}/[^/]+/content/listitem-${id}(?!\\d)`).test(d)), + reported: (guid, id) => conflicts.some((d) => names(guid, id).test(d)), + // Whether a preflight kept this content item's target-only change, with a warning + keptWarned: (guid, id) => kept.some((e) => names(guid, id).test(e.detail)), }; } @@ -256,7 +259,8 @@ for (let i = 0; i < plan.length; i++) { let both = null; try { - both = await conflict(fromCtx, toCtx, mapped(forward)); + const driftedId = drifted ? Number(/#(\d+)/.exec(drifted.detail)?.[1]) || null : null; + both = await conflict(fromCtx, toCtx, mapped(forward), driftedId); if (both) applied.push({ name: "conflict", ...both }); } catch (e) { record(round, direction, "apply:conflict", false, String(e?.message ?? e).slice(0, 200)); @@ -278,7 +282,7 @@ for (let i = 0; i < plan.length; i++) { } } - const { reported } = syncStep(round, direction); + const { reported, keptWarned } = syncStep(round, direction); let kept = false; if (both) { @@ -310,12 +314,14 @@ for (let i = 0; i < plan.length; i++) { round, direction, "drift", - same || reported(to, id), + same || keptWarned(to, id), same ? "overwritten to match" - : reported(to, id) - ? "kept, and reported as a conflict" - : "the other side's edit was silently kept; the instances differ" + : keptWarned(to, id) + ? "kept, with a target-changed warning" + : reported(to, id) + ? "reported as a conflict, but only the target changed" + : "the other side's edit was silently kept; the instances differ" ); kept ||= !same; } From 512d7b05690bf1df77680549506a96c9fac18338 Mon Sep 17 00:00:00 2001 From: Kevin Date: Tue, 3 Nov 2026 11:16:24 -0500 Subject: [PATCH 3/3] Chore: bump version to 1.3.0-beta.1 Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d1f5191c..3332dc55 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agility/cli", - "version": "1.3.0-beta.0", + "version": "1.3.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agility/cli", - "version": "1.3.0-beta.0", + "version": "1.3.0-beta.1", "license": "ISC", "dependencies": { "@agility/content-sync": "^1.2.0", diff --git a/package.json b/package.json index 8423344b..2f1ea629 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agility/cli", - "version": "1.3.0-beta.0", + "version": "1.3.0-beta.1", "repository": { "type": "git", "url": "https://github.com/agility/agility-cli.git"