Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,10 +159,12 @@ During analysis and sync, files are displayed with status indicators:
packageJsonSync: ['dependencies', 'devDependencies', 'scripts']
```

**Supported keys:** `dependencies`, `devDependencies`, `peerDependencies`, `optionalDependencies`, `scripts`, `engines`, `packageManager`, `overrides`
**Supported keys:** `dependencies`, `devDependencies`, `peerDependencies`, `optionalDependencies`, `scripts`, `engines`, `packageManager`, `overrides`, `exports`, `pnpm`

It **adds** new keys and **updates** existing ones to match upstream, but never **removes** keys
that only exist in your fork — so extra dependencies and scripts survive each sync.
that only exist in your fork — so extra dependencies and scripts survive each sync. `scripts` and
`exports` are add-only: an entry your fork already defines is never rewritten. `exports` only
merges when both sides are subpath maps (`{ ".": …, "./config": … }`).

## Contributions (pull from forks)

Expand Down
2 changes: 2 additions & 0 deletions src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type PackageJsonSyncKey =
| 'engines'
| 'packageManager'
| 'overrides'
| 'exports'
| 'pnpm';

/**
Expand Down Expand Up @@ -170,6 +171,7 @@ export const cellaConfigSchema = z
'engines',
'packageManager',
'overrides',
'exports',
'pnpm',
]),
)
Expand Down
37 changes: 37 additions & 0 deletions src/services/packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,31 @@ function safeMergeRecord(
return { merged: sorted, changed, added };
}

/** A subpath map (`{ ".": …, "./config": … }`), as opposed to a string or a conditions object. */
function isSubpathMap(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
return Object.keys(value).every((name) => name.startsWith('.'));
}

/**
* Safe merge for the `exports` key — add upstream subpaths the fork lacks, never touch the
* fork's own. Only merges when both sides are subpath maps: a string or conditions object
* can't take extra subpaths, and condition order is significant. Keeps the fork's key order.
*/
function safeMergeExports(
forkExports: unknown,
upstreamExports: unknown,
): { merged: Record<string, unknown>; changed: boolean; added: string[] } | undefined {
if (!isSubpathMap(upstreamExports)) return undefined;
if (forkExports !== undefined && !isSubpathMap(forkExports)) return undefined;

const merged = { ...(forkExports || {}) };
const added = Object.keys(upstreamExports).filter((name) => !(name in merged));
for (const name of added) merged[name] = upstreamExports[name];

return { merged, changed: added.length > 0, added };
}

/**
* Safe merge for the `pnpm` key — recurse into sub-objects with add/bump-only logic.
*/
Expand Down Expand Up @@ -318,6 +343,18 @@ async function syncPackageJson(
continue;
}

if (key === 'exports') {
// Add-only, subpath maps only: the fork gains new upstream subpaths, keeps any it defines
// itself, and a subpath it has repointed stays repointed.
const result = safeMergeExports(forkPkg.exports, upstreamPkg.exports);
if (result?.changed) {
forkPkg.exports = result.merged;
updated = true;
changes.push(...result.added.map((name) => `exports.${name}: added`));
}
continue;
}

// All Record<string, string> keys — safe merge.
// Scripts are add-only (never overwrite fork scripts); the rest also bump versions.
const forkValue = forkPkg[key] as Record<string, string> | undefined;
Expand Down
41 changes: 41 additions & 0 deletions tests/packages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,47 @@ describe('packages merge', () => {
expect(scripts.test).toBe('vitest');
});

it('should add new export subpaths from upstream but keep fork subpaths and order', async () => {
const forkPkg = readPkg(forkPath);
forkPkg.exports = { './fork-only': './dist/fork.js', '.': './dist/custom.js' };
writePkg(forkPath, forkPkg);
exec('git add -A && git commit -m "add fork exports"', forkPath);

const upstreamPkg = readPkg(upstreamPath);
upstreamPkg.exports = {
'.': './dist/index.js',
'./config': { types: './dist/config.d.ts', import: './dist/config.js' },
};
writePkg(upstreamPath, upstreamPkg);
exec('git add -A && git commit -m "add exports"', upstreamPath);
exec('git fetch cella-upstream', forkPath);

await runPackages(buildConfig({ packageJsonSync: ['dependencies', 'exports'] }));

const exportsMap = readPkg(forkPath).exports as Record<string, unknown>;
// Repointed subpath stays repointed, new subpath arrives with its conditions in upstream order
expect(exportsMap['.']).toBe('./dist/custom.js');
expect(Object.keys(exportsMap)).toEqual(['./fork-only', '.', './config']);
expect(Object.keys(exportsMap['./config'] as object)).toEqual(['types', 'import']);
});

it('should leave exports alone when either side is not a subpath map', async () => {
const forkPkg = readPkg(forkPath);
forkPkg.exports = './dist/index.js';
writePkg(forkPath, forkPkg);
exec('git add -A && git commit -m "add fork exports"', forkPath);

const upstreamPkg = readPkg(upstreamPath);
upstreamPkg.exports = { '.': './dist/index.js', './config': './dist/config.js' };
writePkg(upstreamPath, upstreamPkg);
exec('git add -A && git commit -m "add exports"', upstreamPath);
exec('git fetch cella-upstream', forkPath);

await runPackages(buildConfig({ packageJsonSync: ['dependencies', 'exports'] }));

expect(readPkg(forkPath).exports).toBe('./dist/index.js');
});

it('should merge pnpm.overrides with add/bump-only logic', async () => {
// Fork has pnpm overrides
const forkPkg = readPkg(forkPath);
Expand Down
Loading