Skip to content
Open
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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,10 @@ then repair descendants automatically after the root lands.
- Refreshes stack blocks in descriptions.
- Saves `.git/stack/undo.json` before mutations.

GitHub stack blocks use compact `#101` references. GitLab blocks use `!101`
references plus titles because bare GitLab MR links only show titles on hover.
Stack blocks render open changes as a nested list where indentation shows the
PR/MR target-branch topology. GitHub stack blocks use compact `#101`
references. GitLab blocks use `!101` references plus titles because bare GitLab
MR links only show titles on hover.

If a repair fails, run:

Expand Down
14 changes: 8 additions & 6 deletions skills/stack/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,19 +87,21 @@ work. Repeat after any parent branch changes or a squash merge lands.
`stack sync --apply` and `stack merge --apply/--auto` refresh a deterministic
block in each open change description:

```md
```text
<!-- stack:links:start -->

### [Stack](https://github.com/kitlangton/stack)

1. #101
2. #102
3. **#103** 👈 current
- #101 `stack-a`
- #102 `stack-b`
- **#103** 👈 current `stack-c`
<!-- stack:links:end -->
```

Earlier entries are landed history. The current change is bold with `👈 current`.
GitHub uses `#123`; GitLab uses `!123 - Title`.
Earlier top-level entries are landed history. Open changes are rendered as a
nested list where indentation shows lineage and siblings share the same parent.
The current change is bold with `👈 current`. GitHub uses `#123`; GitLab uses
`!123 - Title`.

## Safety Rules

