gh-stack 0.17.2 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,325 @@
1
+ // After `merge` collapses a stack top-down on GitHub (PR3 squashed into PR2's
2
+ // branch, PR2 into PR1's), every child's content lives in one squash commit on
3
+ // its parent's branch. If that branch is rewritten before the base PR merges
4
+ // (#28: a sync rebased the base and force-pushed it without the squash commit),
5
+ // the base PR ships without its children, and nothing errors.
6
+ //
7
+ // So before the base PR is handed to main, check that each child's squash
8
+ // commit is still in what it was squashed into:
9
+ // - a child of the base: the base branch's head on origin;
10
+ // - a child of an intermediate branch: that branch's PR head when it merged
11
+ // (what was squashed into *its* parent).
12
+ //
13
+ // Fail closed. "covered" needs proof: the commit is an ancestor, a commit
14
+ // there has its patch-id, or the tree holds every file it wrote at the blob it
15
+ // wrote. "missing" needs proof too: the tree holds every file it touched
16
+ // exactly as it was *before* the commit. Anything in between is "unconfirmed",
17
+ // and a lookup that fails is "unverified". The caller must not merge on either
18
+ // without asking (and never under --yes).
19
+ import { $ } from "bun";
20
+ import * as git from "./git.ts";
21
+ import type { PrMergeResult } from "./github.ts";
22
+
23
+ export interface CollapsedChild {
24
+ branch: string;
25
+ pr: number;
26
+ /** The stack parent it was squashed into. */
27
+ parent: string;
28
+ /** The parent's PR, when the parent isn't the base. */
29
+ parentPr: number | null;
30
+ }
31
+
32
+ /**
33
+ * A file a commit touched: its path, GitHub's status, its blob after the
34
+ * commit (null if removed), and, when known, its blob before (null if added).
35
+ */
36
+ export interface CommitFile {
37
+ filename: string;
38
+ status: string;
39
+ sha: string | null;
40
+ before?: string | null;
41
+ }
42
+
43
+ /** GitHub lookups, injected so the check runs against a local remote in tests. */
44
+ export interface CollapseLookup {
45
+ prResult(
46
+ pr: number,
47
+ ): Promise<Pick<PrMergeResult, "state" | "baseRefName" | "headRefOid" | "mergeCommit"> | null>;
48
+ commitPatch(sha: string): Promise<string | null>;
49
+ /** The files a commit changed; null if unavailable or truncated. */
50
+ commitFiles(sha: string): Promise<CommitFile[] | null>;
51
+ }
52
+
53
+ interface Placement {
54
+ mergeCommit: string;
55
+ /** The commit that should contain it, and what that is ("origin/pr1", "PR #2's merged head"). */
56
+ container: string;
57
+ containerLabel: string;
58
+ }
59
+
60
+ export type CollapseVerdict =
61
+ | { child: CollapsedChild; status: "covered" }
62
+ /** Proven absent: every file it touched is still as it was before it. */
63
+ | ({ child: CollapsedChild; status: "missing"; fetchable: boolean } & Placement)
64
+ /** Compared, but neither proof held: maybe there, maybe not. */
65
+ | ({
66
+ child: CollapsedChild;
67
+ status: "unconfirmed";
68
+ reason: string;
69
+ transient: boolean;
70
+ } & Placement)
71
+ /** Couldn't get far enough to compare. */
72
+ | { child: CollapsedChild; status: "unverified"; reason: string; transient: boolean };
73
+
74
+ export async function verifyCollapse(opts: {
75
+ base: string;
76
+ trunkRef: string;
77
+ children: CollapsedChild[];
78
+ lookup: CollapseLookup;
79
+ }): Promise<CollapseVerdict[]> {
80
+ const { base, trunkRef, children, lookup } = opts;
81
+ const shallow =
82
+ (await $`git rev-parse --is-shallow-repository`.nothrow().quiet()).stdout.toString().trim() ===
83
+ "true";
84
+
85
+ // GitHub lookups: each PR once (a parent is also a child), all at once.
86
+ const prs = new Map<number, ReturnType<CollapseLookup["prResult"]>>();
87
+ const pr = (n: number) => {
88
+ if (!prs.has(n))
89
+ prs.set(
90
+ n,
91
+ lookup.prResult(n).catch(() => null),
92
+ );
93
+ return prs.get(n)!;
94
+ };
95
+ await Promise.all(
96
+ children.flatMap((c) => [pr(c.pr), ...(c.parentPr != null ? [pr(c.parentPr)] : [])]),
97
+ );
98
+
99
+ const heads = await git.remoteHeads();
100
+ const baseTip = heads.get(base) ?? null;
101
+ if (baseTip) await git.fetchObjectsOnly([`refs/heads/${base}`]);
102
+
103
+ // The git side runs one child at a time: concurrent fetches into one
104
+ // repository contend for the same pack and ref locks.
105
+ const verdicts: CollapseVerdict[] = [];
106
+ for (const child of children) {
107
+ const result = await pr(child.pr);
108
+ if (!result) {
109
+ verdicts.push({
110
+ child,
111
+ status: "unverified",
112
+ reason: `couldn't read PR #${child.pr} from GitHub`,
113
+ transient: true,
114
+ });
115
+ continue;
116
+ }
117
+ if (result.state !== "MERGED" || !result.mergeCommit) {
118
+ verdicts.push({
119
+ child,
120
+ status: "unverified",
121
+ reason: "GitHub reports no merge commit for it",
122
+ transient: false,
123
+ });
124
+ continue;
125
+ }
126
+ if (result.baseRefName !== child.parent) {
127
+ verdicts.push({
128
+ child,
129
+ status: "unverified",
130
+ reason: `it merged into ${result.baseRefName}, not ${child.parent}`,
131
+ transient: false,
132
+ });
133
+ continue;
134
+ }
135
+
136
+ // What should contain the squash commit.
137
+ let container: string | null;
138
+ let containerLabel: string;
139
+ if (child.parent === base) {
140
+ container = baseTip;
141
+ containerLabel = `origin/${base}`;
142
+ } else {
143
+ const parentResult = child.parentPr != null ? await pr(child.parentPr) : null;
144
+ const merged = parentResult?.state === "MERGED";
145
+ container = merged ? parentResult.headRefOid : (heads.get(child.parent) ?? null);
146
+ containerLabel = merged ? `PR #${child.parentPr}'s merged head` : `origin/${child.parent}`;
147
+ if (container && !(await hasCommit(container))) await git.fetchObjectsOnly([container]);
148
+ }
149
+ if (!container || !(await hasCommit(container))) {
150
+ verdicts.push({
151
+ child,
152
+ status: "unverified",
153
+ reason: `couldn't fetch ${containerLabel}`,
154
+ transient: true,
155
+ });
156
+ continue;
157
+ }
158
+
159
+ const squash = result.mergeCommit;
160
+ const placement = { mergeCommit: squash, container, containerLabel };
161
+ if (!(await hasCommit(squash))) await git.fetchObjectsOnly([squash]);
162
+ const fetchable = await hasCommit(squash);
163
+
164
+ // The commit and its patch from our own objects when we have them,
165
+ // otherwise from GitHub (an orphaned squash commit usually can't be fetched).
166
+ let patchId: string | null;
167
+ let files: CommitFile[] | null;
168
+ let historyChecked = false;
169
+ if (fetchable) {
170
+ if (await git.isAncestor(squash, container)) {
171
+ verdicts.push({ child, status: "covered" });
172
+ continue;
173
+ }
174
+ patchId = await patchIdOf(squash);
175
+ files = await localCommitFiles(squash);
176
+ historyChecked = !shallow;
177
+ } else {
178
+ const patch = await lookup.commitPatch(squash).catch(() => null);
179
+ patchId = patch === null ? null : await patchIdFromText(patch);
180
+ files = await lookup.commitFiles(squash).catch(() => null);
181
+ // Fetching the container brought all its ancestors, so in a full clone
182
+ // a squash commit we still don't have isn't one of them.
183
+ historyChecked = !shallow;
184
+ }
185
+
186
+ if ((await patchIdIn(patchId, container, trunkRef)) || (await filesIn(files, container))) {
187
+ verdicts.push({ child, status: "covered" });
188
+ continue;
189
+ }
190
+ if (await filesAsBefore(files, container)) {
191
+ verdicts.push({ child, status: "missing", fetchable, ...placement });
192
+ continue;
193
+ }
194
+
195
+ const gaps: string[] = [];
196
+ if (!historyChecked) {
197
+ gaps.push(
198
+ "the repository is shallow, so its history couldn't be searched (git fetch --unshallow)",
199
+ );
200
+ }
201
+ if (patchId === null) gaps.push("its patch couldn't be read");
202
+ if (files === null) gaps.push("its file list couldn't be read");
203
+ if (files !== null) {
204
+ gaps.push(
205
+ "its files there match neither its own version nor the one before it (they may have changed since)",
206
+ );
207
+ }
208
+ verdicts.push({
209
+ child,
210
+ status: "unconfirmed",
211
+ reason: gaps.join("; "),
212
+ // A GitHub read that failed may succeed on a retry.
213
+ transient: !fetchable && (patchId === null || files === null),
214
+ ...placement,
215
+ });
216
+ }
217
+ return verdicts;
218
+ }
219
+
220
+ async function hasCommit(sha: string): Promise<boolean> {
221
+ return git.refExists(`${sha}^{commit}`);
222
+ }
223
+
224
+ async function patchIdOf(sha: string): Promise<string | null> {
225
+ const { stdout } = await $`git show ${sha} | git patch-id --stable`.nothrow().quiet();
226
+ return stdout.toString().trim().split(" ")[0] || null;
227
+ }
228
+
229
+ async function patchIdFromText(patch: string): Promise<string | null> {
230
+ const { stdout } = await $`git patch-id --stable < ${new Response(patch)}`.nothrow().quiet();
231
+ return stdout.toString().trim().split(" ")[0] || null;
232
+ }
233
+
234
+ /** Whether a commit with this patch-id is among `container`'s own commits (above the trunk). */
235
+ async function patchIdIn(id: string | null, container: string, trunkRef: string): Promise<boolean> {
236
+ if (!id) return false;
237
+ const exclude = (await git.refExists(trunkRef)) ? [`^${trunkRef}`] : [];
238
+ const { stdout } =
239
+ await $`git log -p --no-merges --max-count=2000 ${container} ${exclude} | git patch-id --stable`
240
+ .nothrow()
241
+ .quiet();
242
+ return stdout
243
+ .toString()
244
+ .split("\n")
245
+ .some((line) => line.split(" ")[0] === id);
246
+ }
247
+
248
+ const NO_BLOB = /^0+$/;
249
+
250
+ /**
251
+ * The files a local commit touched, with blobs before and after. NUL-separated
252
+ * (-z) so paths come back raw, exactly as GitHub's API reports them.
253
+ */
254
+ export async function localCommitFiles(sha: string): Promise<CommitFile[] | null> {
255
+ const { stdout, exitCode } = await $`git diff-tree -r -z --no-commit-id --root ${sha}`
256
+ .nothrow()
257
+ .quiet();
258
+ if (exitCode !== 0) return null;
259
+ // -z: ":<old mode> <new mode> <old blob> <new blob> <status>\0<path>\0" per file.
260
+ const parts = stdout.toString().split("\0");
261
+ const files: CommitFile[] = [];
262
+ for (let i = 0; i + 1 < parts.length; i += 2) {
263
+ const meta = parts[i]!.replace(/^:/, "").split(" ");
264
+ const [, , oldBlob, newBlob, status] = meta;
265
+ if (!status) continue;
266
+ files.push({
267
+ filename: parts[i + 1]!,
268
+ status: status === "A" ? "added" : status === "D" ? "removed" : "modified",
269
+ sha: NO_BLOB.test(newBlob!) ? null : newBlob!,
270
+ before: NO_BLOB.test(oldBlob!) ? null : oldBlob!,
271
+ });
272
+ }
273
+ return files;
274
+ }
275
+
276
+ /** Each path's blob in `tree` (absent paths are missing from the map). */
277
+ async function blobsAt(tree: string, paths: string[]): Promise<Map<string, string> | null> {
278
+ const { stdout, exitCode } = await $`git --literal-pathspecs ls-tree -r -z ${tree} -- ${paths}`
279
+ .nothrow()
280
+ .quiet();
281
+ if (exitCode !== 0) return null;
282
+ const blobs = new Map<string, string>();
283
+ for (const entry of stdout.toString().split("\0").filter(Boolean)) {
284
+ // "<mode> <type> <blob>\t<path>"
285
+ const tab = entry.indexOf("\t");
286
+ blobs.set(entry.slice(tab + 1), entry.slice(0, tab).split(" ")[2]!);
287
+ }
288
+ return blobs;
289
+ }
290
+
291
+ /**
292
+ * Whether `container` holds every file `files` wrote, at the blob it wrote
293
+ * (and none it removed): the content is there even if the commit was
294
+ * rewritten. False when unsure, which never by itself means missing.
295
+ */
296
+ async function filesIn(files: CommitFile[] | null, container: string): Promise<boolean> {
297
+ if (!files || files.length === 0) return false;
298
+ const blobs = await blobsAt(
299
+ container,
300
+ files.map((f) => f.filename),
301
+ );
302
+ if (!blobs) return false;
303
+ return files.every((f) =>
304
+ f.status === "removed"
305
+ ? !blobs.has(f.filename)
306
+ : f.sha !== null && blobs.get(f.filename) === f.sha,
307
+ );
308
+ }
309
+
310
+ /**
311
+ * Whether `container` holds every file `files` touched exactly as it was
312
+ * *before* the commit: positive evidence its changes aren't there. Needs the
313
+ * "before" of every file (GitHub's API only gives that for added files).
314
+ */
315
+ async function filesAsBefore(files: CommitFile[] | null, container: string): Promise<boolean> {
316
+ if (!files || files.length === 0 || files.some((f) => f.before === undefined)) return false;
317
+ const blobs = await blobsAt(
318
+ container,
319
+ files.map((f) => f.filename),
320
+ );
321
+ if (!blobs) return false;
322
+ return files.every((f) =>
323
+ f.before === null ? !blobs.has(f.filename) : blobs.get(f.filename) === f.before,
324
+ );
325
+ }
@@ -0,0 +1,259 @@
1
+ // Defaults config (#39): repo and user defaults for boolean command flags, so
2
+ // a convention like "every PR opens as a draft" can't be forgotten.
3
+ //
4
+ // .gh-stack.toml at the repo root (committed, shared by the team)
5
+ // ~/.config/gh-stack/config.toml (personal; honors XDG_CONFIG_HOME)
6
+ // GH_STACK_DRAFT=1, GH_STACK_NO_PUSH=1 (env, per session)
7
+ //
8
+ // Precedence, highest first: CLI flag > env > repo config > user config > built-in.
9
+ // Keys mirror command and flag names (`[submit] draft`). A new default is one
10
+ // entry in SETTINGS. `--yes` is deliberately not configurable from any file.
11
+ import * as fs from "node:fs";
12
+ import * as os from "node:os";
13
+ import * as path from "node:path";
14
+ import { repoRoot } from "./git.ts";
15
+
16
+ export type ConfigSource = "flag" | "env" | "repo" | "user" | "default";
17
+
18
+ export interface BoolSetting {
19
+ section: string;
20
+ key: string;
21
+ builtin: boolean;
22
+ /** Flags that turn it on / off. The last one on the command line wins. */
23
+ onFlags: string[];
24
+ offFlags: string[];
25
+ /** `invert`: the variable names the opposite (GH_STACK_NO_PUSH=1 → push = false). */
26
+ env: { name: string; invert?: boolean };
27
+ description: string;
28
+ }
29
+
30
+ export const SETTINGS = {
31
+ "submit.draft": {
32
+ section: "submit",
33
+ key: "draft",
34
+ builtin: false,
35
+ onFlags: ["--draft", "-d"],
36
+ offFlags: ["--no-draft"],
37
+ env: { name: "GH_STACK_DRAFT" },
38
+ description: "Create new PRs as drafts",
39
+ },
40
+ "restack.push": {
41
+ section: "restack",
42
+ key: "push",
43
+ builtin: true,
44
+ onFlags: ["--push"],
45
+ offFlags: ["--no-push"],
46
+ env: { name: "GH_STACK_NO_PUSH", invert: true },
47
+ description: "Push rebased branches (restack and sync)",
48
+ },
49
+ } satisfies Record<string, BoolSetting>;
50
+
51
+ export type SettingName = keyof typeof SETTINGS;
52
+
53
+ export const REPO_CONFIG_FILE = ".gh-stack.toml";
54
+
55
+ export interface ConfigFile {
56
+ path: string;
57
+ exists: boolean;
58
+ data: Record<string, unknown>;
59
+ }
60
+
61
+ export interface ConfigLayers {
62
+ env: Record<string, string | undefined>;
63
+ repo: ConfigFile | null;
64
+ user: ConfigFile | null;
65
+ }
66
+
67
+ export interface Resolved {
68
+ value: boolean;
69
+ source: ConfigSource;
70
+ /** The flag, env var, or file the value came from. */
71
+ origin?: string;
72
+ }
73
+
74
+ /** A config problem the user must fix: malformed TOML, a wrong-typed value. */
75
+ export class ConfigError extends Error {}
76
+
77
+ function settingByPath(section: string, key: string): BoolSetting | undefined {
78
+ return (SETTINGS as Record<string, BoolSetting>)[`${section}.${key}`];
79
+ }
80
+
81
+ /**
82
+ * Parse a flag-default env var: 1/true/yes or 0/false/no (any case); empty =
83
+ * unset. Anything else is an error, so a typo can't be silently ignored.
84
+ */
85
+ export function parseEnvBool(name: string, raw: string | undefined): boolean | undefined {
86
+ if (raw === undefined || raw.trim() === "") return undefined;
87
+ const v = raw.trim().toLowerCase();
88
+ if (["1", "true", "yes"].includes(v)) return true;
89
+ if (["0", "false", "no"].includes(v)) return false;
90
+ throw new ConfigError(`${name}=${raw} is not a boolean (use 1/0, true/false, or yes/no).`);
91
+ }
92
+
93
+ /**
94
+ * Check a parsed config file against SETTINGS. A known key with a non-boolean
95
+ * value is an error (silently falling back could open a PR the repo wanted as
96
+ * a draft); unknown sections and keys come back as warnings.
97
+ */
98
+ export function validateConfig(data: Record<string, unknown>, file: string): string[] {
99
+ const warnings: string[] = [];
100
+ for (const [section, value] of Object.entries(data)) {
101
+ if (section === "yes") {
102
+ warnings.push(yesWarning(section, file));
103
+ continue;
104
+ }
105
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
106
+ warnings.push(`ignoring unknown key "${section}" in ${file}`);
107
+ continue;
108
+ }
109
+ for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
110
+ const spec = settingByPath(section, key);
111
+ if (!spec) {
112
+ warnings.push(
113
+ key === "yes"
114
+ ? yesWarning(`[${section}] yes`, file)
115
+ : `ignoring unknown key [${section}] ${key} in ${file}`,
116
+ );
117
+ continue;
118
+ }
119
+ if (typeof v !== "boolean") {
120
+ throw new ConfigError(
121
+ `[${section}] ${key} in ${file} must be true or false, got ${JSON.stringify(v)}.`,
122
+ );
123
+ }
124
+ }
125
+ }
126
+ return warnings;
127
+ }
128
+
129
+ function yesWarning(where: string, file: string): string {
130
+ return (
131
+ `ignoring ${where} in ${file}: auto-confirming destructive operations can't be ` +
132
+ `set in a config file. Pass --yes or set GH_STACK_YES=1 for the run that needs it.`
133
+ );
134
+ }
135
+
136
+ /** The value an explicit flag sets, or null when no flag for it is present. */
137
+ export function flagSetting(name: SettingName, args: string[]): Resolved | null {
138
+ const spec: BoolSetting = SETTINGS[name];
139
+ for (let i = args.length - 1; i >= 0; i--) {
140
+ const arg = args[i]!;
141
+ if (spec.onFlags.includes(arg)) return { value: true, source: "flag", origin: arg };
142
+ if (spec.offFlags.includes(arg)) return { value: false, source: "flag", origin: arg };
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /**
148
+ * Resolve one setting from the CLI args and the config layers. Pure: the
149
+ * layers are loaded (and validated) by `loadConfigLayers`.
150
+ */
151
+ export function resolveSetting(name: SettingName, args: string[], layers: ConfigLayers): Resolved {
152
+ const spec: BoolSetting = SETTINGS[name];
153
+
154
+ const flag = flagSetting(name, args);
155
+ if (flag) return flag;
156
+
157
+ const envValue = parseEnvBool(spec.env.name, layers.env[spec.env.name]);
158
+ if (envValue !== undefined) {
159
+ return {
160
+ value: spec.env.invert ? !envValue : envValue,
161
+ source: "env",
162
+ origin: spec.env.name,
163
+ };
164
+ }
165
+
166
+ for (const source of ["repo", "user"] as const) {
167
+ const file = layers[source];
168
+ const section = file?.data[spec.section] as Record<string, unknown> | undefined;
169
+ const v = section?.[spec.key];
170
+ if (typeof v === "boolean") return { value: v, source, origin: file!.path };
171
+ }
172
+
173
+ return { value: spec.builtin, source: "default" };
174
+ }
175
+
176
+ /**
177
+ * Read and validate one TOML file. A missing file (ENOENT) is an empty layer;
178
+ * any other read failure (a directory, no permission) is a ConfigError.
179
+ */
180
+ export function loadConfigFile(file: string): { file: ConfigFile; warnings: string[] } {
181
+ let text: string;
182
+ try {
183
+ text = fs.readFileSync(file, "utf8");
184
+ } catch (err) {
185
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
186
+ return { file: { path: file, exists: false, data: {} }, warnings: [] };
187
+ }
188
+ throw new ConfigError(`can't read ${file}: ${(err as Error).message}`);
189
+ }
190
+ let data: Record<string, unknown>;
191
+ try {
192
+ data = Bun.TOML.parse(text) as Record<string, unknown>;
193
+ } catch (err) {
194
+ throw new ConfigError(`invalid TOML in ${file}: ${(err as Error).message}`);
195
+ }
196
+ return { file: { path: file, exists: true, data }, warnings: validateConfig(data, file) };
197
+ }
198
+
199
+ /** `$XDG_CONFIG_HOME/gh-stack/config.toml`, else `~/.config/gh-stack/config.toml`. */
200
+ export function userConfigPath(env: Record<string, string | undefined> = process.env): string {
201
+ const base = env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
202
+ return path.join(base, "gh-stack", "config.toml");
203
+ }
204
+
205
+ /** `<repo root>/.gh-stack.toml`, or null outside a work tree. */
206
+ export async function repoConfigPath(): Promise<string | null> {
207
+ try {
208
+ const root = await repoRoot();
209
+ return root ? path.join(root, REPO_CONFIG_FILE) : null;
210
+ } catch {
211
+ return null; // not in a git work tree (e.g. `gh-stack config` run anywhere)
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Load env + repo + user layers. Files are re-read on every call (they're tiny
217
+ * and commands resolve once or twice), so in-process tests that switch repos
218
+ * never see a stale layer. Throws ConfigError; returns warnings to print.
219
+ */
220
+ export async function loadConfigLayers(): Promise<{ layers: ConfigLayers; warnings: string[] }> {
221
+ const repoPath = await repoConfigPath();
222
+ const repo = repoPath ? loadConfigFile(repoPath) : null;
223
+ const user = loadConfigFile(userConfigPath());
224
+ return {
225
+ layers: { env: process.env, repo: repo?.file ?? null, user: user.file },
226
+ warnings: [...(repo?.warnings ?? []), ...user.warnings],
227
+ };
228
+ }
229
+
230
+ /**
231
+ * Resolve a setting for a command. An explicit flag decides without reading
232
+ * any config, so a broken config file or env var can't stop a command (or a
233
+ * chained one, like merge's restack) that was told exactly what to do.
234
+ * Otherwise the layers are loaded: warnings go to stderr, and a ConfigError
235
+ * is thrown for the command, or index.ts, to report.
236
+ */
237
+ export async function resolveFlag(name: SettingName, args: string[]): Promise<Resolved> {
238
+ const flag = flagSetting(name, args);
239
+ if (flag) return flag;
240
+ const { layers, warnings } = await loadConfigLayers();
241
+ for (const w of warnings) console.error(`gh-stack: warning: ${w}`);
242
+ return resolveSetting(name, [], layers);
243
+ }
244
+
245
+ /** Human-readable origin of a resolved value, e.g. `repo config (/x/.gh-stack.toml)`. */
246
+ export function describeSource(r: Resolved): string {
247
+ switch (r.source) {
248
+ case "flag":
249
+ return `${r.origin} flag`;
250
+ case "env":
251
+ return `${r.origin} env var`;
252
+ case "repo":
253
+ return `repo config (${r.origin})`;
254
+ case "user":
255
+ return `user config (${r.origin})`;
256
+ case "default":
257
+ return "built-in default";
258
+ }
259
+ }