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
263 changes: 263 additions & 0 deletions src/domains/rules/NoRepeatedSubjectLines.tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import { describe, expect, it } from "vitest"
import { fakeCommitFactory } from "#commits/Commit.fixtures.ts"
import type { Commit } from "#commits/Commit.ts"
import { fakeConfiguration } from "#configurations/Configuration.fixtures.ts"
import { commitConcern } from "#rules/concerns/CommitConcern.ts"
import type { Concerns } from "#rules/concerns/Concern.ts"
import { noRepeatedSubjectLines } from "#rules/NoRepeatedSubjectLines.ts"
import type { RuleKey, RuleOptions } from "#rules/Rule.ts"
import { fakeCommitSha } from "#types/CommitSha.fixtures.ts"
import type { Vector } from "#types/Vector.ts"

const rule = "noRepeatedSubjectLines" satisfies RuleKey
const enabled: RuleOptions<typeof rule> = {}

const fakeCommit = fakeCommitFactory(fakeConfiguration())

describe("when verifying a set of multiple commits and some commits have repeated subject lines", () => {
const commits: Vector<Commit, 10> = [
fakeCommit({ message: "test" }),
fakeCommit({ message: "another bugfix" }),
fakeCommit({ message: "Upgrade React to 19.2.6" }),
fakeCommit({ message: "#15 make some toast" }),
fakeCommit({ message: " Tune the kettle" }),
fakeCommit({ message: "#15 make some toast" }),
fakeCommit({ message: "Label the mystery switch" }),
fakeCommit({ message: "#15 make some toast" }),
fakeCommit({ message: "Upgrade React to 19.2.6" }),
fakeCommit({ message: " Tune the kettle" }),
]

describe("and the rule is enabled", () => {
const actualConcerns = noRepeatedSubjectLines(commits, enabled)

it("raises concerns about the commits with repeated subject lines", () => {
expect(actualConcerns).toEqual<Concerns>([
commitConcern(rule, commits[5].sha),
commitConcern(rule, commits[7].sha),
commitConcern(rule, commits[8].sha),
commitConcern(rule, commits[9].sha),
])
})
})

describe("and the rule is disabled", () => {
const actualConcerns = noRepeatedSubjectLines(commits, null)

it("does not raise any concerns", () => {
expect(actualConcerns).toEqual<Concerns>([])
})
})
})

describe("when verifying a set of multiple commits and some commits have repeated subject lines differing only by whitespace or case", () => {
const commits: Vector<Commit, 12> = [
fakeCommit({ message: "test" }),
fakeCommit({ message: "another bugfix" }),
fakeCommit({ message: "GL-27 Make some toast" }),
fakeCommit({ message: "tune the kettle" }),
fakeCommit({ message: "AnOtHeR bUgFiX" }),
fakeCommit({ message: "GL-27 make some toast" }),
fakeCommit({ message: "Label the mystery switch" }),
fakeCommit({ message: " GL-27 make some TOAST " }),
fakeCommit({ message: " Tune the kettle" }),
fakeCommit({ message: "TEST!!" }),
fakeCommit({ message: "tune the kettle " }),
fakeCommit({ message: " ANOTHER bugfix" }),
]

describe("and the rule is enabled", () => {
const actualConcerns = noRepeatedSubjectLines(commits, enabled)

it("raises concerns about the commits with repeated subject lines being whitespace- and case-insensitive", () => {
expect(actualConcerns).toEqual<Concerns>([
commitConcern(rule, commits[4].sha),
commitConcern(rule, commits[5].sha),
commitConcern(rule, commits[7].sha),
commitConcern(rule, commits[8].sha),
commitConcern(rule, commits[10].sha),
commitConcern(rule, commits[11].sha),
])
})
})

describe("and the rule is disabled", () => {
const actualConcerns = noRepeatedSubjectLines(commits, null)

it("does not raise any concerns", () => {
expect(actualConcerns).toEqual<Concerns>([])
})
})
})

