From 3471d755c483a381522c892c1bae669d221b9cdd Mon Sep 17 00:00:00 2001 From: Axel Niklasson Yun Date: Tue, 15 Sep 2026 09:36:23 +0200 Subject: [PATCH 1/2] Add opt-out for branch ref issue detection --- README.md | 55 +++++++++++++++++++----------- src/args.test.ts | 5 +++ src/args.ts | 3 ++ src/git.test.ts | 88 ++++++++++++++++++++++++++++++++++++++++++++++++ src/git.ts | 26 +++++++++----- src/index.ts | 3 ++ 6 files changed, 152 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index ab4141f..c6fd708 100644 --- a/README.md +++ b/README.md @@ -163,26 +163,27 @@ The provider is detected from the remote hostname, or on GitLab CI from the job ### CLI Options -| Option | Commands | Description | -| ---------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--name` | `sync`, `complete`, `update` | Custom release name. For `sync`, the value is applied to the targeted release — both newly created releases and existing ones get the provided name. For `complete` and `update`, sets the name on the targeted release. | -| `--release-version` | `sync`, `complete`, `update` | Release version identifier. For `sync`, defaults to short commit hash. For `complete` and `update`, selects an existing release with that version (errors if none exists); does not change a release's version. If omitted, targets the most recent started release. | -| `--description` | `sync`, `complete`, `update` | Release description. Pass an empty string to clear an existing description. | -| `--stage` | `update` | Target deployment stage (required for `update`) | -| `--include-paths` | `sync` | Filter commits by changed file paths | -| `--include-subjects` | `sync` | Filter commits whose subject (first line) matches a regex | -| `--issue-pattern` | `sync` | Extract issue identifiers captured by group 1 from commit subjects | -| `--link` | `sync`, `complete`, `update` | Add a link to the targeted release. Use `--link "https://example.com"` or `--link "Label=https://example.com"`; repeat the flag to add multiple links. | -| `--document` | `sync`, `complete`, `update` | Attach a document. `--document "Title=...markdown..."`; repeat for multiple docs. Existing documents with the same title on the release are updated. | -| `--document-file` | `sync`, `complete`, `update` | Same as `--document` but reads the body from a file: `--document-file "Title=path/to/file.md"`. Use `-` to read from stdin. | -| `--release-notes` | `sync`, `complete`, `update` | Set the release notes for this release. Inline markdown. If combined with `--release-notes-file`, the last flag wins. | -| `--release-notes-file` | `sync`, `complete`, `update` | Same as `--release-notes` but reads from a file. Use `-` for stdin. | -| `--base-ref` | `sync` | Override the scan base. Exclusive: scans `..HEAD`. | -| `--json` | `sync`, `complete`, `update` | Output result as JSON on stdout. Logs are emitted as JSON Lines (one object per line) on stderr. | -| `--dry-run` | `sync`, `complete`, `update` | Scan commits and call read-only Linear APIs (e.g. recent releases, pipeline settings), but skip the create/update mutations. Logs the action that would have been taken. No release is created or modified. | -| `--quiet` | `sync`, `complete`, `update` | Suppress info-level output. Warnings and errors are still printed. | -| `--verbose` | `sync`, `complete`, `update` | Print detailed progress including debug diagnostics | -| `--timeout` | `sync`, `complete`, `update` | Max duration in seconds before aborting (default: 60) | +| Option | Commands | Description | +| --------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--name` | `sync`, `complete`, `update` | Custom release name. For `sync`, the value is applied to the targeted release — both newly created releases and existing ones get the provided name. For `complete` and `update`, sets the name on the targeted release. | +| `--release-version` | `sync`, `complete`, `update` | Release version identifier. For `sync`, defaults to short commit hash. For `complete` and `update`, selects an existing release with that version (errors if none exists); does not change a release's version. If omitted, targets the most recent started release. | +| `--description` | `sync`, `complete`, `update` | Release description. Pass an empty string to clear an existing description. | +| `--stage` | `update` | Target deployment stage (required for `update`) | +| `--include-paths` | `sync` | Filter commits by changed file paths | +| `--include-subjects` | `sync` | Filter commits whose subject (first line) matches a regex | +| `--issue-pattern` | `sync` | Extract issue identifiers captured by group 1 from commit subjects | +| `--no-branch-ref-detection` | `sync` | Disable issue attribution from Git branch refs; keep commit-message and PR/MR detection | +| `--link` | `sync`, `complete`, `update` | Add a link to the targeted release. Use `--link "https://example.com"` or `--link "Label=https://example.com"`; repeat the flag to add multiple links. | +| `--document` | `sync`, `complete`, `update` | Attach a document. `--document "Title=...markdown..."`; repeat for multiple docs. Existing documents with the same title on the release are updated. | +| `--document-file` | `sync`, `complete`, `update` | Same as `--document` but reads the body from a file: `--document-file "Title=path/to/file.md"`. Use `-` to read from stdin. | +| `--release-notes` | `sync`, `complete`, `update` | Set the release notes for this release. Inline markdown. If combined with `--release-notes-file`, the last flag wins. | +| `--release-notes-file` | `sync`, `complete`, `update` | Same as `--release-notes` but reads from a file. Use `-` for stdin. | +| `--base-ref` | `sync` | Override the scan base. Exclusive: scans `..HEAD`. | +| `--json` | `sync`, `complete`, `update` | Output result as JSON on stdout. Logs are emitted as JSON Lines (one object per line) on stderr. | +| `--dry-run` | `sync`, `complete`, `update` | Scan commits and call read-only Linear APIs (e.g. recent releases, pipeline settings), but skip the create/update mutations. Logs the action that would have been taken. No release is created or modified. | +| `--quiet` | `sync`, `complete`, `update` | Suppress info-level output. Warnings and errors are still printed. | +| `--verbose` | `sync`, `complete`, `update` | Print detailed progress including debug diagnostics | +| `--timeout` | `sync`, `complete`, `update` | Max duration in seconds before aborting (default: 60) | ### Command Targeting @@ -269,6 +270,20 @@ linear-release sync --issue-pattern='\b([A-Z]+-\d+)\b' Identifiers that do not exist in the workspace are ignored server-side, so the broad recipe is safe but can be noisy (for example, it also matches `UTF-8`). Reverting a commit whose issues were linked only through `--issue-pattern` does not un-link them, just as with the built-in subject patterns. +### Disable branch ref detection + +By default, `sync` can extract issue identifiers from Git branch refs pointing at regular commits in the scan. Those refs can include unrelated branches created from shipped commits without any new work. + +Use `--no-branch-ref-detection` to ignore these refs: + +```bash +linear-release sync --no-branch-ref-detection +``` + +Issue identifiers in commit messages, source branches recorded in merge messages, PR/MR references, and `--issue-pattern` detection remain enabled. The flag applies to both added and reverted issue attribution. + +This disables all attribution from branch refs, including legitimate fast-forward merges whose issue key exists only in the branch name. Those workflows need an issue identifier in the commit message or a PR/MR reference that Linear can resolve. Omitting the flag preserves existing behavior. + ### Release Links `--link` attaches external URLs to the release — a GitHub release page, a CI run, a deployment dashboard. diff --git a/src/args.test.ts b/src/args.test.ts index aa9b688..2b8cf7f 100644 --- a/src/args.test.ts +++ b/src/args.test.ts @@ -134,6 +134,11 @@ describe("parseCLIArgs", () => { expect(parseCLIArgs([]).issuePattern).toBeNull(); }); + it("detects branch refs by default and allows opting out", () => { + expect(parseCLIArgs([]).detectBranchRefs).toBe(true); + expect(parseCLIArgs(["sync", "--no-branch-ref-detection"]).detectBranchRefs).toBe(false); + }); + it("returns --issue-pattern as the raw pattern string", () => { expect(parseCLIArgs(["--issue-pattern", "\\[([A-Z]+-\\d+)\\]"]).issuePattern).toBe("\\[([A-Z]+-\\d+)\\]"); }); diff --git a/src/args.ts b/src/args.ts index 9e21130..81eb860 100644 --- a/src/args.ts +++ b/src/args.ts @@ -29,6 +29,7 @@ export type ParsedCLIArgs = { includePaths: string[]; includeSubjects: string | null; issuePattern: string | null; + detectBranchRefs: boolean; links: ReleaseLink[]; documents: ReleaseDocumentSpec[]; releaseNotes?: ReleaseNoteSpec; @@ -137,6 +138,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { "include-paths": { type: "string" }, "include-subjects": { type: "string" }, "issue-pattern": { type: "string" }, + "no-branch-ref-detection": { type: "boolean", default: false }, link: { type: "string", multiple: true }, document: { type: "string", multiple: true }, "document-file": { type: "string", multiple: true }, @@ -249,6 +251,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs { : [], includeSubjects, issuePattern, + detectBranchRefs: !values["no-branch-ref-detection"], links, documents, releaseNotes, diff --git a/src/git.test.ts b/src/git.test.ts index 0287017..6a34866 100644 --- a/src/git.test.ts +++ b/src/git.test.ts @@ -7,6 +7,8 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { findBaseSha } from "./base-sha"; import { findAnchorAheadOfHead } from "./scan-base"; import { ConfigurationError } from "./provider"; +import { parseCLIArgs } from "./args"; +import { scanCommits } from "./scan"; import { assertGitAvailable, buildPathspecArgs, @@ -1268,6 +1270,92 @@ describe("merge commit handling", () => { const interior = result.find((c) => c.sha === ff.commits.feature); expect(interior?.branchName).toBe("user/REL-9-feature"); }); + + it("can disable branch-only attribution for fast-forwarded work", async () => { + const commits = await getCommitContextsBetweenShas(ff.commits.base, ff.commits.tip, { + cwd: ff.cwd, + detectBranchRefs: false, + }); + expect(commits.find((c) => c.sha === ff.commits.feature)?.branchName).toBeNull(); + expect(scanCommits(commits.reverse()).issueReferences).toEqual([]); + }); + }); +}); + +describe("--no-branch-ref-detection", () => { + it.each(["tip", "interior", "single"] as const)("ignores an empty issue branch in a %s scan", async (position) => { + const { cwd, base } = initTempRepo({ + prefix: "linear-release-empty-ref-", + dirs: [], + seedFile: { path: "README.md", content: "initial" }, + }); + try { + runGit('commit --allow-empty -m "feat(DEV-4617): add private S3 bucket (#1283)"', cwd); + runGit('commit --allow-empty -m "chore: bump version to 0.2.248 [skip ci]"', cwd); + const release = runGit("rev-parse HEAD", cwd); + runGit("tag v0.2.248", cwd); + runGit("branch DEV-4661", cwd); + runGit(`update-ref refs/remotes/origin/DEV-4661 ${release}`, cwd); + runGit(`update-ref refs/remotes/origin/main ${release}`, cwd); + if (position === "interior") { + runGit('commit --allow-empty -m "chore: later main work"', cwd); + runGit(`update-ref refs/remotes/origin/main ${runGit("rev-parse HEAD", cwd)}`, cwd); + } + const from = position === "single" ? release : base; + const defaultCommits = await getCommitContextsBetweenShas(from, release, { cwd }); + expect(scanCommits(defaultCommits.reverse()).issueReferences.map((r) => r.identifier)).toEqual(["DEV-4661"]); + + const { detectBranchRefs, issuePattern } = parseCLIArgs([ + "sync", + "--no-branch-ref-detection", + "--issue-pattern=^[a-z]+\\(([A-Z]+-\\d+)\\)!?:", + ]); + const commits = await getCommitContextsBetweenShas(from, release, { cwd, detectBranchRefs }); + const result = scanCommits(commits.reverse(), { issuePattern }); + expect(result.issueReferences.map((r) => r.identifier)).toEqual(position === "single" ? [] : ["DEV-4617"]); + expect(result.prNumbers).toEqual(position === "single" ? [] : [1283]); + expect(result.debugSink.issues).not.toHaveProperty("DEV-4661"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + + it("preserves source branches recorded in merge messages", async () => { + const repo = createTempRepoWithMerge(); + try { + const commits = await getCommitContextsBetweenShas(repo.commits.base, repo.commits.mergeCommit, { + cwd: repo.cwd, + detectBranchRefs: false, + }); + const result = scanCommits(commits.reverse()); + expect(result.issueReferences.map((r) => r.identifier)).toEqual(["ENG-123"]); + expect(result.prNumbers).toEqual([42]); + expect(result.debugSink.issues["ENG-123"]).toEqual([ + expect.objectContaining({ sha: repo.commits.mergeCommit, source: "branch_name" }), + ]); + } finally { + rmSync(repo.cwd, { recursive: true, force: true }); + } + }); + + it("ignores revert refs without suppressing message issue keys or PRs", async () => { + const { cwd, base } = initTempRepo({ + prefix: "linear-release-revert-ref-", + dirs: [], + seedFile: { path: "README.md", content: "initial" }, + }); + try { + runGit('commit --allow-empty -m "Fixes ENG-123 (#42)"', cwd); + runGit("branch revert-42-ENG-123", cwd); + const head = runGit("rev-parse HEAD", cwd); + const commits = await getCommitContextsBetweenShas(base, head, { cwd, detectBranchRefs: false }); + const result = scanCommits(commits.reverse()); + expect(result.issueReferences.map((r) => r.identifier)).toEqual(["ENG-123"]); + expect(result.revertedIssueReferences).toEqual([]); + expect(result.prNumbers).toEqual([42]); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } }); }); diff --git a/src/git.ts b/src/git.ts index 3e1c738..d0741dc 100644 --- a/src/git.ts +++ b/src/git.ts @@ -391,8 +391,10 @@ export function extractBranchNameFromMergeMessage(message: string | null | undef * (whose key is not this commit's work), so a merge takes its branch name solely * from a parseable merge message. A regular commit may fall back to decorations — * the GitLab fast-forward / direct-push case where the key lives only in the ref. + * Disabling detectBranchRefs skips that fallback, including unrelated empty + * branches pointing at shipped commits, while preserving merge-message branches. */ -function parseCommitChunk(chunk: string): CommitContext { +function parseCommitChunk(chunk: string, detectBranchRefs: boolean): CommitContext { const [sha, rawMessage, rawDecorations, rawParents] = chunk.split("\x1f"); // Collapse runs of horizontal whitespace, but keep newlines so downstream // extractors can tell the title from the body and skip nested commit blocks. @@ -405,7 +407,9 @@ function parseCommitChunk(chunk: string): CommitContext { .split(/\s+/) .filter((p) => /^[0-9a-f]{40}$/i.test(p)); const isMerge = parents.length >= 2; - const branchName = extractBranchNameFromMergeMessage(message) ?? (isMerge ? null : extractBranchName(rawDecorations)); + const branchName = + extractBranchNameFromMergeMessage(message) ?? + (isMerge || !detectBranchRefs ? null : extractBranchName(rawDecorations)); return { sha: sha.trim(), branchName, message, parents }; } @@ -454,7 +458,7 @@ export function ensureCommitAvailable(sha: string, cwd: string = process.cwd()): ); } -function runLog(rangeArgs: string[], cwd: string): Promise { +function runLog(rangeArgs: string[], cwd: string, detectBranchRefs = true): Promise { const args = ["log", "--format=%H%x1f%B%x1f%D%x1f%P%x1e", ...rangeArgs]; return new Promise((resolve, reject) => { @@ -471,7 +475,7 @@ function runLog(rangeArgs: string[], cwd: string): Promise { const record = stdoutBuffer.subarray(0, separatorIndex).toString("utf8"); stdoutBuffer = stdoutBuffer.subarray(separatorIndex + 1); if (record.trim().length > 0) { - commits.push(parseCommitChunk(record)); + commits.push(parseCommitChunk(record, detectBranchRefs)); } separatorIndex = stdoutBuffer.indexOf(0x1e); } @@ -507,7 +511,7 @@ function runLog(rangeArgs: string[], cwd: string): Promise { } const trailingRecord = stdoutBuffer.toString("utf8"); if (trailingRecord.trim().length > 0) { - commits.push(parseCommitChunk(trailingRecord)); + commits.push(parseCommitChunk(trailingRecord, detectBranchRefs)); } settled = true; resolve(commits); @@ -574,14 +578,20 @@ function mergeDeliversToPaths(commit: CommitContext, pathspecArgs: string[], cwd * @param toSha - Ending commit SHA (inclusive) * @param options.includePaths - Glob patterns to filter commits by file paths (relative to repo root) * @param options.inspectSingleCommit - When SHAs match, inspect that one commit instead of treating it as an empty range + * @param options.detectBranchRefs - Infer source branches from ref decorations (defaults to true) * @param options.cwd - Working directory for git commands (defaults to process.cwd()) */ export async function getCommitContextsBetweenShas( fromSha: string, toSha: string, - options: { includePaths?: string[] | null; inspectSingleCommit?: boolean; cwd?: string } = {}, + options: { + includePaths?: string[] | null; + inspectSingleCommit?: boolean; + detectBranchRefs?: boolean; + cwd?: string; + } = {}, ): Promise { - const { includePaths = null, inspectSingleCommit = true, cwd = process.cwd() } = options; + const { includePaths = null, inspectSingleCommit = true, detectBranchRefs = true, cwd = process.cwd() } = options; if (!SHA_PATTERN.test(fromSha)) { warn(`Invalid "from" SHA format "${fromSha}"`); @@ -599,7 +609,7 @@ export async function getCommitContextsBetweenShas( ...(inspectingSingleCommit ? ["--no-walk", toSha] : [`${fromSha}..${toSha}`]), ...pathspecArgs, ]; - const logged = await runLog(rangeArgs, cwd); + const logged = await runLog(rangeArgs, cwd, detectBranchRefs); const commits = pathspecArgs.length ? logged.filter((commit) => mergeDeliversToPaths(commit, pathspecArgs, cwd)) : logged; diff --git a/src/index.ts b/src/index.ts index 166af2a..c471530 100644 --- a/src/index.ts +++ b/src/index.ts @@ -71,6 +71,7 @@ Options: --stage= Deployment stage (required for update) --include-paths= Filter commits by file paths (comma-separated globs) --include-subjects= Filter commits whose subject (first line) matches the regex + --no-branch-ref-detection Ignore Git branch refs when attributing issues; keep commit-message detection --issue-pattern= Extract issue IDs captured by group 1 from commit subjects (e.g. "\\[([A-Z]+-\\d+)\\]") --link Add a link to the targeted release (repeatable) --document Attach a document to the release (repeatable, Title required) @@ -133,6 +134,7 @@ const { includePaths, includeSubjects, issuePattern, + detectBranchRefs, links, documents: documentSpecs, releaseNotes: releaseNotesSpec, @@ -357,6 +359,7 @@ async function syncCommand(): Promise<{ const commits = await getCommitContextsBetweenShas(latestSha, currentCommit.commit, { includePaths: effectiveIncludePaths, inspectSingleCommit: scanBase.kind !== "base-ref", + detectBranchRefs, }); const broadScanWarning = getBroadScanWarning(commits.length, scanBase); if (broadScanWarning) { From d4ca4c5b9c12476c85f56f7c2898ca38a42bd4ff Mon Sep 17 00:00:00 2001 From: Axel Niklasson Yun Date: Tue, 15 Sep 2026 09:39:08 +0200 Subject: [PATCH 2/2] Clarify branch ref detection flag help --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index c471530..ede3e37 100644 --- a/src/index.ts +++ b/src/index.ts @@ -71,7 +71,7 @@ Options: --stage= Deployment stage (required for update) --include-paths= Filter commits by file paths (comma-separated globs) --include-subjects= Filter commits whose subject (first line) matches the regex - --no-branch-ref-detection Ignore Git branch refs when attributing issues; keep commit-message detection + --no-branch-ref-detection Disable issue detection from Git branch pointers; preserve issue keys and source branches recorded in commit messages --issue-pattern= Extract issue IDs captured by group 1 from commit subjects (e.g. "\\[([A-Z]+-\\d+)\\]") --link Add a link to the targeted release (repeatable) --document Attach a document to the release (repeatable, Title required)