Skip to content

Commit d8c47d3

Browse files
committed
Add --base-ref to manually override scan base for initialization and migration flows
1 parent d30cd25 commit d8c47d3

7 files changed

Lines changed: 349 additions & 44 deletions

File tree

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ linear-release update --stage="in review" --name="Release 1.2.0"
155155
| `--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. |
156156
| `--stage` | `update` | Target deployment stage (required for `update`) |
157157
| `--include-paths` | `sync` | Filter commits by changed file paths |
158+
| `--base-ref` | `sync` | One-time scan base override for initialization or migration. Exclusive: scans `<base-ref>..HEAD`. |
158159
| `--json` | `sync`, `complete`, `update` | Output result as JSON on stdout. Logs are emitted as JSON Lines (one object per line) on stderr. |
159160
| `--quiet` | `sync`, `complete`, `update` | Suppress info-level output. Warnings and errors are still printed. |
160161
| `--verbose` | `sync`, `complete`, `update` | Print detailed progress including debug diagnostics |
@@ -220,10 +221,23 @@ Path patterns can also be configured in your pipeline settings in Linear. If bot
220221
> [!NOTE]
221222
> **First sync**: when no prior release exists for the pipeline, only the current commit is scanned (there's no previous SHA to bound the range from).
222223
224+
### Overriding the Scan Base
225+
226+
Use `--base-ref` as a one-time initialization or migration escape hatch when Linear's previous release SHA is missing, unreachable, or incompatible with the current repository history.
227+
228+
```bash
229+
linear-release sync --base-ref=v1.2.0 --include-paths="apps/api/**"
230+
```
231+
232+
The base ref is exclusive: linear-release scans `<base-ref>..HEAD`, matching Git range syntax. Pass the last commit, tag, or ref that should be treated as already released, not the first commit you want included.
233+
234+
When `--base-ref` is accepted, the release is synced and current `HEAD` is stored as the new release baseline even if zero commits match `--include-paths`. Remove `--base-ref` after the successful sync; future runs should use the normal previous-release baseline. If a reachable previous release baseline already exists, linear-release rejects `--base-ref` to avoid repeatedly rescanning old history.
235+
223236
## Troubleshooting
224237

225238
- **Unexpected release was updated/completed**: pass `--release-version` explicitly so the command does not target the latest started/planned release.
226-
- **No release created by `sync`**: if no commits match the computed range (or path filters), `sync` returns `{"release":null}`.
239+
- **No release created by `sync`**: without `--base-ref`, if no commits match the computed range (or path filters), `sync` returns `{"release":null}`.
240+
- **Need to backfill the first release or migrate rewritten history**: run `sync` once with `--base-ref=<ref>` to set an explicit scan base, then remove the flag.
227241
- **Stage update fails**: `--stage` matches first by exact name, then case-insensitively with dashes and underscores treated as spaces. If multiple stages normalize to the same value, pass the exact stage name to disambiguate.
228242
- **`sync --release-version` fails because the matching release is archived**: restore the archived release in Linear before re-syncing.
229243
- **Operation timed out**: the CLI aborts after 60 seconds by default. For large repositories or slow networks, increase the limit with `--timeout=120`.

src/args.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,16 @@ describe("parseCLIArgs", () => {
4343
expect(result.stageName).toBe("production");
4444
});
4545

46+
it("parses --base-ref", () => {
47+
const result = parseCLIArgs(["--base-ref", "v1.2.3"]);
48+
expect(result.baseRef).toBe("v1.2.3");
49+
});
50+
51+
it("parses --base-ref with = syntax", () => {
52+
const result = parseCLIArgs(["--base-ref=main~5"]);
53+
expect(result.baseRef).toBe("main~5");
54+
});
55+
4656
it("defaults --json to false", () => {
4757
const result = parseCLIArgs([]);
4858
expect(result.jsonOutput).toBe(false);

src/args.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export type ParsedCLIArgs = {
66
releaseName?: string;
77
releaseVersion?: string;
88
stageName?: string;
9+
baseRef?: string;
910
includePaths: string[];
1011
jsonOutput: boolean;
1112
timeoutSeconds: number;
@@ -19,6 +20,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
1920
name: { type: "string" },
2021
"release-version": { type: "string" },
2122
stage: { type: "string" },
23+
"base-ref": { type: "string" },
2224
"include-paths": { type: "string" },
2325
json: { type: "boolean", default: false },
2426
timeout: { type: "string" },
@@ -52,6 +54,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
5254
releaseName: values.name,
5355
releaseVersion: values["release-version"],
5456
stageName: values.stage,
57+
baseRef: values["base-ref"],
5558
includePaths: values["include-paths"]
5659
? values["include-paths"]
5760
.split(",")

src/git.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { execSync } from "node:child_process";
1+
import { execFileSync, execSync } from "node:child_process";
22
import type { CommitContext, GitInfo, RepoInfo } from "./types";
33
import { error as logError, verbose, warn } from "./log";
44

@@ -263,6 +263,31 @@ export function verifyAncestorReachable(sha: string, headSha: string, cwd: strin
263263

264264
const SHA_PATTERN = /^[0-9a-f]{7,40}$/i;
265265

266+
/**
267+
* Resolves a git ref, tag, or SHA to a full commit SHA.
268+
*
269+
* Short/full SHAs in shallow clones may not resolve until history is deepened,
270+
* so SHA-like inputs get the same availability treatment as release baselines.
271+
*/
272+
export function resolveCommitRef(ref: string, cwd: string = process.cwd()): string {
273+
const resolve = () =>
274+
execFileSync("git", ["rev-parse", "--verify", `${ref}^{commit}`], {
275+
cwd,
276+
stdio: ["ignore", "pipe", "ignore"],
277+
encoding: "utf8",
278+
}).trim();
279+
280+
try {
281+
return resolve();
282+
} catch {
283+
if (SHA_PATTERN.test(ref)) {
284+
ensureCommitAvailable(ref, cwd);
285+
return resolve();
286+
}
287+
throw new Error(`Could not resolve "${ref}" to a commit. Use a valid commit SHA, tag, or ref.`);
288+
}
289+
}
290+
266291
/**
267292
* Extracts the branch name from a merge commit message.
268293
* Supports:
@@ -369,19 +394,21 @@ function runLog(rangeArgs: string, cwd: string): CommitContext[] {
369394
* `--no-walk` (only when `fromSha === toSha`): without it, `git log -1 <sha>
370395
* -- <paths>` walks back from `<sha>` to the first ancestor matching the
371396
* pathspec — silently returning an unrelated commit when `<sha>` itself
372-
* doesn't match.
397+
* doesn't match. Callers that need true `sha..sha` empty-range semantics can
398+
* pass `inspectSingleCommit: false`.
373399
*
374400
* @param fromSha - Starting commit SHA (exclusive)
375401
* @param toSha - Ending commit SHA (inclusive)
376402
* @param options.includePaths - Glob patterns to filter commits by file paths (relative to repo root)
403+
* @param options.inspectSingleCommit - When SHAs match, inspect that one commit instead of treating it as an empty range
377404
* @param options.cwd - Working directory for git commands (defaults to process.cwd())
378405
*/
379406
export function getCommitContextsBetweenShas(
380407
fromSha: string,
381408
toSha: string,
382-
options: { includePaths?: string[] | null; cwd?: string } = {},
409+
options: { includePaths?: string[] | null; inspectSingleCommit?: boolean; cwd?: string } = {},
383410
): CommitContext[] {
384-
const { includePaths = null, cwd = process.cwd() } = options;
411+
const { includePaths = null, inspectSingleCommit = true, cwd = process.cwd() } = options;
385412

386413
if (!SHA_PATTERN.test(fromSha)) {
387414
warn(`Invalid "from" SHA format "${fromSha}"`);
@@ -392,17 +419,18 @@ export function getCommitContextsBetweenShas(
392419
return [];
393420
}
394421

422+
const inspectingSingleCommit = fromSha === toSha && inspectSingleCommit;
395423
const args = [
396424
includePaths?.length ? "--full-history" : "",
397-
fromSha === toSha ? `--no-walk ${toSha}` : `${fromSha}..${toSha}`,
425+
inspectingSingleCommit ? `--no-walk ${toSha}` : `${fromSha}..${toSha}`,
398426
buildPathspecArgs(includePaths),
399427
]
400428
.filter(Boolean)
401429
.join(" ");
402430
const commits = runLog(args, cwd);
403431

404432
if (commits.length === 0) {
405-
if (fromSha === toSha) {
433+
if (inspectingSingleCommit) {
406434
const pathFilter = includePaths?.length ? ` include paths: ${includePaths.join(", ")}` : "";
407435
verbose(`Commit ${toSha.slice(0, 7)} did not match${pathFilter}`);
408436
} else {

src/index.ts

Lines changed: 75 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,16 @@ import {
55
getCommitContextsBetweenShas,
66
getCurrentGitInfo,
77
getRepoInfo,
8-
resolveFirstSyncBoundary,
8+
resolveCommitRef,
99
verifyAncestorReachable,
1010
} from "./git";
11-
import { findBaseSha } from "./base-sha";
11+
import {
12+
assertBaseRefIsAncestor,
13+
assertBaseRefAllowed,
14+
ScanBase,
15+
selectAutomaticScanBase,
16+
shouldCreateReleaseForScan,
17+
} from "./scan-base";
1218
import { scanCommits } from "./scan";
1319
import {
1420
Release,
@@ -51,6 +57,7 @@ Options:
5157
--release-version=<version> Release version identifier
5258
--stage=<stage> Deployment stage (required for update)
5359
--include-paths=<paths> Filter commits by file paths (comma-separated globs)
60+
--base-ref=<ref> One-time sync base override for initialization/migration (exclusive; scans <ref>..HEAD)
5461
--timeout=<seconds> Abort if the operation exceeds this duration (default: 60)
5562
--json Output result as JSON (logs emitted as JSON Lines on stderr)
5663
--quiet Suppress info-level output (warnings and errors still printed)
@@ -67,6 +74,7 @@ Examples:
6774
linear-release complete
6875
linear-release update --stage=production
6976
linear-release sync --include-paths="apps/web/**,packages/**"
77+
linear-release sync --base-ref=v1.2.0 --include-paths="apps/web/**"
7078
`);
7179
process.exit(0);
7280
}
@@ -86,7 +94,7 @@ try {
8694
error(`${message} (run linear-release --help for usage)`);
8795
process.exit(1);
8896
}
89-
const { command, releaseName, releaseVersion, stageName, includePaths, jsonOutput, timeoutSeconds, logLevel } =
97+
const { command, releaseName, releaseVersion, stageName, baseRef, includePaths, jsonOutput, timeoutSeconds, logLevel } =
9098
parsedArgs;
9199
const cliWarnings = getCLIWarnings(parsedArgs);
92100
setLogLevel(logLevel);
@@ -165,22 +173,33 @@ async function syncCommand(): Promise<{
165173
throw new Error("Could not get current commit");
166174
}
167175