describe("when verifying a set of multiple commits and merge commits have repeated subject lines", () => {
const commits: Vector<Commit, 9> = [
fakeCommit({ message: "upgrade the snack dispenser" }),
fakeCommit({
message: "Calibrate the waffle dial",
parents: [fakeCommitSha(), fakeCommitSha()],
}),
fakeCommit({ message: "Calibrate the waffle dial" }),
fakeCommit({ message: "install the `PantryDashboard`" }),
fakeCommit({
message: "install the `PantryDashboard`",
parents: [fakeCommitSha(), fakeCommitSha(), fakeCommitSha()],
}),
fakeCommit({
message: "Calibrate the waffle dial",
parents: [fakeCommitSha(), fakeCommitSha()],
}),
fakeCommit({ message: "upgrade the snack dispenser" }),
fakeCommit({ message: "install the `PantryDashboard`" }),
fakeCommit({
message: "install the `PantryDashboard`",
parents: [fakeCommitSha(), fakeCommitSha()],
}),
]

describe("and the rule is enabled", () => {
const actualConcerns = noRepeatedSubjectLines(commits, enabled)

it("raises concerns about the commits with repeated subject lines, but ignores merge commits", () => {
expect(actualConcerns).toEqual<Concerns>([
commitConcern(rule, commits[2].sha),
commitConcern(rule, commits[6].sha),
commitConcern(rule, commits[7].sha),
])
})
})

describe("and the rule is disabled", () => {
const actualConcerns = noRepeatedSubjectLines(commits, null)

it("does not raise any concerns", () => {
expect(actualConcerns).toEqual<Concerns>([])
})
})
})

describe("when verifying a set of multiple commits and revert commits have repeated subject lines", () => {
const commits: Vector<Commit, 8> = [
fakeCommit({ message: "Refactor the office playlist scheduler" }),
fakeCommit({ message: "upgrade the snack dispenser" }),
fakeCommit({ message: 'Revert "upgrade the snack dispenser"' }),
fakeCommit({ message: "Revert the snack dispenser manually" }),
fakeCommit({ message: "Refactor the office playlist scheduler" }),
fakeCommit({ message: "upgrade the snack dispenser" }),
fakeCommit({ message: 'Revert "upgrade the snack dispenser"' }),
fakeCommit({ message: "upgrade the snack dispenser" }),
]

describe("and the rule is enabled", () => {
const actualConcerns = noRepeatedSubjectLines(commits, enabled)

it("raises concerns about the commits with repeated subject lines, but ignores commits with revert markers", () => {
expect(actualConcerns).toEqual<Concerns>([
commitConcern(rule, commits[4].sha),
commitConcern(rule, commits[5].sha),
commitConcern(rule, commits[7].sha),
])
})
})

describe("and the rule is disabled", () => {
const actualConcerns = noRepeatedSubjectLines(commits, null)

it("does not raise any concerns", () => {
expect(actualConcerns).toEqual<Concerns>([])
})
})
})

describe("when verifying a set of multiple commits and squash commits have repeated subject lines", () => {
const commits: Vector<Commit, 12> = [
fakeCommit({ message: "Refactor the office playlist scheduler" }),
fakeCommit({ message: "fixup! Refactor the office playlist scheduler" }),
fakeCommit({ message: "test" }),
fakeCommit({ message: "upgrade the snack dispenser" }),
fakeCommit({ message: "squash! Refactor the office playlist scheduler" }),
fakeCommit({ message: "fixup! Refactor the office playlist scheduler" }),
fakeCommit({ message: "upgrade the snack dispenser" }),
fakeCommit({ message: "fixup! squash! Refactor the office playlist scheduler" }),
fakeCommit({ message: "test" }),
fakeCommit({ message: "amend!test" }),
fakeCommit({ message: "amend!amend!test" }),
fakeCommit({ message: " Test" }),
]

describe("and the rule is enabled", () => {
const actualConcerns = noRepeatedSubjectLines(commits, enabled)

it("raises concerns about the commits with repeated subject lines, but ignores commits with squash markers", () => {
expect(actualConcerns).toEqual<Concerns>([
commitConcern(rule, commits[6].sha),
commitConcern(rule, commits[8].sha),
commitConcern(rule, commits[11].sha),
])
})
})

describe("and the rule is disabled", () => {
const actualConcerns = noRepeatedSubjectLines(commits, null)

it("does not raise any concerns", () => {
expect(actualConcerns).toEqual<Concerns>([])
})
})
})

