Skip to content

Commit ec08798

Browse files
authored
Add --base-ref to manually override scan base (#91)
1 parent d30cd25 commit ec08798

7 files changed

Lines changed: 411 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` | Override the scan base. 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` to explicitly choose the exclusive lower bound for `sync`'s commit scan. This is useful when the automatically selected release baseline is not the range you want for a custom branching workflow, first-time onboarding, or migration.
227+
228+
```bash
229+
linear-release sync --base-ref=<last-released-ref> --include-paths="apps/api/**"
230+
```
231+
232+
The base ref is exclusive: linear-release scans `<base-ref>..HEAD`, matching Git range syntax, and still applies any configured path filters. 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 provided, it overrides automatic base selection for that run. After sync, current `HEAD` is stored as the future release baseline. Choosing an older or newer base can reattach or skip commits, so use this only when you intentionally want to own the scan range.
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, migrate rewritten history, or override the inferred range**: run `sync` with `--base-ref=<ref>` to set an explicit scan base.
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: 46 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,43 @@ 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+
* Shallow or single-branch clones often lack the target locally:
270+
* - SHA-like inputs: deepen history until the commit is reachable.
271+
* - Tag or branch refs: `git fetch origin <ref>` populates FETCH_HEAD with
272+
* the resolved commit for both kinds, without needing to know which.
273+
*/
274+
export function resolveCommitRef(ref: string, cwd: string = process.cwd()): string {
275+
const resolve = (target: string = ref) =>
276+
execFileSync("git", ["rev-parse", "--verify", `${target}^{commit}`], {
277+
cwd,
278+
stdio: ["ignore", "pipe", "ignore"],
279+
encoding: "utf8",
280+
}).trim();
281+
282+
try {
283+
return resolve();
284+
} catch {
285+
if (SHA_PATTERN.test(ref)) {
286+
ensureCommitAvailable(ref, cwd);
287+
return resolve();
288+
}
289+
try {
290+
verbose(`Ref "${ref}" not in local history; fetching from origin`);
291+
execFileSync("git", ["fetch", "origin", ref], {
292+
cwd,
293+
stdio: ["ignore", "ignore", "ignore"],
294+
timeout: 30_000,
295+
});
296+
return resolve("FETCH_HEAD");
297+
} catch {
298+
throw new Error(`Could not resolve "${ref}" to a commit. Use a valid commit SHA, tag, or ref.`);
299+
}
300+
}
301+
}
302+
266303
/**
267304
* Extracts the branch name from a merge commit message.
268305
* Supports:
@@ -369,19 +406,21 @@ function runLog(rangeArgs: string, cwd: string): CommitContext[] {
369406
* `--no-walk` (only when `fromSha === toSha`): without it, `git log -1 <sha>
370407
* -- <paths>` walks back from `<sha>` to the first ancestor matching the
371408
* pathspec — silently returning an unrelated commit when `<sha>` itself
372-
* doesn't match.
409+
* doesn't match. Callers that need true `sha..sha` empty-range semantics can
410+
* pass `inspectSingleCommit: false`.
373411
*
374412
* @param fromSha - Starting commit SHA (exclusive)
375413
* @param toSha - Ending commit SHA (inclusive)
376414
* @param options.includePaths - Glob patterns to filter commits by file paths (relative to repo root)
415+
* @param options.inspectSingleCommit - When SHAs match, inspect that one commit instead of treating it as an empty range
377416
* @param options.cwd - Working directory for git commands (defaults to process.cwd())
378417
*/
379418
export function getCommitContextsBetweenShas(
380419
fromSha: string,
381420
toSha: string,
382-
options: { includePaths?: string[] | null; cwd?: string } = {},
421+
options: { includePaths?: string[] | null; inspectSingleCommit?: boolean; cwd?: string } = {},
383422
): CommitContext[] {
384-
const { includePaths = null, cwd = process.cwd() } = options;
423+
const { includePaths = null, inspectSingleCommit = true, cwd = process.cwd() } = options;
385424

386425
if (!SHA_PATTERN.test(fromSha)) {
387426
warn(`Invalid "from" SHA format "${fromSha}"`);
@@ -392,17 +431,18 @@ export function getCommitContextsBetweenShas(
392431
return [];
393432
}
394433

434+
const inspectingSingleCommit = fromSha === toSha && inspectSingleCommit;
395435
const args = [
396436
includePaths?.length ? "--full-history" : "",
397-
fromSha === toSha ? `--no-walk ${toSha}` : `${fromSha}..${toSha}`,
437+
inspectingSingleCommit ? `--no-walk ${toSha}` : `${fromSha}..${toSha}`,
398438
buildPathspecArgs(includePaths),
399439
]
400440
.filter(Boolean)
401441
.join(" ");
402442
const commits = runLog(args, cwd);
403443

404444
if (commits.length === 0) {
405-
if (fromSha === toSha) {
445+
if (inspectingSingleCommit) {
406446
const pathFilter = includePaths?.length ? ` include paths: ${includePaths.join(", ")}` : "";
407447
verbose(`Commit ${toSha.slice(0, 7)} did not match${pathFilter}`);
408448
} else {

src/index.ts

Lines changed: 63 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import {
55
getCommitContextsBetweenShas,
66
getCurrentGitInfo,
77
getRepoInfo,
8-
resolveFirstSyncBoundary,
8+
resolveCommitRef,
99
verifyAncestorReachable,
1010
} from "./git";
11-
import { findBaseSha } from "./base-sha";
11+
import { assertBaseRefIsAncestor, ScanBase, selectAutomaticScanBase, shouldCreateReleaseForScan } from "./scan-base";
1212
import { scanCommits } from "./scan";
1313
import {
1414
Release,
@@ -51,6 +51,7 @@ Options:
5151
--release-version=<version> Release version identifier
5252
--stage=<stage> Deployment stage (required for update)
5353
--include-paths=<paths> Filter commits by file paths (comma-separated globs)
54+
--base-ref=<ref> Override sync scan base (exclusive; scans <ref>..HEAD)
5455
--timeout=<seconds> Abort if the operation exceeds this duration (default: 60)
5556
--json Output result as JSON (logs emitted as JSON Lines on stderr)
5657
--quiet Suppress info-level output (warnings and errors still printed)
@@ -67,6 +68,7 @@ Examples:
6768
linear-release complete
6869
linear-release update --stage=production
6970
linear-release sync --include-paths="apps/web/**,packages/**"
71+
linear-release sync --base-ref=<last-released-ref> --include-paths="apps/web/**"
7072
`);
7173
process.exit(0);
7274
}
@@ -86,7 +88,7 @@ try {
8688
error(`${message} (run linear-release --help for usage)`);
8789
process.exit(1);
8890
}
89-
const { command, releaseName, releaseVersion, stageName, includePaths, jsonOutput, timeoutSeconds, logLevel } =
91+
const { command, releaseName, releaseVersion, stageName, baseRef, includePaths, jsonOutput, timeoutSeconds, logLevel } =
9092
parsedArgs;
9193
const cliWarnings = getCLIWarnings(parsedArgs);
9294
setLogLevel(logLevel);
@@ -165,22 +167,33 @@ async function syncCommand(): Promise<{
165167
throw new Error("Could not get current commit");
166168
}
167169

168-
let latestSha = await getLatestSha();
170+
const recentReleases = await getRecentReleases();
171+
const scanBase = getScanBase(recentReleases, currentCommit.commit);
172+
let latestSha = scanBase.sha;
169173
let inspectingOnlyCurrentCommit = false;
170174

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;
175+
if (scanBase.kind === "base-ref") {
176+
assertBaseRefIsAncestor(scanBase.ref, latestSha, currentCommit.commit, { verifyAncestorReachable });
177+
const includePathSummary = effectiveIncludePaths?.length
178+
? ` with include paths: ${effectiveIncludePaths.join(", ")}`
179+
: "";
180+
info(`Scanning ${latestSha.slice(0, 7)}..${currentCommit.commit.slice(0, 7)}${includePathSummary}`);
181+
} else {
182+
try {
183+
ensureCommitAvailable(latestSha);
184+
} catch (error) {
185+
const message = error instanceof Error ? error.message : String(error);
186+
warn(
187+
`Could not make sha ${latestSha} available in local git history; falling back to current commit only. ${message}`,
188+
);
189+
inspectingOnlyCurrentCommit = true;
190+
latestSha = currentCommit.commit;
191+
}
180192
}
181193

182194
const commits = getCommitContextsBetweenShas(latestSha, currentCommit.commit, {
183195
includePaths: effectiveIncludePaths,
196+
inspectSingleCommit: scanBase.kind !== "base-ref",
184197
});
185198

186199
if (inspectingOnlyCurrentCommit) {
@@ -195,7 +208,9 @@ async function syncCommand(): Promise<{
195208
}
196209
} else {
197210
const commitNoun = effectiveIncludePaths?.length ? "matching commit" : "commit";
198-
if (latestSha === currentCommit.commit) {
211+
if (scanBase.kind === "base-ref") {
212+
info(`Found ${commits.length} ${pluralize(commits.length, commitNoun)} in requested range`);
213+
} else if (latestSha === currentCommit.commit) {
199214
info(
200215
`Inspected current commit ${currentCommit.commit.slice(0, 7)}; found ${commits.length} ${pluralize(commits.length, commitNoun)}`,
201216
);
@@ -207,14 +222,16 @@ async function syncCommand(): Promise<{
207222
}
208223

209224
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.");
225+
const reason = effectiveIncludePaths?.length
226+
? `No matching commits found for include paths: ${effectiveIncludePaths.join(", ")}`
227+
: scanBase.kind === "base-ref"
228+
? "No commits found in the requested range"
229+
: "No commits found in the computed range";
230+
if (!shouldCreateReleaseForScan(commits.length, scanBase)) {
231+
info(`${reason}. Skipping release creation.`);
232+
return null;
216233
}
217-
return null;
234+
info(`${reason}. Syncing release anyway because --base-ref was provided to establish the baseline.`);
218235
}
219236

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

244264
return {
245265
release: {
@@ -344,19 +364,25 @@ async function getRecentReleases(): Promise<Release[]> {
344364
return response.data.recentReleasesByAccessKey;
345365
}
346366

347-
async function getLatestSha(): Promise<string> {
348-
const currentSha = getCurrentGitInfo().commit;
349-
if (!currentSha) {
350-
throw new Error("Could not get current commit");
367+
function getScanBase(candidates: Release[], currentSha: string): ScanBase {
368+
if (baseRef) {
369+
let resolvedSha: string;
370+
try {
371+
resolvedSha = resolveCommitRef(baseRef);
372+
} catch (e) {
373+
const detail = e instanceof Error ? e.message : String(e);
374+
throw new Error(`Invalid --base-ref: ${detail}`);
375+
}
376+
info(`Using --base-ref ${baseRef} (${resolvedSha.slice(0, 7)}); skipping automatic baseline selection`);
377+
return { kind: "base-ref", sha: resolvedSha, ref: baseRef };
351378
}
352379

353-
const candidates = await getRecentReleases();
354-
const result = findBaseSha(candidates, currentSha, { verifyAncestorReachable });
355-
if (result.kind === "found") {
356-
return result.sha;
380+
const scanBase = selectAutomaticScanBase(candidates, currentSha, { verifyAncestorReachable });
381+
if (scanBase.kind !== "first-sync") {
382+
return scanBase;
357383
}
358384

359-
if (candidates.length === 0) {
385+
if (scanBase.candidatesConsidered === 0) {
360386
verbose("No recent releases found; assuming first sync");
361387
} else {
362388
// The candidate list came back non-empty but no entry is reachable from
@@ -368,20 +394,20 @@ async function getLatestSha(): Promise<string> {
368394
// resolveFirstSyncBoundary, which uses HEAD^1 when HEAD is a merge commit.
369395
// The follow-up verbose lines below print the boundary that was chosen.
370396
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`,
397+
`No recent release is an ancestor of ${currentSha} (${scanBase.candidatesConsidered} ${pluralize(
398+
scanBase.candidatesConsidered,
399+
"candidate",
400+
)} considered); falling back to the first-sync scan boundary`,
374401
);
375402
}
376403
// For a merge HEAD the issue keys live on HEAD^2's branch, not on HEAD
377404
// 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`);
405+
if (scanBase.sha !== currentSha) {
406+
verbose(`Merge HEAD: using HEAD^1 (${scanBase.sha}) as the scan boundary`);
381407
} else {
382408
verbose("Inspecting current commit only");
383409
}
384-
return boundary;
410+
return scanBase;
385411
}
386412

387413
async function getPipelineSettings(): Promise<{

0 commit comments

Comments
 (0)