diff --git a/plugins/sentry-cli/skills/sentry-cli/references/release.md b/plugins/sentry-cli/skills/sentry-cli/references/release.md index 704f9ed56..31ae7e940 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/release.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/release.md @@ -98,6 +98,7 @@ Set commits for a release - `--local - Read commits from local git history` - `--clear - Clear all commits from the release` - `--commit - Explicit commit as REPO@SHA or REPO@PREV..SHA (comma-separated)` +- `--path - Filter commits to these paths (comma-separated). Implies --local.` - `--initial-depth - Number of commits to read with --local - (default: "20")` ### `sentry release propose-version` diff --git a/src/commands/release/set-commits.ts b/src/commands/release/set-commits.ts index 692f85d26..c65d358b8 100644 --- a/src/commands/release/set-commits.ts +++ b/src/commands/release/set-commits.ts @@ -41,8 +41,9 @@ function setCommitsFromLocal( org: string, version: string, cwd: string, - depth: number + options: { depth: number; paths?: string[] } ): Promise { + const { depth, paths } = options; const shallow = isShallowRepository(cwd); if (shallow) { log.warn( @@ -51,7 +52,13 @@ function setCommitsFromLocal( ); } - const commits = getCommitLog(cwd, { depth }); + const commits = getCommitLog(cwd, { depth, paths }); + if (commits.length === 0 && paths && paths.length > 0) { + log.warn( + `No commits found touching ${paths.join(", ")} within the last ${depth} commits. ` + + "Check the path(s) or increase --initial-depth." + ); + } const repoName = getRepositoryName(cwd); const commitsWithRepo = commits.map((c) => ({ ...c, @@ -128,7 +135,7 @@ async function setCommitsDefault( ): Promise { // Fast path: cached "no repos" — skip the API call entirely if (hasNoRepoIntegration(org)) { - return setCommitsFromLocal(org, version, cwd, depth); + return setCommitsFromLocal(org, version, cwd, { depth }); } try { @@ -152,14 +159,14 @@ async function setCommitsDefault( "Could not auto-discover commits (no repository integration). " + "Falling back to local git history." ); - return setCommitsFromLocal(org, version, cwd, depth); + return setCommitsFromLocal(org, version, cwd, { depth }); } if (error instanceof ValidationError && error.field === "repository") { log.warn( `Auto-discovery failed: ${error.message}. ` + "Falling back to local git history." ); - return setCommitsFromLocal(org, version, cwd, depth); + return setCommitsFromLocal(org, version, cwd, { depth }); } throw error; } @@ -185,10 +192,14 @@ export const setCommitsCommand = buildCommand({ "(requires a local git checkout — matches the origin remote against Sentry repos),\n" + "or --local to read commits from the local git history.\n" + "With no flag, tries --auto first and falls back to --local on failure.\n\n" + + "For monorepos, --path restricts commits to one or more subtrees\n" + + "(comma-separated). It implies --local and cannot be combined with\n" + + "--auto or --commit, whose ranges are expanded server-side.\n\n" + "Examples:\n" + " sentry release set-commits 1.0.0 --auto\n" + " sentry release set-commits my-org/1.0.0 --local\n" + " sentry release set-commits 1.0.0 --local --initial-depth 50\n" + + " sentry release set-commits 1.0.0 --path apps/mobile,packages/shared-ui\n" + " sentry release set-commits 1.0.0 --commit owner/repo@abc123..def456\n" + " sentry release set-commits 1.0.0 --clear", }, @@ -230,6 +241,13 @@ export const setCommitsCommand = buildCommand({ "Explicit commit as REPO@SHA or REPO@PREV..SHA (comma-separated)", optional: true, }, + path: { + kind: "parsed", + parse: String, + brief: + "Filter commits to these paths (comma-separated). Implies --local.", + optional: true, + }, "initial-depth": { kind: "parsed", parse: numberParser, @@ -245,6 +263,7 @@ export const setCommitsCommand = buildCommand({ readonly local: boolean; readonly clear: boolean; readonly commit?: string; + readonly path?: string; readonly "initial-depth": number; readonly json: boolean; readonly fields?: string[]; @@ -277,6 +296,29 @@ export const setCommitsCommand = buildCommand({ ); } + // --path filters local git history by pathspec. It only works in local + // mode: --auto and --commit hand a SHA range to Sentry, which expands it + // into commits server-side, so the CLI can't filter those by path. + const paths = + flags.path === undefined + ? [] + : flags.path + .split(",") + .map((p) => p.trim()) + .filter(Boolean); + if (flags.path !== undefined && paths.length === 0) { + throw new ValidationError( + "--path requires at least one non-empty path.", + "path" + ); + } + if (paths.length > 0 && (flags.auto || flags.commit)) { + throw new ValidationError( + "--path cannot be combined with --auto or --commit (their commit ranges are expanded server-side). Use --path with local mode.", + "path" + ); + } + // Explicit --commit mode: parse REPO@SHA or REPO@PREV..SHA pairs as refs if (flags.commit) { const refs = flags.commit.split(",").map((pair) => { @@ -311,14 +353,12 @@ export const setCommitsCommand = buildCommand({ let release: SentryRelease; - if (flags.local) { - // Explicit --local: use local git only - release = await setCommitsFromLocal( - org, - version, - cwd, - flags["initial-depth"] - ); + if (flags.local || paths.length > 0) { + // Explicit --local, or --path (which implies local): use local git only + release = await setCommitsFromLocal(org, version, cwd, { + depth: flags["initial-depth"], + paths, + }); } else if (flags.auto) { // Explicit --auto: use repo integration, fail hard on error release = await setCommitsAuto(org, version, cwd); diff --git a/src/lib/git.ts b/src/lib/git.ts index 499700626..7894120f4 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -135,20 +135,28 @@ const NUL = "\x00"; * * @param cwd - Working directory * @param options - Log options + * @param options.from - Only include commits after this ref (uses `from..HEAD`) + * @param options.depth - Maximum number of commits to return (default 20) + * @param options.paths - Restrict the log to commits touching these pathspecs + * (appended after `--`). Use for monorepos to scope a release to one subtree. + * With `--max-count`, the depth bounds commits *matching* the paths. * @returns Array of commit data matching the Sentry releases API format */ export function getCommitLog( cwd?: string, - options: { from?: string; depth?: number } = {} + options: { from?: string; depth?: number; paths?: string[] } = {} ): GitCommit[] { - const { from, depth = 20 } = options; + const { from, depth = 20, paths } = options; // Format: hash, subject, author name, author email, author date (ISO) // %x00 is git's hex escape for NUL — avoids literal NUL in the command string const format = "%H%x00%s%x00%aN%x00%aE%x00%aI"; const range = from ? `${from}..HEAD` : "HEAD"; + // Pathspecs go after `--`; each path is a discrete argv entry (no shell), so + // there is no escaping/injection concern. + const pathspec = paths && paths.length > 0 ? ["--", ...paths] : []; const raw = git( - ["log", `--format=${format}`, `--max-count=${depth}`, range], + ["log", `--format=${format}`, `--max-count=${depth}`, range, ...pathspec], cwd ); diff --git a/test/commands/release/set-commits.test.ts b/test/commands/release/set-commits.test.ts index 443323ba2..11fc9a1b8 100644 --- a/test/commands/release/set-commits.test.ts +++ b/test/commands/release/set-commits.test.ts @@ -366,3 +366,144 @@ describe("release set-commits (default mode)", () => { expect(setCommitsLocalSpy).toHaveBeenCalled(); }); }); + +describe("release set-commits --path", () => { + let setCommitsAutoSpy: ReturnType; + let setCommitsLocalSpy: ReturnType; + let resolveOrgSpy: ReturnType; + + // Use the actual repo root as cwd so getCommitLog can read git history + const repoRoot = new URL("../../..", import.meta.url).pathname.replace( + /\/$/, + "" + ); + + beforeEach(() => { + setCommitsAutoSpy = vi.spyOn(apiClient, "setCommitsAuto"); + setCommitsLocalSpy = vi.spyOn(apiClient, "setCommitsLocal"); + resolveOrgSpy = vi.spyOn(resolveTarget, "resolveOrg"); + }); + + afterEach(() => { + setCommitsAutoSpy.mockRestore(); + setCommitsLocalSpy.mockRestore(); + resolveOrgSpy.mockRestore(); + }); + + test("implies local mode (no --auto needed)", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + setCommitsLocalSpy.mockResolvedValue(sampleRelease); + + const { context } = createMockContext(repoRoot); + const func = await setCommitsCommand.loader(); + await func.call( + context, + { + auto: false, + local: false, + clear: false, + commit: undefined, + path: "src", + "initial-depth": 20, + json: true, + }, + "1.0.0" + ); + + expect(setCommitsLocalSpy).toHaveBeenCalled(); + expect(setCommitsAutoSpy).not.toHaveBeenCalled(); + }); + + test("accepts comma-separated paths in local mode", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + setCommitsLocalSpy.mockResolvedValue(sampleRelease); + + const { context } = createMockContext(repoRoot); + const func = await setCommitsCommand.loader(); + await func.call( + context, + { + auto: false, + local: false, + clear: false, + commit: undefined, + path: "src,test", + "initial-depth": 20, + json: true, + }, + "1.0.0" + ); + + expect(setCommitsLocalSpy).toHaveBeenCalled(); + expect(setCommitsAutoSpy).not.toHaveBeenCalled(); + }); + + test("throws when --path has only empty/whitespace tokens", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + + const { context } = createMockContext(repoRoot); + const func = await setCommitsCommand.loader(); + + await expect( + func.call( + context, + { + auto: false, + local: false, + clear: false, + commit: undefined, + path: " , ", + "initial-depth": 20, + json: false, + }, + "1.0.0" + ) + ).rejects.toThrow("--path requires at least one non-empty path"); + }); + + test("throws when --path used with --auto", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + + const { context } = createMockContext(repoRoot); + const func = await setCommitsCommand.loader(); + + await expect( + func.call( + context, + { + auto: true, + local: false, + clear: false, + commit: undefined, + path: "apps/mobile", + "initial-depth": 20, + json: false, + }, + "1.0.0" + ) + ).rejects.toThrow("--path cannot be combined with --auto or --commit"); + }); + + test("throws when --path used with --commit", async () => { + resolveOrgSpy.mockResolvedValue({ org: "my-org" }); + + const { context } = createMockContext(repoRoot); + const func = await setCommitsCommand.loader(); + + await expect( + func.call( + context, + { + auto: false, + local: false, + clear: false, + commit: "repo@a..b", + path: "apps/mobile", + "initial-depth": 20, + json: false, + }, + "1.0.0" + ) + ).rejects.toThrow("--path cannot be combined with --auto or --commit"); + }); +}); diff --git a/test/lib/git-commit-log.test.ts b/test/lib/git-commit-log.test.ts new file mode 100644 index 000000000..a4a97d2f3 --- /dev/null +++ b/test/lib/git-commit-log.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +// Mock node:child_process so getCommitLog never shells out to a real git. +const execFileSyncMock = vi.fn(() => ""); +vi.mock("node:child_process", () => ({ + execFileSync: (...args: unknown[]) => execFileSyncMock(...args), +})); + +import { getCommitLog } from "../../src/lib/git.js"; + +/** Extract the argv passed to the mocked git invocation. */ +function lastGitArgs(): string[] { + const call = execFileSyncMock.mock.calls.at(-1); + // execFileSync(file, args, options) + return (call?.[1] ?? []) as string[]; +} + +describe("getCommitLog pathspec argv", () => { + afterEach(() => { + execFileSyncMock.mockClear(); + execFileSyncMock.mockReturnValue(""); + }); + + test("appends `--` and paths when paths provided", () => { + getCommitLog("/repo", { paths: ["apps/mobile", "packages/shared"] }); + + const args = lastGitArgs(); + expect(args).toContain("--"); + const sep = args.indexOf("--"); + expect(args.slice(sep + 1)).toEqual(["apps/mobile", "packages/shared"]); + }); + + test("omits `--` when no paths provided", () => { + getCommitLog("/repo", {}); + expect(lastGitArgs()).not.toContain("--"); + }); + + test("omits `--` for empty paths array", () => { + getCommitLog("/repo", { paths: [] }); + expect(lastGitArgs()).not.toContain("--"); + }); + + test("pathspec follows the commit range", () => { + getCommitLog("/repo", { from: "abc123", paths: ["src"] }); + + const args = lastGitArgs(); + const rangeIdx = args.indexOf("abc123..HEAD"); + const sepIdx = args.indexOf("--"); + expect(rangeIdx).toBeGreaterThanOrEqual(0); + expect(sepIdx).toBeGreaterThan(rangeIdx); + }); + + test("parses NUL-delimited git output into commits", () => { + execFileSyncMock.mockReturnValue( + "abc\x00subject\x00Jane\x00jane@example.com\x002026-01-01T00:00:00Z" + ); + + const commits = getCommitLog("/repo", { paths: ["src"] }); + expect(commits).toEqual([ + { + id: "abc", + message: "subject", + author_name: "Jane", + author_email: "jane@example.com", + timestamp: "2026-01-01T00:00:00Z", + }, + ]); + }); +});