diff --git a/__mocks__/@monodeploy/git.ts b/__mocks__/@monodeploy/git.ts index dbdfdd3b..7d24fb9a 100644 --- a/__mocks__/@monodeploy/git.ts +++ b/__mocks__/@monodeploy/git.ts @@ -197,6 +197,13 @@ export const gitGlob = async ( return globs // TODO: not entirely accurate } +export const gitCheckout = async ( + { files }: { files: string[] }, + { cwd, context }: { cwd: string; context?: YarnContext }, +): Promise => { + // +} + module.exports = { __esModule: true, _commitFiles_, diff --git a/e2e-tests/tests/issues/issue-601.test.ts b/e2e-tests/tests/issues/issue-601.test.ts new file mode 100644 index 00000000..8d15b6e1 --- /dev/null +++ b/e2e-tests/tests/issues/issue-601.test.ts @@ -0,0 +1,105 @@ +import { RegistryMode } from '@monodeploy/types' + +import setupProject from 'helpers/setupProject' + +describe('Issue #601', () => { + it( + 'handles merge conflicts', + setupProject({ + repository: [ + { + 'pkg-1': {}, + }, + ], + config: { + access: 'public', + changelogFilename: '/CHANGELOG.md', + dryRun: false, + autoCommit: true, + autoCommitMessage: 'chore: release', + conventionalChangelogConfig: require.resolve( + '@tophat/conventional-changelog-config', + ), + git: { + push: true, + remote: 'origin', + tag: true, + }, + jobs: 1, + persistVersions: false, + registryMode: RegistryMode.NPM, + topological: true, + topologicalDev: true, + maxConcurrentReads: 1, + maxConcurrentWrites: 1, + }, + testCase: async ({ run, readFile, exec, writeFile }) => { + // First semantic commit + await writeFile('packages/pkg-1/README.md', 'Modification.') + await exec('git add . && git commit -n -m "feat: change 1" && git push') + + const { error } = await run() + if (error) console.error(error) + expect(error).toBeUndefined() + + // We should have a 'pkg-1/CHANGELOG.md' at this point. + expect(await readFile('packages/pkg-1/CHANGELOG.md')).toMatch(/change 1/) + + await exec('git checkout -b change_2') + await writeFile('packages/pkg-1/README.md', 'Modification 2.') + await exec('git add . && git commit -n -m "feat: change 2"') + + await exec('git checkout -b change_3') // change_3 is based on change_2 + await writeFile('packages/pkg-1/README.md', 'Modification 3.') + await exec('git add . && git commit -n -m "feat: change 3"') + + // Back on change 2, we'll publish + await exec('git checkout main') + await exec('git merge change_2 --no-verify --no-edit') + + const { error: error2 } = await run() + if (error2) console.error(error2) + expect(error2).toBeUndefined() + + // At this point we expected change 2 followed by change 1 in the changelog + let remoteChangelog = ( + await exec('git cat-file blob origin/main:packages/pkg-1/CHANGELOG.md') + ).stdout + expect(remoteChangelog).toMatch(/change 2.*change 1/s) + + // Switch back to change_3 which is now "out of sync" with main + await exec('git checkout main') + await exec('git reset --hard change_3') + + // If we attempt to publish, we'll have a conflict with the CHANGELOG.md file + // since our change_3 will only have change 1 and change 3 and will be missing change 2. + // We'll validate this: + remoteChangelog = ( + await exec('git cat-file blob origin/main:packages/pkg-1/CHANGELOG.md') + ).stdout + expect(remoteChangelog).toEqual(expect.stringContaining('change 1')) + expect(remoteChangelog).toEqual(expect.stringContaining('change 2')) + expect(remoteChangelog).not.toEqual(expect.stringContaining('change 3')) + + // Now we publish. It's up to monodeploy to deal with or prevent conflicts. + const { error: error3 } = await run() + if (error3) console.error(error3) + expect(error3).toBeUndefined() + + // If we get this far, monodeploy didn't fail due to the conflicts. We'll verify the changelog + // with ordering: + remoteChangelog = ( + await exec('git cat-file blob origin/main:packages/pkg-1/CHANGELOG.md') + ).stdout + expect(remoteChangelog).toMatch(/change 3.*change 2.*change 1/s) + expect((await (await exec('git describe --abbrev=0')).stdout).trim()).toBe( + 'pkg-1@0.3.0', + ) + + // NOTE: the hard reset we do disassociates the git tag with the HEAD of main. + // This causes the change_3 monodeploy run to include changes 2 and 3. This is + // just a quirk of the test scenario. + }, + }), + ) +}) diff --git a/packages/changelog/src/prependChangelogFile.ts b/packages/changelog/src/prependChangelogFile.ts index 37b6907e..5fc7bb3b 100644 --- a/packages/changelog/src/prependChangelogFile.ts +++ b/packages/changelog/src/prependChangelogFile.ts @@ -1,6 +1,7 @@ import { promises as fs } from 'fs' import path from 'path' +import { gitCheckout } from '@monodeploy/git' import logging from '@monodeploy/logging' import { type ChangesetSchema, @@ -75,14 +76,30 @@ const prependChangelogFile = async ({ context, changeset, workspaces, + forceRefreshChangelogs = false, }: { config: MonodeployConfiguration context: YarnContext changeset: ChangesetSchema workspaces: Set + forceRefreshChangelogs?: boolean }): Promise => { if (!config.changelogFilename) return + // Make sure the changelogs are up to date with the remote + if (!config.dryRun && forceRefreshChangelogs) { + const changelogGlob = config.changelogFilename.replace('', '**') + if (changelogGlob) { + try { + await gitCheckout({ files: [changelogGlob] }, { cwd: config.cwd, context }) + } catch { + logging.debug('Force refreshing changelogs failed. Ignoring.', { + report: context.report, + }) + } + } + } + if (config.changelogFilename.includes(TOKEN_PACKAGE_DIR)) { const prependForWorkspace = async (workspace: Workspace): Promise => { const filename = npath.fromPortablePath( diff --git a/packages/git/src/gitCommands.ts b/packages/git/src/gitCommands.ts index 0ce5400c..8aea0465 100644 --- a/packages/git/src/gitCommands.ts +++ b/packages/git/src/gitCommands.ts @@ -13,6 +13,20 @@ const git = async ( return await exec(command, { cwd, env: { GIT_TERMINAL_PROMPT: '0', ...process.env } }) } +export const gitCheckout = async ( + { files }: { files: string[] }, + { cwd, context }: { cwd: string; context?: YarnContext }, +): Promise => { + const { stdout: branch } = await git('rev-parse --abbrev-ref --symbolic-full-name @\\{u\\}', { + cwd, + context, + }) + await git(`checkout ${branch.trim()} -- ${files.map((f) => `"${f}"`).join(' ')}`, { + cwd, + context, + }) +} + export const gitResolveSha = async ( ref: string, { cwd, context }: { cwd: string; context?: YarnContext }, @@ -84,15 +98,18 @@ export const gitPull = async ({ remote, context, autostash = false, + strategyOption, }: { cwd: string remote: string context?: YarnContext autostash?: boolean + strategyOption?: 'theirs' }): Promise => { assertProduction() const args = ['--rebase', '--no-verify'] if (autostash) args.push('--autostash') + if (strategyOption) args.push(`--strategy-option=${strategyOption}`) await git(`pull ${args.join(' ')} ${remote}`, { cwd, context, diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index d642be38..44d2373a 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -214,15 +214,6 @@ const monodeploy = async ( resolve() }) - await report.startTimerPromise('Updating Changelog', { skipIfEmpty: false }, async () => { - await prependChangelogFile({ - config, - context, - changeset, - workspaces, - }) - }) - try { // Update package.jsons (the main destructive action which requires the backup) await report.startTimerPromise( @@ -270,6 +261,20 @@ const monodeploy = async ( let publishCommitSha: string | undefined const restoredGitTags = getGitTagsFromChangeset(changeset) + await report.startTimerPromise( + 'Updating Changelog', + { skipIfEmpty: false }, + async () => { + await prependChangelogFile({ + config, + context, + changeset, + workspaces, + forceRefreshChangelogs: config.autoCommit && config.git.push, + }) + }, + ) + await report.startTimerPromise( 'Committing Changes', { skipIfEmpty: true }, diff --git a/packages/publish/src/commitPublishChanges.ts b/packages/publish/src/commitPublishChanges.ts index 2a3977b2..1d1e4ec7 100644 --- a/packages/publish/src/commitPublishChanges.ts +++ b/packages/publish/src/commitPublishChanges.ts @@ -47,21 +47,22 @@ export const createPublishCommit = async ({ } } - if (config.git.tag && gitTags?.size) { - // Tag commit - await createReleaseGitTags({ - config, - context, - gitTags, - }) - } - if (config.git.push && config.autoCommit) { await gitPull({ cwd: config.cwd, remote: config.git.remote, context, autostash: true, + strategyOption: 'theirs', + }) + } + + if (config.git.tag && gitTags?.size) { + // Tag commit + await createReleaseGitTags({ + config, + context, + gitTags, }) }