gh-stack 0.1.1 → 0.1.2
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.
- package/README.md +1 -1
- package/package.json +2 -2
- package/src/commands/add.ts +2 -1
- package/src/commands/merge.ts +2 -1
- package/src/commands/remove.ts +2 -1
- package/src/commands/restack.ts +14 -8
- package/src/commands/sync.ts +3 -2
- package/src/commands/undo.ts +13 -1
- package/src/index.ts +7 -5
- package/src/lib/cli.ts +32 -0
- package/src/lib/git.ts +25 -3
- package/src/lib/safety.ts +109 -1
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gh-stack",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Stacked PR manager for squash-merge workflows",
|
|
5
5
|
"author": "Kiliman <kiliman@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/kiliman/gh-stack"
|
|
9
|
+
"url": "git+https://github.com/kiliman/gh-stack.git"
|
|
10
10
|
},
|
|
11
11
|
"keywords": [
|
|
12
12
|
"git",
|
package/src/commands/add.ts
CHANGED
|
@@ -3,7 +3,7 @@ import * as p from "@clack/prompts";
|
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import * as git from "../lib/git.ts";
|
|
5
5
|
import { addBranchToStack, getTopOfStack, getOrderedBranches } from "../lib/metadata.ts";
|
|
6
|
-
import { ensureMetadata, ensureCurrentStack } from "../lib/safety.ts";
|
|
6
|
+
import { ensureMetadata, ensureCurrentStack, ensureValidStack } from "../lib/safety.ts";
|
|
7
7
|
import { getPrNumber } from "../lib/github.ts";
|
|
8
8
|
import { isAutoYes, selectParent } from "../lib/ui.ts";
|
|
9
9
|
import type { Branch } from "../types.ts";
|
|
@@ -48,6 +48,7 @@ OPTIONS
|
|
|
48
48
|
|
|
49
49
|
const meta = await ensureMetadata();
|
|
50
50
|
const stackName = ensureCurrentStack(meta);
|
|
51
|
+
await ensureValidStack(meta, stackName);
|
|
51
52
|
const stack = meta.stacks[stackName]!;
|
|
52
53
|
|
|
53
54
|
p.intro(pc.cyan(`Add branch to ${pc.yellow(stackName)}`));
|
package/src/commands/merge.ts
CHANGED
|
@@ -4,7 +4,7 @@ import * as p from "@clack/prompts";
|
|
|
4
4
|
import pc from "picocolors";
|
|
5
5
|
import * as git from "../lib/git.ts";
|
|
6
6
|
import { findStackForBranch, getOrderedBranches, writeMetadata } from "../lib/metadata.ts";
|
|
7
|
-
import { ensureMetadata, ensureCleanWorkingTree } from "../lib/safety.ts";
|
|
7
|
+
import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib/safety.ts";
|
|
8
8
|
import { takeSnapshot } from "../lib/snapshot.ts";
|
|
9
9
|
import { closePr } from "../lib/github.ts";
|
|
10
10
|
import { confirmAction } from "../lib/ui.ts";
|
|
@@ -39,6 +39,7 @@ This keeps all commits local (avoiding orphaned squash commits).
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
const stack = meta.stacks[stackName]!;
|
|
42
|
+
await ensureValidStack(meta, stackName);
|
|
42
43
|
const ordered = getOrderedBranches(stack);
|
|
43
44
|
|
|
44
45
|
if (ordered.length <= 1) {
|
package/src/commands/remove.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import * as p from "@clack/prompts";
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import { removeBranchFromStack, getChildren } from "../lib/metadata.ts";
|
|
5
|
-
import { ensureMetadata, ensureCurrentStack } from "../lib/safety.ts";
|
|
5
|
+
import { ensureMetadata, ensureCurrentStack, ensureValidStack } from "../lib/safety.ts";
|
|
6
6
|
import { takeSnapshot } from "../lib/snapshot.ts";
|
|
7
7
|
import { selectBranch } from "../lib/ui.ts";
|
|
8
8
|
import * as git from "../lib/git.ts";
|
|
@@ -23,6 +23,7 @@ If no branch is specified, shows interactive selector.
|
|
|
23
23
|
|
|
24
24
|
const meta = await ensureMetadata();
|
|
25
25
|
const stackName = ensureCurrentStack(meta);
|
|
26
|
+
await ensureValidStack(meta, stackName);
|
|
26
27
|
const stack = meta.stacks[stackName]!;
|
|
27
28
|
const currentBranch = await git.currentBranch();
|
|
28
29
|
|
package/src/commands/restack.ts
CHANGED
|
@@ -10,7 +10,12 @@ import {
|
|
|
10
10
|
loadRestackState,
|
|
11
11
|
clearRestackState,
|
|
12
12
|
} from "../lib/metadata.ts";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
ensureMetadata,
|
|
15
|
+
ensureCleanWorkingTree,
|
|
16
|
+
ensureNotMain,
|
|
17
|
+
ensureValidStack,
|
|
18
|
+
} from "../lib/safety.ts";
|
|
14
19
|
import { takeSnapshot } from "../lib/snapshot.ts";
|
|
15
20
|
import { confirmAction } from "../lib/ui.ts";
|
|
16
21
|
import type { StackMetadata } from "../types.ts";
|
|
@@ -72,7 +77,7 @@ ALIASES
|
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
// Cleanup tags
|
|
75
|
-
await git.deleteTagsMatching(
|
|
80
|
+
await git.deleteTagsMatching(git.STACK_SYNC_TAG_GLOB);
|
|
76
81
|
|
|
77
82
|
p.outro(pc.green("Stack Restack Complete!"));
|
|
78
83
|
}
|
|
@@ -142,7 +147,7 @@ async function handleResume(
|
|
|
142
147
|
const parent = stack.branches[branch]?.parent;
|
|
143
148
|
if (!parent) continue;
|
|
144
149
|
|
|
145
|
-
const tagName =
|
|
150
|
+
const tagName = git.tempBaseTagName(branch);
|
|
146
151
|
if (await git.tagExists(tagName)) {
|
|
147
152
|
const tagSha = await git.revParse(tagName);
|
|
148
153
|
console.log(
|
|
@@ -183,6 +188,7 @@ async function handleFreshRestack(
|
|
|
183
188
|
}
|
|
184
189
|
|
|
185
190
|
const stack = meta.stacks[stackName]!;
|
|
191
|
+
await ensureValidStack(meta, stackName);
|
|
186
192
|
|
|
187
193
|
// Check for stale tags from previous runs
|
|
188
194
|
await handleStaleTags();
|
|
@@ -247,7 +253,7 @@ async function handleFreshRestack(
|
|
|
247
253
|
|
|
248
254
|
const mb = await git.mergeBase(branch, parent);
|
|
249
255
|
if (mb) {
|
|
250
|
-
const tagName =
|
|
256
|
+
const tagName = git.tempBaseTagName(branch);
|
|
251
257
|
await git.createTag(tagName, mb);
|
|
252
258
|
console.log(
|
|
253
259
|
` ${pc.green("✓")} Tagged base for ${branch}: ${pc.cyan(tagName)} (${mb.slice(0, 8)})`,
|
|
@@ -319,7 +325,7 @@ async function processChain(
|
|
|
319
325
|
});
|
|
320
326
|
|
|
321
327
|
// Use temporary tag as stable base reference
|
|
322
|
-
const tagName =
|
|
328
|
+
const tagName = git.tempBaseTagName(branch);
|
|
323
329
|
let success: boolean;
|
|
324
330
|
|
|
325
331
|
if (await git.tagExists(tagName)) {
|
|
@@ -433,11 +439,11 @@ async function promptPush(branch: string): Promise<void> {
|
|
|
433
439
|
async function handleStaleTags(): Promise<void> {
|
|
434
440
|
try {
|
|
435
441
|
const { $ } = await import("bun");
|
|
436
|
-
const result = await $`git tag -l
|
|
442
|
+
const result = await $`git tag -l ${git.STACK_SYNC_TAG_GLOB}`.text();
|
|
437
443
|
const tags = result.trim();
|
|
438
444
|
if (!tags) return;
|
|
439
445
|
|
|
440
|
-
p.log.warn(
|
|
446
|
+
p.log.warn(`Found stale ${git.STACK_SYNC_TAG_GLOB} tags from a previous run`);
|
|
441
447
|
console.log();
|
|
442
448
|
|
|
443
449
|
const action = await p.select({
|
|
@@ -462,7 +468,7 @@ async function handleStaleTags(): Promise<void> {
|
|
|
462
468
|
|
|
463
469
|
if (action === "clean") {
|
|
464
470
|
p.log.info("Cleaning up stale tags...");
|
|
465
|
-
await git.deleteTagsMatching(
|
|
471
|
+
await git.deleteTagsMatching(git.STACK_SYNC_TAG_GLOB);
|
|
466
472
|
p.log.success("Tags cleaned");
|
|
467
473
|
}
|
|
468
474
|
|
package/src/commands/sync.ts
CHANGED
|
@@ -3,7 +3,7 @@ import * as p from "@clack/prompts";
|
|
|
3
3
|
import pc from "picocolors";
|
|
4
4
|
import * as git from "../lib/git.ts";
|
|
5
5
|
import { findStackForBranch, getOrderedBranches, saveRestackState } from "../lib/metadata.ts";
|
|
6
|
-
import { ensureMetadata, ensureCleanWorkingTree } from "../lib/safety.ts";
|
|
6
|
+
import { ensureMetadata, ensureCleanWorkingTree, ensureValidStack } from "../lib/safety.ts";
|
|
7
7
|
import { takeSnapshot } from "../lib/snapshot.ts";
|
|
8
8
|
import { confirmAction } from "../lib/ui.ts";
|
|
9
9
|
|
|
@@ -36,6 +36,7 @@ with the base branch included.
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
const stack = meta.stacks[stackName]!;
|
|
39
|
+
await ensureValidStack(meta, stackName);
|
|
39
40
|
const ordered = getOrderedBranches(stack);
|
|
40
41
|
|
|
41
42
|
if (ordered.length === 0) {
|
|
@@ -98,7 +99,7 @@ with the base branch included.
|
|
|
98
99
|
const parentRef = parent === "main" ? "origin/main" : parent;
|
|
99
100
|
const mb = await git.mergeBase(branch, parentRef);
|
|
100
101
|
if (mb) {
|
|
101
|
-
const tagName =
|
|
102
|
+
const tagName = git.tempBaseTagName(branch);
|
|
102
103
|
await git.createTag(tagName, mb);
|
|
103
104
|
console.log(
|
|
104
105
|
` ${pc.green("✓")} Tagged base for ${branch}: ${pc.cyan(tagName)} (${mb.slice(0, 8)})`,
|
package/src/commands/undo.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// gh-stack undo — Restore from last snapshot
|
|
2
2
|
import * as p from "@clack/prompts";
|
|
3
|
+
import * as git from "../lib/git.ts";
|
|
3
4
|
import pc from "picocolors";
|
|
4
5
|
import { ensureMetadata, ensureCleanWorkingTree } from "../lib/safety.ts";
|
|
5
6
|
import { getLastSnapshot, popSnapshot } from "../lib/snapshot.ts";
|
|
@@ -47,10 +48,21 @@ a destructive operation (restack, merge, sync, remove).
|
|
|
47
48
|
process.exit(0);
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
let activeBranch: string | null = null;
|
|
52
|
+
try {
|
|
53
|
+
activeBranch = await git.currentBranch();
|
|
54
|
+
} catch {
|
|
55
|
+
// Detached HEAD is fine — we can restore all branches with branch -f.
|
|
56
|
+
}
|
|
57
|
+
|
|
50
58
|
// Restore each branch HEAD
|
|
51
59
|
for (const [branch, sha] of Object.entries(snapshot.branches)) {
|
|
52
60
|
try {
|
|
53
|
-
|
|
61
|
+
if (branch === activeBranch) {
|
|
62
|
+
await $`git reset --hard ${sha}`.quiet();
|
|
63
|
+
} else {
|
|
64
|
+
await $`git branch -f ${branch} ${sha}`.quiet();
|
|
65
|
+
}
|
|
54
66
|
p.log.success(`Restored ${pc.yellow(branch)} → ${sha.slice(0, 8)}`);
|
|
55
67
|
} catch {
|
|
56
68
|
p.log.error(`Failed to restore ${branch}`);
|
package/src/index.ts
CHANGED
|
@@ -7,26 +7,28 @@ if (process.env.GH_STACK_NO_COLOR) {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
import { ensureGitRepo } from "./lib/safety.ts";
|
|
10
|
+
import { parseCliArgs } from "./lib/cli.ts";
|
|
10
11
|
import { setAutoYes } from "./lib/ui.ts";
|
|
11
12
|
|
|
12
13
|
const { version: VERSION } = await import("../package.json");
|
|
13
14
|
|
|
14
15
|
const args = process.argv.slice(2);
|
|
15
|
-
const
|
|
16
|
+
const parsed = parseCliArgs(args, process.env);
|
|
17
|
+
const command = parsed.command;
|
|
16
18
|
|
|
17
19
|
// Handle global flags
|
|
18
|
-
if (
|
|
20
|
+
if (parsed.showVersion) {
|
|
19
21
|
console.log(`gh-stack v${VERSION}`);
|
|
20
22
|
process.exit(0);
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
if (
|
|
25
|
+
if (parsed.showGlobalHelp) {
|
|
24
26
|
printHelp();
|
|
25
27
|
process.exit(0);
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
// Global --yes flag: skip all interactive confirmations (for agents/CI)
|
|
29
|
-
if (
|
|
31
|
+
if (parsed.autoYes) {
|
|
30
32
|
setAutoYes(true);
|
|
31
33
|
}
|
|
32
34
|
|
|
@@ -34,7 +36,7 @@ if (args.includes("--yes") || args.includes("-y") || process.env.GH_STACK_YES ==
|
|
|
34
36
|
await ensureGitRepo();
|
|
35
37
|
|
|
36
38
|
// Route to subcommand — strip global flags from command args
|
|
37
|
-
const commandArgs =
|
|
39
|
+
const commandArgs = parsed.commandArgs;
|
|
38
40
|
|
|
39
41
|
switch (command) {
|
|
40
42
|
case "show":
|
package/src/lib/cli.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface ParsedCliArgs {
|
|
2
|
+
command: string;
|
|
3
|
+
commandArgs: string[];
|
|
4
|
+
autoYes: boolean;
|
|
5
|
+
showVersion: boolean;
|
|
6
|
+
showGlobalHelp: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const LEADING_GLOBAL_FLAGS = new Set(["--yes", "-y", "--help", "--version", "-V"]);
|
|
10
|
+
|
|
11
|
+
export function parseCliArgs(rawArgs: string[], env: NodeJS.ProcessEnv): ParsedCliArgs {
|
|
12
|
+
let commandIndex = 0;
|
|
13
|
+
const leadingFlags = new Set<string>();
|
|
14
|
+
|
|
15
|
+
while (commandIndex < rawArgs.length && LEADING_GLOBAL_FLAGS.has(rawArgs[commandIndex]!)) {
|
|
16
|
+
leadingFlags.add(rawArgs[commandIndex]!);
|
|
17
|
+
commandIndex++;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const command = rawArgs[commandIndex] || "show";
|
|
21
|
+
const commandArgs = rawArgs
|
|
22
|
+
.slice(commandIndex + 1)
|
|
23
|
+
.filter((arg) => arg !== "--yes" && arg !== "-y");
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
command,
|
|
27
|
+
commandArgs,
|
|
28
|
+
autoYes: rawArgs.includes("--yes") || rawArgs.includes("-y") || env.GH_STACK_YES === "1",
|
|
29
|
+
showVersion: leadingFlags.has("--version") || leadingFlags.has("-V"),
|
|
30
|
+
showGlobalHelp: !rawArgs[commandIndex] && leadingFlags.has("--help"),
|
|
31
|
+
};
|
|
32
|
+
}
|
package/src/lib/git.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
// Git command helpers using Bun's shell API
|
|
2
2
|
import { $ } from "bun";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
export const STACK_SYNC_TAG_PREFIX = "stack-sync-base";
|
|
6
|
+
export const STACK_SYNC_TAG_GLOB = `${STACK_SYNC_TAG_PREFIX}-*`;
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* Get the current branch name.
|
|
@@ -141,6 +145,16 @@ export async function remoteBranchExists(branch: string): Promise<boolean> {
|
|
|
141
145
|
return exitCode === 0;
|
|
142
146
|
}
|
|
143
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Check if a branch exists locally.
|
|
150
|
+
*/
|
|
151
|
+
export async function localBranchExists(branch: string): Promise<boolean> {
|
|
152
|
+
const { exitCode } = await $`git show-ref --verify --quiet refs/heads/${branch}`
|
|
153
|
+
.nothrow()
|
|
154
|
+
.quiet();
|
|
155
|
+
return exitCode === 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
144
158
|
/**
|
|
145
159
|
* Create a temporary tag at a specific commit.
|
|
146
160
|
*/
|
|
@@ -230,8 +244,16 @@ export async function commitCount(from: string, to: string): Promise<number> {
|
|
|
230
244
|
}
|
|
231
245
|
|
|
232
246
|
/**
|
|
233
|
-
*
|
|
247
|
+
* Build a deterministic temp tag name for a branch.
|
|
248
|
+
* Includes a readable slug plus a stable hash to avoid collisions.
|
|
234
249
|
*/
|
|
235
|
-
export function
|
|
236
|
-
|
|
250
|
+
export function tempBaseTagName(branch: string): string {
|
|
251
|
+
const slug =
|
|
252
|
+
branch
|
|
253
|
+
.toLowerCase()
|
|
254
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
255
|
+
.replace(/^-+|-+$/g, "")
|
|
256
|
+
.slice(0, 24) || "branch";
|
|
257
|
+
const hash = createHash("sha256").update(branch).digest("hex").slice(0, 10);
|
|
258
|
+
return `${STACK_SYNC_TAG_PREFIX}-${slug}-${hash}`;
|
|
237
259
|
}
|
package/src/lib/safety.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Pre-flight safety checks
|
|
2
2
|
import * as git from "./git.ts";
|
|
3
3
|
import { metadataExists, readMetadata, findStackForBranch } from "./metadata.ts";
|
|
4
|
-
import type { StackMetadata } from "../types.ts";
|
|
4
|
+
import type { StackMetadata, Stack } from "../types.ts";
|
|
5
5
|
import * as p from "@clack/prompts";
|
|
6
6
|
import pc from "picocolors";
|
|
7
7
|
|
|
@@ -96,3 +96,111 @@ export function ensureNotMain(branch: string): void {
|
|
|
96
96
|
process.exit(1);
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Validate stack metadata before destructive operations.
|
|
102
|
+
*/
|
|
103
|
+
export async function validateStack(meta: StackMetadata, stackName: string): Promise<string[]> {
|
|
104
|
+
const stack = meta.stacks[stackName];
|
|
105
|
+
if (!stack) return [`Stack "${stackName}" not found`];
|
|
106
|
+
|
|
107
|
+
const branchNames = Object.keys(stack.branches);
|
|
108
|
+
const errors: string[] = [];
|
|
109
|
+
|
|
110
|
+
for (const branchName of branchNames) {
|
|
111
|
+
const branch = stack.branches[branchName]!;
|
|
112
|
+
|
|
113
|
+
if (!(await git.localBranchExists(branchName))) {
|
|
114
|
+
errors.push(`Branch "${branchName}" does not exist locally`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (branch.parent === branchName) {
|
|
118
|
+
errors.push(`Branch "${branchName}" cannot be its own parent`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (branch.parent !== "main" && branch.parent !== "master" && !stack.branches[branch.parent]) {
|
|
122
|
+
errors.push(`Branch "${branchName}" has unknown parent "${branch.parent}"`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
errors.push(...validateStackGraph(stack));
|
|
127
|
+
return errors;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function ensureValidStack(meta: StackMetadata, stackName: string): Promise<void> {
|
|
131
|
+
const errors = await validateStack(meta, stackName);
|
|
132
|
+
if (errors.length === 0) return;
|
|
133
|
+
|
|
134
|
+
p.cancel(`Invalid stack metadata for ${pc.yellow(stackName)}`);
|
|
135
|
+
console.log();
|
|
136
|
+
for (const error of errors) {
|
|
137
|
+
console.log(` ${pc.red("•")} ${error}`);
|
|
138
|
+
}
|
|
139
|
+
console.log();
|
|
140
|
+
console.log(
|
|
141
|
+
pc.dim("Fix .git/gh-stack-metadata.json or repair the local branches before retrying."),
|
|
142
|
+
);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function validateStackGraph(stack: Stack): string[] {
|
|
147
|
+
const branchNames = Object.keys(stack.branches);
|
|
148
|
+
if (branchNames.length === 0) return [];
|
|
149
|
+
|
|
150
|
+
const errors: string[] = [];
|
|
151
|
+
const roots = branchNames.filter((branchName) => {
|
|
152
|
+
const parent = stack.branches[branchName]!.parent;
|
|
153
|
+
return parent === "main" || parent === "master";
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
if (roots.length === 0) {
|
|
157
|
+
errors.push("Stack has no root branch with parent main/master");
|
|
158
|
+
}
|
|
159
|
+
if (roots.length > 1) {
|
|
160
|
+
errors.push(`Stack must have exactly one root branch; found ${roots.length}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const childrenByParent = new Map<string, string[]>();
|
|
164
|
+
for (const [branchName, branch] of Object.entries(stack.branches)) {
|
|
165
|
+
const siblings = childrenByParent.get(branch.parent) ?? [];
|
|
166
|
+
siblings.push(branchName);
|
|
167
|
+
childrenByParent.set(branch.parent, siblings);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const visited = new Set<string>();
|
|
171
|
+
const visiting = new Set<string>();
|
|
172
|
+
|
|
173
|
+
function walk(branchName: string) {
|
|
174
|
+
if (visiting.has(branchName)) {
|
|
175
|
+
errors.push(`Cycle detected involving "${branchName}"`);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (visited.has(branchName)) return;
|
|
179
|
+
|
|
180
|
+
visiting.add(branchName);
|
|
181
|
+
for (const child of childrenByParent.get(branchName) ?? []) {
|
|
182
|
+
walk(child);
|
|
183
|
+
}
|
|
184
|
+
visiting.delete(branchName);
|
|
185
|
+
visited.add(branchName);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
for (const root of roots) {
|
|
189
|
+
walk(root);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// If the graph is malformed, walk any remaining nodes so we can report
|
|
193
|
+
// cycles/unreachable branches instead of stopping at the root error.
|
|
194
|
+
for (const branchName of branchNames) {
|
|
195
|
+
if (!visited.has(branchName)) {
|
|
196
|
+
walk(branchName);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (visited.size !== branchNames.length) {
|
|
201
|
+
const unreachable = branchNames.filter((branchName) => !visited.has(branchName));
|
|
202
|
+
errors.push(`Unreachable or cyclic branches detected: ${unreachable.join(", ")}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return errors;
|
|
206
|
+
}
|