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
1 change: 1 addition & 0 deletions plugins/sentry-cli/skills/sentry-cli/references/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ Set commits for a release
- `--local - Read commits from local git history`
- `--clear - Clear all commits from the release`
- `--commit <value> - Explicit commit as REPO@SHA or REPO@PREV..SHA (comma-separated)`
- `--path <value> - Filter commits to these paths (comma-separated). Implies --local.`
- `--initial-depth <value> - Number of commits to read with --local - (default: "20")`

### `sentry release propose-version`
Expand Down
66 changes: 53 additions & 13 deletions src/commands/release/set-commits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ function setCommitsFromLocal(
org: string,
version: string,
cwd: string,
depth: number
options: { depth: number; paths?: string[] }
): Promise<SentryRelease> {
const { depth, paths } = options;
const shallow = isShallowRepository(cwd);
if (shallow) {
log.warn(
Expand All @@ -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,
Expand Down Expand Up @@ -128,7 +135,7 @@ async function setCommitsDefault(
): Promise<SentryRelease> {
// 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 {
Expand All @@ -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;
}
Expand All @@ -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",
},
Expand Down Expand Up @@ -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,
Expand All @@ -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[];
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 11 additions & 3 deletions src/lib/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);

Expand Down
141 changes: 141 additions & 0 deletions test/commands/release/set-commits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,3 +366,144 @@ describe("release set-commits (default mode)", () => {
expect(setCommitsLocalSpy).toHaveBeenCalled();
});
});

describe("release set-commits --path", () => {
let setCommitsAutoSpy: ReturnType<typeof spyOn>;
let setCommitsLocalSpy: ReturnType<typeof spyOn>;
let resolveOrgSpy: ReturnType<typeof spyOn>;

// 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");
});
});
69 changes: 69 additions & 0 deletions test/lib/git-commit-log.test.ts
Original file line number Diff line number Diff line change
@@ -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",
},
]);
});
});
Loading