describe("when verifying a set of multiple commits and some commits have repeated subject lines, but different issue links", () => {
const commits: Vector<Commit, 4> = [
fakeCommit({ message: "#1 replace guesswork with a tiny chart" }),
fakeCommit({ message: "#2 replace guesswork with a tiny chart" }),
fakeCommit({ message: "#1 replace guesswork with a tiny chart" }),
fakeCommit({ message: "#3 replace guesswork with a tiny chart" }),
]

describe("and the rule is enabled", () => {
const actualConcerns = noRepeatedSubjectLines(commits, enabled)

it("raises concerns about the commits with repeated subject lines, but ignores commits with different issue links", () => {
expect(actualConcerns).toEqual<Concerns>([commitConcern(rule, commits[2].sha)])
})
})

describe("and the rule is disabled", () => {
const actualConcerns = noRepeatedSubjectLines(commits, null)

it("does not raise any concerns", () => {
expect(actualConcerns).toEqual<Concerns>([])
})
})
})

describe("when verifying a set of multiple commits and no commits have repeated subject lines", () => {
const commits: Vector<Commit, 10> = [
fakeCommit({ message: "init" }),
fakeCommit({ message: "Install the pastry metrics panel" }),
fakeCommit({ message: "bugfix, sorry!!" }),
fakeCommit({ message: "replace guesswork with a tiny chart" }),
fakeCommit({ message: "Bring order to the button drawer" }),
fakeCommit({ message: "fixup! Bring order to the button drawer" }),
fakeCommit({ message: "fixup! Bring order to the button drawer" }),
fakeCommit({ message: " explain why the build needs socks" }),
fakeCommit({ message: "Test 1 2 3" }),
fakeCommit({ message: "fixup! replace guesswork with a tiny chart" }),
]

describe("and the rule is enabled", () => {
const actualConcerns = noRepeatedSubjectLines(commits, enabled)

it("does not raise any concerns", () => {
expect(actualConcerns).toEqual<Concerns>([])
})
})

describe("and the rule is disabled", () => {
const actualConcerns = noRepeatedSubjectLines(commits, null)

it("does not raise any concerns", () => {
expect(actualConcerns).toEqual<Concerns>([])
})
})
})
48 changes: 42 additions & 6 deletions src/domains/rules/NoRepeatedSubjectLines.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,51 @@
import type { Commit, Commits } from "#commits/Commit.ts"
import type { Commits } from "#commits/Commit.ts"
import type { TokenisedLine } from "#commits/tokens/Token.ts"
import { commitConcern } from "#rules/concerns/CommitConcern.ts"
import type { Concern, Concerns } from "#rules/concerns/Concern.ts"
import type { RuleKey } from "#rules/Rule.ts"
import type { EmptyObject } from "#types/EmptyObject.ts"
import { notNullish } from "#utilities/Arrays.ts"
import { collapseWhitespace } from "#utilities/Strings.ts"

const _rule = "noRepeatedSubjectLines" satisfies RuleKey
const rule = "noRepeatedSubjectLines" satisfies RuleKey