Expand Down
2 changes: 1 addition & 1 deletion src/services/Stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1419,7 +1419,7 @@ ${note}`;
StackBlock.render({
pulls: selectedPulls,
metas,
chain: graph.displayChainFor(String(pull.head)),
tree: graph.displayTreeFor(String(pull.head)),
completed,
branch: String(pull.head),
previous: meta.body,
Expand Down
48 changes: 36 additions & 12 deletions src/stackBlock.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { PullMeta, PullRef } from "./domain/model.ts";
import type { DisplayTree } from "./stackGraph.ts";

const start = "<!-- stack:links:start -->";
const end = "<!-- stack:links:end -->";
Expand Down Expand Up @@ -41,32 +42,41 @@ const completedLines = (
return prior
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("- [") || /^\d+\.\s+/.test(line))
.filter((line) => line.startsWith("- ") || /^\d+\.\s+/.test(line))
.flatMap((line) => {
const checked = line.startsWith("- [x]");
const numbered = /^\d+\.\s+/.test(line);
const branch = line.match(/`([^`]+)`/)?.[1] ?? null;
const pr = line.match(/[#!]\d+/)?.[0] ?? null;
const key = branch ?? pr;
if (!key || liveKeys.has(key)) return [];
if (completedKeys.size > 0 && !numbered && !checked && !completedKeys.has(key)) {
const checkbox = line.startsWith("- [");
const completedHistory = checked || numbered || (!checkbox && branch === null);
if (!completedHistory && !completedKeys.has(key)) {
return [];
}
if (completedKeys.size === 0 && line.startsWith("- [") && !checked) {
if (checkbox && !checked && !completedKeys.has(key)) {
return [];
}
const cleaned = line
.replace(/^- \[[ x]\]\s+/, "")
.replace(/^-\s+/, "")
.replace(/^\d+\.\s+/, "")
.replaceAll("**", "")
.replace(/([#!]\d+)\s+`[^`]+`/g, "$1")
.replace(/\s+`[^`]+`/g, "")
.replace(/\s*(?:←|👈) current$/, "");
const number = Number(pr?.slice(1));
const title = Number.isInteger(number) ? completedTitles.get(number) : undefined;
return [/[#!]\d+\s+-\s+/.test(cleaned) ? cleaned : `${cleaned}${inlineTitle(title ?? null)}`];
});
};

const walkTree = (tree: DisplayTree): ReadonlyArray<string> => [
tree.branch,
...tree.children.flatMap((child) => walkTree(child)),
];

export const references = (body: string) => {
const prior = body.match(new RegExp(`${start}([\\s\\S]*?)${end}`))?.[1];
if (!prior) return [];
Expand All @@ -78,7 +88,7 @@ export const references = (body: string) => {
export const render = (opts: {
readonly pulls: ReadonlyArray<PullRef>;
readonly metas: ReadonlyMap<string, PullMeta>;
readonly chain: ReadonlyArray<string>;
readonly tree: DisplayTree | null;
readonly completed?: ReadonlySet<string>;
readonly branch: string;
readonly previous: string;
Expand All @@ -91,17 +101,25 @@ export const render = (opts: {
const showTitles = opts.showTitles ?? false;
const heading = (opts.blockLink ?? true) ? linkedHeading : plainHeading;
const prs = new Map(opts.pulls.map((pull) => [String(pull.head), pull]));
const chain = opts.chain;
const treeBranches = opts.tree ? walkTree(opts.tree) : [];
const liveKeys = new Set(
chain.flatMap((branch) => {
[...opts.pulls.map((pull) => String(pull.head)), ...treeBranches].flatMap((branch) => {
const pr = prs.get(branch) ?? opts.metas.get(branch) ?? null;
return pr ? [branch, `#${pr.number}`, `!${pr.number}`] : [branch];
}),
);
const line = (name: string) => {
const head = format(name, prs, opts.metas, reference, showTitles);
if (name === opts.branch) return `**${head}** 👈 current`;
return head;
const label = name === opts.branch ? `**${head}** 👈 current` : head;
if (head === `\`${name}\``) return label;
return `${label} \`${name}\``;
};
const treeLines = (tree: DisplayTree, depth = 0): ReadonlyArray<string> => {
const prefix = `${" ".repeat(depth)}- `;
return [
prefix + line(tree.branch),
...tree.children.flatMap((child) => treeLines(child, depth + 1)),
];
};
const items = [
...completedLines(
Expand All @@ -110,12 +128,18 @@ export const render = (opts: {
opts.completed ?? new Set(),
showTitles ? (opts.completedTitles ?? new Map()) : new Map(),
),
...chain.map(line),
...(opts.tree ? treeLines(opts.tree) : []),
];

return [start, heading, "", ...items.map((item, index) => `${index + 1}. ${item}`), end].join(
"\n",
);
return [
start,
heading,
"",
...items.map((entry) =>
entry.startsWith("- ") || entry.startsWith(" ") ? entry : `- ${entry}`,
),
end,
].join("\n");
};

export const splice = (body: string, next: string) => {
Expand Down
23 changes: 23 additions & 0 deletions src/stackGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,17 @@ export interface StatusTree {
readonly children: ReadonlyMap<string, ReadonlyArray<string>>;
}

export interface DisplayTree {
readonly branch: string;
readonly children: ReadonlyArray<DisplayTree>;
}

export interface StackGraph {
readonly report: StatusReport;
readonly tree: StatusTree;
readonly pathTo: (branch: string) => ReadonlyArray<string>;
readonly displayChainFor: (branch: string) => ReadonlyArray<string>;
readonly displayTreeFor: (branch: string) => DisplayTree;
readonly rank: (branch: string) => number;
readonly rootOf: (branch: string) => string;
readonly wouldCreateCycle: (branch: string, parent: string) => boolean;
Expand Down Expand Up @@ -177,6 +183,7 @@ export const make = (input: StackGraphInput): StackGraph => {
};

const explicitChildren = new Map<string, Array<string>>();
const liveBranches = new Set(input.pulls.map((pull) => String(pull.head)));
for (const link of input.state.links) {
const parent = String(link.parent);
const list = explicitChildren.get(parent) ?? [];
Expand All @@ -203,6 +210,21 @@ export const make = (input: StackGraphInput): StackGraph => {
return chain;
};

const displayTreeFor = (branch: string): DisplayTree => {
const root = pathTo(branch).find((name) => liveBranches.has(name)) ?? branch;
const build = (name: string, seen = new Set<string>()): DisplayTree => {
if (seen.has(name)) return { branch: name, children: [] };
const nextSeen = new Set(seen);
nextSeen.add(name);
const children = (explicitChildren.get(name) ?? [])
.filter((child) => liveBranches.has(child))
.map((child) => build(child, nextSeen));
return { branch: name, children };
};

return build(root);
};

const rank = (branch: string, seen = new Set<string>()): number => {
if (seen.has(branch)) return 0;
const link = links.get(branch);
Expand All @@ -229,6 +251,7 @@ export const make = (input: StackGraphInput): StackGraph => {
tree,
pathTo,
displayChainFor,
displayTreeFor,
rank,
rootOf,
wouldCreateCycle: (branch: string, parent: string) =>
Expand Down
Loading