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 @@ -99,6 +99,7 @@ Set commits for a release
- `--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.`
- `--from <value> - Read the local range <ref>..HEAD (e.g. previous release tag). Implies --local.`
- `--initial-depth <value> - Number of commits to read with --local - (default: "20")`

### `sentry release propose-version`
Expand Down
210 changes: 151 additions & 59 deletions src/commands/release/set-commits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,20 @@ const log = logger.withTag("release.set-commits");

const USAGE_HINT = "sentry release set-commits [<org>/]<version>";

/** Read commits from local git history and send to the Sentry API. */
/**
* Read commits from local git history and send to the Sentry API.
*
* When `from` is set, reads the whole `from..HEAD` range (bounded by the range
* itself, so `depth` is omitted); otherwise walks back `depth` commits from
* HEAD. Optional `paths` restrict the log to commits touching those subtrees.
*/
function setCommitsFromLocal(
org: string,
version: string,
cwd: string,
options: { depth: number; paths?: string[] }
options: { depth?: number; paths?: string[]; from?: string }
): Promise<SentryRelease> {
const { depth, paths } = options;
const { depth, paths, from } = options;
const shallow = isShallowRepository(cwd);
if (shallow) {
log.warn(
Expand All @@ -51,12 +57,24 @@ function setCommitsFromLocal(
);
}

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 commits = getCommitLog(cwd, { depth, paths, from });
if (commits.length === 0) {
const hasPaths = paths !== undefined && paths.length > 0;
const scope = from
? `in ${from}..HEAD`
: `within the last ${depth} commits`;
const pathClause = hasPaths ? ` touching ${paths.join(", ")}` : "";
// Only mention paths in the suggestion when the user actually passed some,
// so `--from` without `--path` doesn't tell them to "check the path(s)".
let suggestion: string;
if (from) {
suggestion = hasPaths ? "Check the ref and path(s)." : "Check the ref.";
} else {
suggestion = "Check the path(s) or increase --initial-depth.";
}
if (from || hasPaths) {
log.warn(`No commits found${pathClause} ${scope}. ${suggestion}`);
}
}
const repoName = getRepositoryName(cwd);
const commitsWithRepo = commits.map((c) => ({
Expand Down Expand Up @@ -171,6 +189,104 @@ async function setCommitsDefault(
}
}

/**
* Parse the `--commit` flag into Sentry ref objects.
*
* Accepts comma-separated `REPO@SHA` or `REPO@PREV..SHA` entries. The range
* form maps `PREV` to `previousCommit` and `SHA` to `commit`.
*
* @throws {ValidationError} When an entry is missing the `@` separator.
*/
function parseCommitRefs(
commitFlag: string
): Array<{ repository: string; commit: string; previousCommit?: string }> {
return commitFlag.split(",").map((pair) => {
const trimmed = pair.trim();
const atIdx = trimmed.lastIndexOf("@");
if (atIdx <= 0) {
throw new ValidationError(
`Invalid commit format '${trimmed}'. Expected REPO@SHA or REPO@PREV..SHA.`,
"commit"
);
}
const repository = trimmed.slice(0, atIdx);
const sha = trimmed.slice(atIdx + 1);

const rangeParts = sha.split("..");
if (rangeParts.length === 2 && rangeParts[0] && rangeParts[1]) {
return {
repository,
commit: rangeParts[1],
previousCommit: rangeParts[0],
};
}

return { repository, commit: sha };
});
}

/**
* Parse and validate the local-scope flags (`--path`, `--from`).
*
* Both restrict commit selection to local git history and are incompatible
* with `--auto`/`--commit`, whose commit ranges are expanded server-side (so a
* client-side path filter or range start would be silently ignored).
*
* @returns The parsed pathspecs and the normalized (trimmed) `from` ref.
* @throws {ValidationError} On an empty `--path`/`--from`, or a conflict with
* `--auto`/`--commit`.
*/
function parseLocalScope(flags: {
auto: boolean;
commit?: string;
path?: string;
from?: string;
}): { paths: string[]; from?: string } {
const serverExpanded = flags.auto || Boolean(flags.commit);

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 && serverExpanded) {
throw new ValidationError(
"--path cannot be combined with --auto or --commit (their commit ranges are expanded server-side). Use --path with local mode.",
"path"
);
}

const from = flags.from?.trim();
if (flags.from !== undefined && !from) {
Comment thread
sentry-warden[bot] marked this conversation as resolved.
throw new ValidationError("--from requires a non-empty git ref.", "from");
}
// Reject option-like refs. Otherwise `--from=--format=x` would become the
// argv element `--format=x..HEAD`, which git parses as a `--format` override
// (arg injection). Git ref names cannot start with "-" anyway.
if (from?.startsWith("-")) {
throw new ValidationError(
"--from must be a git ref, not an option (must not start with '-').",
"from"
);
}
if (from && serverExpanded) {
throw new ValidationError(
"--from cannot be combined with --auto or --commit (their commit ranges are expanded server-side). Use --from with local mode.",
"from"
);
}

return { paths, from };
}

function formatCommitsSet(release: SentryRelease): string {
const lines: string[] = [];
lines.push(`## Commits Set: ${escapeMarkdownInline(release.version)}`);
Expand All @@ -194,11 +310,19 @@ export const setCommitsCommand = buildCommand({
"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" +
"Use --from <ref> to read the local range <ref>..HEAD (e.g. the previous\n" +
"release tag through the current checkout). It implies --local, reads the\n" +
"whole range (--initial-depth does not apply), and combines with --path to\n" +
"scope a monorepo release to the files that changed since the last release.\n" +
"Like --path, it cannot be combined with --auto or --commit. Requires a\n" +
"full (non-shallow) checkout spanning the range.\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 --from v0.9.0\n" +
" sentry release set-commits 1.0.0 --from v0.9.0 --path apps/mobile,apps/shared\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 @@ -247,6 +371,13 @@ export const setCommitsCommand = buildCommand({
"Filter commits to these paths (comma-separated). Implies --local.",
optional: true,
},
from: {
kind: "parsed",
parse: String,
brief:
"Read the local range <ref>..HEAD (e.g. previous release tag). Implies --local.",
optional: true,
},
"initial-depth": {
kind: "parsed",
parse: numberParser,
Expand All @@ -263,6 +394,7 @@ export const setCommitsCommand = buildCommand({
readonly clear: boolean;
readonly commit?: string;
readonly path?: string;
readonly from?: string;
readonly "initial-depth": number;
readonly json: boolean;
readonly fields?: string[];
Expand Down Expand Up @@ -297,68 +429,28 @@ 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"
);
}
// --path and --from restrict commit selection to local git history. Neither
// works with --auto/--commit, whose ranges are expanded server-side.
const { paths, from } = parseLocalScope(flags);

// Explicit --commit mode: parse REPO@SHA or REPO@PREV..SHA pairs as refs
if (flags.commit) {
const refs = flags.commit.split(",").map((pair) => {
const trimmed = pair.trim();
const atIdx = trimmed.lastIndexOf("@");
if (atIdx <= 0) {
throw new ValidationError(
`Invalid commit format '${trimmed}'. Expected REPO@SHA or REPO@PREV..SHA.`,
"commit"
);
}
const repository = trimmed.slice(0, atIdx);
const sha = trimmed.slice(atIdx + 1);

// Support REPO@PREV..SHA range syntax
const rangeParts = sha.split("..");
if (rangeParts.length === 2 && rangeParts[0] && rangeParts[1]) {
return {
repository,
commit: rangeParts[1],
previousCommit: rangeParts[0],
};
}

return { repository, commit: sha };
});

const refs = parseCommitRefs(flags.commit);
const release = await setCommitsWithRefs(org, version, refs);
yield new CommandOutput(release);
return;
}

let release: SentryRelease;

if (flags.local || paths.length > 0) {
// Explicit --local, or --path (which implies local): use local git only
if (flags.local || paths.length > 0 || from) {
// Explicit --local, or --path/--from (which imply local): local git only.
// An explicit --from range is self-bounding, so pass depth 0 (no cap) to
// read every commit in <ref>..HEAD; otherwise walk back --initial-depth.
release = await setCommitsFromLocal(org, version, cwd, {
depth: flags["initial-depth"],
depth: from ? 0 : flags["initial-depth"],
paths,
from,
});
} else if (flags.auto) {
// Explicit --auto: use repo integration, fail hard on error
Expand Down
41 changes: 39 additions & 2 deletions src/lib/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ export type GitCommit = {
repository?: string;
};

/**
* Upper bound on captured git stdout (100 MB).
*
* `execFileSync` defaults to a 1 MB `maxBuffer` and throws
* `ERR_CHILD_PROCESS_STDIO_MAXBUFFER` once exceeded. Uncapped `git log`
* (e.g. `getCommitLog` with `--from` and no `--max-count`) can emit far more
* than 1 MB on a large `from..HEAD` range, so raise the limit to avoid
* crashing on big histories. ~200 bytes/commit → this allows ~500k commits.
*/
const GIT_MAX_BUFFER = 100 * 1024 * 1024;

/**
* Run a git command and return trimmed stdout.
*
Expand All @@ -41,6 +52,7 @@ function git(args: string[], cwd?: string): string {
cwd,
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
maxBuffer: GIT_MAX_BUFFER,
}).trim();
}

Expand Down Expand Up @@ -136,7 +148,10 @@ 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.depth - Maximum number of commits to return (default 20). Pass
* a non-positive value together with `from` to read the whole `from..HEAD`
* range without a cap. A non-positive depth without `from` still passes
* `--max-count` (e.g. `--initial-depth 0` yields zero commits, not all of HEAD).
* @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.
Expand All @@ -148,15 +163,37 @@ export function getCommitLog(
): GitCommit[] {
const { from, depth = 20, paths } = options;

// Reject option-like refs so a `from` such as "--format=x" can't be parsed
// as a git option (arg injection). Git refs cannot start with "-" anyway.
// This is version-independent, unlike `--end-of-options` (git >= 2.24).
// Mirrors the guard in getMergeBase.
if (from?.startsWith("-")) {
throw new ValidationError(
`Invalid git ref '${from}': must not start with '-'.`,
"from"
);
}

// 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";
// Drop max-count only for an explicit uncapped range read (`from` + depth <= 0).
// A non-positive depth without `from` (e.g. `--initial-depth 0`) must still
// pass --max-count so we don't walk all of HEAD.
let maxCount: string[];
if (depth > 0) {
maxCount = [`--max-count=${depth}`];
} else if (from) {
maxCount = [];
} else {
maxCount = [`--max-count=${depth}`];
}
// 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, ...pathspec],
["log", `--format=${format}`, ...maxCount, range, ...pathspec],
cwd
);

Expand Down
Loading
Loading