diff --git a/README.md b/README.md index c08d4db0..fadec6ff 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,7 @@ This works across release lines. For example, with 7.x on `master` and 6.x on a Merge one release line into another with a merge commit. A squash merge hides the commits, and their pull requests, that it brings in. The version being prepared (from `package.json`) gets a section for the commits on `--head` that no tag contains yet. +Once that version is tagged those commits are its own, and anything the branch gains afterwards is gathered under an `Unreleased` heading until the next version is prepared, which `template.unreleased` renames. Without `--tags`, `gren` writes that section and the latest tag `--head` contains, so on a maintenance branch it works on that line's latest release rather than on the highest version in the repository. `--tags=..` puts everything since the old tag in the new tag's section, taking in the releases between them. diff --git a/lib/src/Gren.js b/lib/src/Gren.js index 787b742e..69286b4c 100644 --- a/lib/src/Gren.js +++ b/lib/src/Gren.js @@ -905,7 +905,7 @@ class Gren { return `${rangeBody}\n`; } - return "*No changelog for this release.*\n"; + return `${this.options.template.noChangelog}\n`; } /** @@ -1914,17 +1914,30 @@ class Gren { } return entries - .map(({ release, frozen, verbatim }) => - frozen - ? verbatim !== undefined && { + .map(({ release, frozen, verbatim }) => { + if (frozen) { + return ( + verbatim !== undefined && { id: release.id, release: release.name, name: this.options.prefix + release.name, published_at: release.date, verbatim, } - : blocks.shift(), - ) + ); + } + + const block = blocks.shift(); + + if (release.version) { + return block; + } + + // There is no version for the prefix to belong to in an unreleased section, and no + // reason to write the heading at all when the commits under it have nothing to say: + // they are in the report either way. + return block.body !== this._templateBody([]) && { ...block, name: release.name }; + }) .filter(Boolean); } @@ -2016,23 +2029,31 @@ class Gren { const releases = []; const unreleasedVersion = parseVersion(version); - if (head && unreleasedVersion && !tags.some((tag) => tag.version === unreleasedVersion)) { + if (head) { if (!git.refExists(head)) { throw chalk.red(`\nThe branch "${head}" is not in the local repository.`); } const merged = git.tagsMergedInto(head); - const newer = tags.find( - (tag) => merged.has(tag.name) && semver.gte(tag.version, unreleasedVersion), - ); - - if (newer) { + const taken = unreleasedVersion && tags.some((tag) => tag.version === unreleasedVersion); + // A version equal to a tag is the ordinary state between releases. A version behind one + // is a package.json somebody forgot to bump, which is worth saying out loud. + const behind = + unreleasedVersion && + !taken && + tags.find((tag) => merged.has(tag.name) && semver.gt(tag.version, unreleasedVersion)); + + if (behind) { console.warn( chalk.yellow( - `\nSkipping the unreleased ${version} section: ${head} already contains ${newer.name}.`, + `\nThe version being prepared, ${version}, is older than ${behind.name}, which ` + + `${head} already contains. Commits no tag holds are listed as ` + + `${this.options.template.unreleased}.`, ), ); - } else { + } + + if (unreleasedVersion && !taken && !behind) { releases.push({ id: null, name: version, @@ -2040,6 +2061,21 @@ class Gren { ref: head, date: new Date().toISOString(), }); + } else { + // The version being prepared has been released already, so anything the branch has that + // no tag contains belongs to a release that does not have a name yet. + const excludeRefs = tags.map(({ ref }) => ref); + + if (git.releaseCommits(head, excludeRefs).length) { + releases.push({ + id: null, + name: this.options.template.unreleased, + version: null, + ref: head, + date: new Date().toISOString(), + excludeRefs, + }); + } } } diff --git a/lib/src/templates.js b/lib/src/templates.js index e2ba28b1..2373c463 100644 --- a/lib/src/templates.js +++ b/lib/src/templates.js @@ -8,5 +8,7 @@ export default { group: "\n### {{heading}}\n", changelogTitle: "# Changelog\n\n", release: "## {{release}} ({{date}})\n\n{{body}}", + unreleased: "Unreleased", + noChangelog: "*No changelog for this release.*", releaseSeparator: "\n---\n\n", }; diff --git a/test/Gren.membership.spec.js b/test/Gren.membership.spec.js index 55e9f6b6..83526103 100644 --- a/test/Gren.membership.spec.js +++ b/test/Gren.membership.spec.js @@ -412,14 +412,88 @@ describe("Gren release membership", () => { assert.deepEqual(numbers(byRelease(blocks)["v1.2.0"]), [6, 5, 4, 3]); }); - it("Should skip an unreleased version the branch has already passed", async () => { + it("Should gather commits no tag contains under an unreleased heading", async () => { + // 2.0.0 is released, and master carries c12 beyond every tag. + const blocks = await createGren({ version: "2.0.0" })._getReleaseBlocks(); + const unreleased = byRelease(blocks)["Unreleased"]; + + assert.deepEqual( + blocks.map(({ release }) => release), + ["Unreleased", "v2.0.0", "v1.2.1", "v1.2.0", "v1.1.1", "v1.1.0", "v1.0.0"], + ); + assert.deepEqual(numbers(unreleased), [9]); + assert.equal( + unreleased.name, + "Unreleased", + "The prefix belongs to a version, and it has none", + ); + }); + + it("Should take the unreleased heading and the empty body from the templates", async () => { + const gren = createGren({ + version: "2.0.0", + template: { issue: "{{text}} {{name}} {{url}}", unreleased: "Nog niet uitgebracht" }, + }); + const blocks = await gren._getReleaseBlocks(); + + assert.include( + blocks.map(({ release }) => release), + "Nog niet uitgebracht", + ); + assert.equal( + createGren({ template: { noChangelog: "_Niets te melden._" } })._templateBody([]), + "_Niets te melden._\n", + ); + }); + + it("Should leave out the unreleased heading when its commits have no pull request", async () => { + // c12 is beyond every tag, but without pull request 9 there is nothing to say about it. + const blocks = await createGren({ + version: "2.0.0", + overridePrs: (prs) => prs.filter(({ number }) => number !== 9), + })._getReleaseBlocks(); + + assert.notInclude( + blocks.map(({ release }) => release), + "Unreleased", + ); + assert.include(warnings.join("\n"), repo.sha.c12.slice(0, 10), "It is reported instead"); + }); + + it("Should leave out the unreleased heading when the branch is level with a tag", async () => { + const blocks = await createGren({ head: "1.x", version: "1.2.1" })._getReleaseBlocks(); + + assert.deepEqual( + blocks.map(({ release }) => release), + ["v2.0.0", "v1.2.1", "v1.2.0", "v1.1.1", "v1.1.0", "v1.0.0"], + "Every tag still has a section; 1.x simply has nothing beyond them", + ); + }); + + it("Should still gather them when the branch has passed the version being prepared", async () => { + const blocks = await createGren({ version: "1.1.5" })._getReleaseBlocks(); + + assert.include( + blocks.map(({ release }) => release), + "Unreleased", + "The work is there whatever package.json says", + ); + }); + + it("Should say when the version being prepared is older than the branch", async () => { const blocks = await createGren({ version: "1.1.5" })._getReleaseBlocks(); assert.notInclude( blocks.map(({ release }) => release), "1.1.5", ); - assert.include(warnings.join("\n"), "Skipping the unreleased 1.1.5 section"); + assert.include(warnings.join("\n"), "The version being prepared, 1.1.5, is older than"); + }); + + it("Should say nothing when the version being prepared is simply released", async () => { + await createGren({ version: "2.0.0" })._getReleaseBlocks(); + + assert.notInclude(warnings.join("\n"), "version being prepared"); }); it("Should generate only the tag given with --tags, or the latest tag", async () => { @@ -428,7 +502,8 @@ describe("Gren release membership", () => { assert.deepEqual( selected.map(({ release }) => release), - ["v1.1.1"], + ["Unreleased", "v1.1.1"], + "2.0.0 is released, so master's later commits have no version yet", ); assert.deepEqual( latest.map(({ release }) => release), @@ -523,16 +598,18 @@ describe("Gren release membership", () => { } }); - it("Should not ask about the branch when the version is already tagged", async () => { - // Nothing is read from the branch, so it does not matter where GitHub has it. + it("Should not ask about a branch it has nothing to read from", async () => { + // 1.x is level with v1.2.1, so nothing is read from it and where GitHub has it does not + // matter. const blocks = await createGren({ headSha: "0".repeat(40), - version: "2.0.0", + head: "1.x", + version: "1.2.1", })._getReleaseBlocks(); - assert.deepEqual( + assert.notInclude( blocks.map(({ release }) => release), - ["v2.0.0", "v1.2.1", "v1.2.0", "v1.1.1", "v1.1.0", "v1.0.0"], + "Unreleased", ); }); @@ -563,7 +640,7 @@ describe("Gren release membership", () => { try { // Every tag is here and points where GitHub says, but the commits between them are not. - await createGren({ tags: "v1.2.0", version: "1.2.0" })._getReleaseBlocks(); + await createGren({ tags: "v1.2.0", version: "1.2.0", head: "HEAD" })._getReleaseBlocks(); assert.fail("A shallow clone should stop the changelog"); } catch (error) { assert.include(String(error), "truncated history");