168-
let latestSha = await getLatestSha();
176+
const recentReleases = await getRecentReleases();
177+
const scanBase = getScanBase(recentReleases, currentCommit.commit);
178+
let latestSha = scanBase.sha;
169179
let inspectingOnlyCurrentCommit = false;
170180

171-
try {
172-
ensureCommitAvailable(latestSha);
173-
} catch (error) {
174-
const message = error instanceof Error ? error.message : String(error);
175-
warn(
176-
`Could not make sha ${latestSha} available in local git history; falling back to current commit only. ${message}`,
177-
);
178-
inspectingOnlyCurrentCommit = true;
179-
latestSha = currentCommit.commit;
181+
if (scanBase.kind === "base-ref") {
182+
assertBaseRefIsAncestor(scanBase.ref, latestSha, currentCommit.commit, { verifyAncestorReachable });
183+
const includePathSummary = effectiveIncludePaths?.length
184+
? ` with include paths: ${effectiveIncludePaths.join(", ")}`
185+
: "";
186+
info(`Scanning ${latestSha.slice(0, 7)}..${currentCommit.commit.slice(0, 7)}${includePathSummary}`);
187+
} else {
188+
try {
189+
ensureCommitAvailable(latestSha);
190+
} catch (error) {
191+
const message = error instanceof Error ? error.message : String(error);
192+
warn(
193+
`Could not make sha ${latestSha} available in local git history; falling back to current commit only. ${message}`,
194+
);
195+
inspectingOnlyCurrentCommit = true;
196+
latestSha = currentCommit.commit;
197+
}
180198
}
181199

182200
const commits = getCommitContextsBetweenShas(latestSha, currentCommit.commit, {
183201
includePaths: effectiveIncludePaths,
202+
inspectSingleCommit: scanBase.kind !== "base-ref",
184203
});
185204

186205
if (inspectingOnlyCurrentCommit) {
@@ -195,7 +214,9 @@ async function syncCommand(): Promise<{
195214
}
196215
} else {
197216
const commitNoun = effectiveIncludePaths?.length ? "matching commit" : "commit";
198-
if (latestSha === currentCommit.commit) {
217+
if (scanBase.kind === "base-ref") {
218+
info(`Found ${commits.length} ${pluralize(commits.length, commitNoun)} in requested range`);
219+
} else if (latestSha === currentCommit.commit) {
199220
info(
200221
`Inspected current commit ${currentCommit.commit.slice(0, 7)}; found ${commits.length} ${pluralize(commits.length, commitNoun)}`,
201222
);
@@ -207,14 +228,16 @@ async function syncCommand(): Promise<{
207228
}
208229

209230
if (commits.length === 0) {
210-
if (effectiveIncludePaths?.length) {
211-
info(
212-
`No matching commits found for include paths: ${effectiveIncludePaths.join(", ")}. Skipping release creation.`,
213-
);
214-
} else {
215-
info("No commits found in the computed range. Skipping release creation.");
231+
const reason = effectiveIncludePaths?.length
232+
? `No matching commits found for include paths: ${effectiveIncludePaths.join(", ")}`
233+
: scanBase.kind === "base-ref"
234+
? "No commits found in the requested range"
235+
: "No commits found in the computed range";
236+
if (!shouldCreateReleaseForScan(commits.length, scanBase)) {
237+
info(`${reason}. Skipping release creation.`);
238+
return null;
216239
}
217-
return null;
240+
info(`${reason}. Syncing release anyway because --base-ref was provided to establish the baseline.`);
218241
}
219242

220243
// git log returns newest-first; scanCommits needs chronological (oldest-first) for last-write-wins
@@ -240,6 +263,9 @@ async function syncCommand(): Promise<{
240263
if (prNumbers.length > 0) parts.push(`pull requests [${prNumbers.map((n) => `#${n}`).join(", ")}]`);
241264
const attached = parts.length > 0 ? parts.join(", ") : "no new issues or pull requests";
242265
info(`Synced to release ${release.name} (${formatVersion(release)}): ${attached}`);
266+
if (scanBase.kind === "base-ref") {
267+
info(`Stored release baseline: ${(release.commitSha ?? currentCommit.commit).slice(0, 7)}`);
268+
}
243269

244270
return {
245271
release: {
@@ -344,19 +370,31 @@ async function getRecentReleases(): Promise<Release[]> {
344370
return response.data.recentReleasesByAccessKey;
345371
}
346372

347-
async function getLatestSha(): Promise<string> {
348-
const currentSha = getCurrentGitInfo().commit;
349-
if (!currentSha) {
350-
throw new Error("Could not get current commit");
373+
function getScanBase(candidates: Release[], currentSha: string): ScanBase {
374+
if (baseRef) {
375+
const previousBaseline = assertBaseRefAllowed(candidates, currentSha, { verifyAncestorReachable });
376+
let resolvedSha: string;
377+
try {
378+
resolvedSha = resolveCommitRef(baseRef);
379+
} catch (e) {
380+
const detail = e instanceof Error ? e.message : String(e);
381+
throw new Error(`Invalid --base-ref: ${detail}`);
382+
}
383+
info(`Using --base-ref ${baseRef} resolved to ${resolvedSha.slice(0, 7)}`);
384+
if (previousBaseline === "unreachable") {
385+
warn("No reachable previous release baseline found; using --base-ref as the scan base");
386+
} else {
387+
verbose("No recent releases found; using --base-ref as the scan base");
388+
}
389+
return { kind: "base-ref", sha: resolvedSha, ref: baseRef };
351390
}
352391

353-
const candidates = await getRecentReleases();
354-
const result = findBaseSha(candidates, currentSha, { verifyAncestorReachable });
355-
if (result.kind === "found") {
356-
return result.sha;
392+
const scanBase = selectAutomaticScanBase(candidates, currentSha, { verifyAncestorReachable });
393+
if (scanBase.kind !== "first-sync") {
394+
return scanBase;
357395
}
358396

359-
if (candidates.length === 0) {
397+
if (scanBase.candidatesConsidered === 0) {
360398
verbose("No recent releases found; assuming first sync");
361399
} else {
362400
// The candidate list came back non-empty but no entry is reachable from
@@ -368,20 +406,20 @@ async function getLatestSha(): Promise<string> {
368406
// resolveFirstSyncBoundary, which uses HEAD^1 when HEAD is a merge commit.
369407
// The follow-up verbose lines below print the boundary that was chosen.
370408
warn(
371-
`No recent release is an ancestor of ${currentSha} (${candidates.length} candidate${
372-
candidates.length === 1 ? "" : "s"
373-
} considered); falling back to the first-sync scan boundary`,
409+
`No recent release is an ancestor of ${currentSha} (${scanBase.candidatesConsidered} ${pluralize(
410+
scanBase.candidatesConsidered,
411+
"candidate",
412+
)} considered); falling back to the first-sync scan boundary`,
374413
);
375414
}
376415
// For a merge HEAD the issue keys live on HEAD^2's branch, not on HEAD
377416
// itself, so HEAD-only would miss them. Non-merge HEAD carries its own key.
378-
const boundary = resolveFirstSyncBoundary(currentSha);
379-
if (boundary !== currentSha) {
380-
verbose(`Merge HEAD: using HEAD^1 (${boundary}) as the scan boundary`);
417+
if (scanBase.sha !== currentSha) {
418+
verbose(`Merge HEAD: using HEAD^1 (${scanBase.sha}) as the scan boundary`);
381419
} else {
382420
verbose("Inspecting current commit only");
383421
}
384-
return boundary;
422+
return scanBase;
385423
}
386424

387425
async function getPipelineSettings(): Promise<{

0 commit comments

Comments
 (0)