/**
* Verifies that each commit has a unique subject line within the branch.
*
* Commits that repeat a subject line are probably meant to be squash commits or the author may have forgotten to update the subject line.
* Rewording commits or combining squash commits with their ancestors makes the commit history cleaner and easier to read.
*
* It is whitespace- and case-insensitive.
* It ignores merge commits, revert commits, and commits with squash markers.
*/
export function noRepeatedSubjectLines(commits: Commits, options: EmptyObject | null): Concerns {
return options !== null ? commits.map(verifyCommit).filter(notNullish) : []
return options !== null ? [...verifyCommits(commits)] : []
}

function verifyCommit(_commit: Commit): Concern | null {
throw new Error("The `noRepeatedSubjectLines` rule has not been implemented yet") // TODO: To be implemented.
function* verifyCommits(commits: Commits): Generator<Concern> {
const previousSubjectLines = new Set<string>()

for (const commit of commits) {
const normalisedSubjectLine = getNormalisedSubjectLine(commit.subjectLine)

if (normalisedSubjectLine !== null) {
if (previousSubjectLines.has(normalisedSubjectLine) && !commit.isMergeCommit) {
yield commitConcern(rule, commit.sha)
}
previousSubjectLines.add(normalisedSubjectLine)
}
}
}

function getNormalisedSubjectLine(subjectLine: TokenisedLine): string | null {
const significantText: Array<string> = []

for (const token of subjectLine) {
if (token.type === "revert-marker" || token.type === "squash-marker") {
return null
}

significantText.push(collapseWhitespace(token.value.trim()).toLowerCase())
}

return significantText.join("")
}
47 changes: 47 additions & 0 deletions src/domains/rules/reports/CommitwiseReport.tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,53 @@ describe("when 'noMergeCommits' has a concern about the commit with a long subje
})
})

describe("when 'noRepeatedSubjectLines' has a concern about the commit", () => {
const configuration = fakeConfiguration()
const fakeCommit = fakeCommitFactory(configuration)

const commit = fakeCommit({
sha: "8c1fbd48d21686c574a01fd2db4be1c991d897",
message: "test",
})
const concern = commitConcern("noRepeatedSubjectLines", commit.sha)

it("describes the rule violation in the commit", () => {
const actualOutput = commitwiseReport([concern], [commit], configuration)
expect(actualOutput).toBe(
`
8c1fbd4 test
╭─────
╰─ Commits must have unique subject lines within a branch.
(noRepeatedSubjectLines)
`.trim(),
)
})
})

describe("when 'noRepeatedSubjectLines' has a concern about the commit with a long subject line", () => {
const configuration = fakeConfiguration()
const fakeCommit = fakeCommitFactory(configuration)

const commit = fakeCommit({
sha: "f3359c9a89b46736a36fa2117515af2f5c93",
message:
"GH-246 Replace guesswork with a tiny chart and upgrade the `ButterflyService` to 8.0.31",
})
const concern = commitConcern("noRepeatedSubjectLines", commit.sha)

it("describes the rule violation in the commit", () => {
const actualOutput = commitwiseReport([concern], [commit], configuration)
expect(actualOutput).toBe(
`
f3359c9 GH-246 Replace guesswork with a tiny chart and upgrade the \`ButterflyService\` to 8.0.31
╭────────────────────────────────────────────────────────────────────────────────────────
╰─ Commits must have unique subject lines within a branch.
(noRepeatedSubjectLines)
`.trim(),
)
})
})

describe("when 'noRevertRevertCommits' has a concern about characters 0-16 of the subject line", () => {
const configuration = fakeConfiguration()
const fakeCommit = fakeCommitFactory(configuration)
Expand Down
2 changes: 1 addition & 1 deletion src/domains/rules/reports/CommitwiseReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ function getRuleMessage(rule: RuleKey, configuration: Configuration): RuleMessag
return ruleMessage("Merge commits are not allowed.")
}
case "noRepeatedSubjectLines": {
throw new Error(`Not implemented yet: ${rule}`)
return ruleMessage("Commits must have unique subject lines within a branch.")
}
case "noRestrictedFooterLines": {
throw new Error(`Not implemented yet: ${rule}`)
Expand Down
Loading
Loading