diff --git a/packages/cli/src/modules/source-control.ee/__tests__/source-control-helper.ee.test.ts b/packages/cli/src/modules/source-control.ee/__tests__/source-control-helper.ee.test.ts index d9114f1c53c..836c4364a02 100644 --- a/packages/cli/src/modules/source-control.ee/__tests__/source-control-helper.ee.test.ts +++ b/packages/cli/src/modules/source-control.ee/__tests__/source-control-helper.ee.test.ts @@ -1188,7 +1188,7 @@ describe('Source Control Helper', () => { expect(result.port).toBe(5000); // Number from remote }); - it('should only include fields present in remote', () => { + it('should keep local fields that are absent from remote', () => { const local = { apiKey: 'secret', port: 3000, @@ -1197,14 +1197,32 @@ describe('Source Control Helper', () => { const remote = { port: 8080, apiKey: '', // Plain string sanitized to empty - // extraField is NOT in remote, so won't be in result + // extraField is NOT in remote (e.g. left at its default when pushed) }; const result = mergeRemoteCrendetialDataIntoLocalCredentialData({ local, remote }); expect(result.apiKey).toBe('secret'); // Local preserved (empty skipped) expect(result.port).toBe(8080); // Merged from remote - expect(result.extraField).toBeUndefined(); // Not in remote, not in result + expect(result.extraField).toBe('local-only'); // Absent from remote, local retained + }); + + it('should not reset a locally selected option field the remote stub omits', () => { + // A stub pushed from an instance where the option was left at its default + // omits that field entirely. Pulling it must not reset a different local selection. + const local = { + apikey: 'prod-secret', + environment: 'https://api.example.com', // non-default selection on this instance + }; + const remote = { + apikey: '', // secret blanked in the stub + // environment omitted because the pushing instance used the default + } as ICredentialDataDecryptedObject; + + const result = mergeRemoteCrendetialDataIntoLocalCredentialData({ local, remote }); + + expect(result.apikey).toBe('prod-secret'); + expect(result.environment).toBe('https://api.example.com'); }); it('should handle empty local object', () => { @@ -1229,8 +1247,8 @@ describe('Source Control Helper', () => { const result = mergeRemoteCrendetialDataIntoLocalCredentialData({ local, remote }); - // Remote is empty, so result is empty (remote is source of truth for which fields exist) - expect(result).toEqual({}); + // Remote carries no fields, so every local field is retained rather than wiped + expect(result).toEqual({ apiKey: 'secret', port: 3000 }); }); it('should handle both empty objects', () => { diff --git a/packages/cli/src/modules/source-control.ee/source-control-helper.ee.ts b/packages/cli/src/modules/source-control.ee/source-control-helper.ee.ts index ce526aba5ba..4a75fbf32e6 100644 --- a/packages/cli/src/modules/source-control.ee/source-control-helper.ee.ts +++ b/packages/cli/src/modules/source-control.ee/source-control-helper.ee.ts @@ -141,11 +141,15 @@ export function mergeRemoteCrendetialDataIntoLocalCredentialData({ } } - // Because oauthTokenData is explicitly stripped from the remote data during sanitization, - // it will never exist in the sanitizedRemote object. Therefore, it is skipped in the loop above. - // We manually merge it back from local to prevent OAuth credentials being wiped out on pull. - if (local.oauthTokenData) { - merged.oauthTokenData = local.oauthTokenData; + // Keep local fields the remote stub does not carry. A field left at its default value + // is not persisted, so it never reaches the stub; an absent field carries the same + // "no value to give" meaning as a present-but-blank one, which is already preserved + // above. Without this it would be dropped on pull and reset to its default. This also + // covers oauthTokenData, which sanitization always strips from the remote. + for (const [key, localValue] of Object.entries(local)) { + if (!(key in sanitizedRemote)) { + merged[key] = localValue; + } } return merged;