gh-stack 0.1.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.
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/package.json +66 -0
- package/src/commands/add.ts +137 -0
- package/src/commands/archive.ts +60 -0
- package/src/commands/init.ts +156 -0
- package/src/commands/list.ts +64 -0
- package/src/commands/merge.ts +175 -0
- package/src/commands/remove.ts +78 -0
- package/src/commands/restack.ts +476 -0
- package/src/commands/show.ts +73 -0
- package/src/commands/status.ts +372 -0
- package/src/commands/switch.ts +114 -0
- package/src/commands/sync.ts +189 -0
- package/src/commands/undo.ts +64 -0
- package/src/commands/update-prs.ts +177 -0
- package/src/index.ts +146 -0
- package/src/lib/git.ts +237 -0
- package/src/lib/github.ts +184 -0
- package/src/lib/metadata.ts +307 -0
- package/src/lib/safety.ts +98 -0
- package/src/lib/snapshot.ts +74 -0
- package/src/lib/ui.ts +234 -0
- package/src/types.ts +68 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// gh-stack undo — Restore from last snapshot
|
|
2
|
+
import * as p from "@clack/prompts";
|
|
3
|
+
import pc from "picocolors";
|
|
4
|
+
import { ensureMetadata, ensureCleanWorkingTree } from "../lib/safety.ts";
|
|
5
|
+
import { getLastSnapshot, popSnapshot } from "../lib/snapshot.ts";
|
|
6
|
+
import { confirmAction } from "../lib/ui.ts";
|
|
7
|
+
import { $ } from "bun";
|
|
8
|
+
|
|
9
|
+
export default async function undo(args: string[]): Promise<void> {
|
|
10
|
+
if (args.includes("--help")) {
|
|
11
|
+
console.log(`
|
|
12
|
+
gh-stack undo — Restore from last snapshot
|
|
13
|
+
|
|
14
|
+
USAGE
|
|
15
|
+
gh-stack undo
|
|
16
|
+
|
|
17
|
+
Restores branch HEADs from the last snapshot taken before
|
|
18
|
+
a destructive operation (restack, merge, sync, remove).
|
|
19
|
+
`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
await ensureCleanWorkingTree();
|
|
24
|
+
|
|
25
|
+
const meta = await ensureMetadata();
|
|
26
|
+
|
|
27
|
+
const snapshot = getLastSnapshot(meta);
|
|
28
|
+
if (!snapshot) {
|
|
29
|
+
p.cancel("No snapshots available to undo");
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
p.intro(pc.cyan("Undo Last Operation"));
|
|
34
|
+
|
|
35
|
+
p.log.info(`Operation: ${pc.yellow(snapshot.operation)}`);
|
|
36
|
+
p.log.info(`Timestamp: ${pc.dim(snapshot.timestamp)}`);
|
|
37
|
+
console.log();
|
|
38
|
+
p.log.info("Will restore:");
|
|
39
|
+
for (const [branch, sha] of Object.entries(snapshot.branches)) {
|
|
40
|
+
console.log(` ${branch} → ${pc.dim(sha.slice(0, 8))}`);
|
|
41
|
+
}
|
|
42
|
+
console.log();
|
|
43
|
+
|
|
44
|
+
const confirmed = await confirmAction("Restore these branch positions?");
|
|
45
|
+
if (!confirmed) {
|
|
46
|
+
p.cancel("Cancelled");
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Restore each branch HEAD
|
|
51
|
+
for (const [branch, sha] of Object.entries(snapshot.branches)) {
|
|
52
|
+
try {
|
|
53
|
+
await $`git branch -f ${branch} ${sha}`.quiet();
|
|
54
|
+
p.log.success(`Restored ${pc.yellow(branch)} → ${sha.slice(0, 8)}`);
|
|
55
|
+
} catch {
|
|
56
|
+
p.log.error(`Failed to restore ${branch}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Pop the snapshot
|
|
61
|
+
await popSnapshot(meta);
|
|
62
|
+
|
|
63
|
+
p.outro(pc.green("Undo complete!"));
|
|
64
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// gh-stack update-prs — Update PR descriptions with stack visualization
|
|
2
|
+
// Port of reference/gh-stack-update-pr.sh
|
|
3
|
+
import * as p from "@clack/prompts";
|
|
4
|
+
import pc from "picocolors";
|
|
5
|
+
import * as git from "../lib/git.ts";
|
|
6
|
+
import { findStackForBranch, getOrderedBranches } from "../lib/metadata.ts";
|
|
7
|
+
import { ensureMetadata } from "../lib/safety.ts";
|
|
8
|
+
import { getPrInfo, getPrBody, updatePrBody, reviewEmoji } from "../lib/github.ts";
|
|
9
|
+
|
|
10
|
+
export default async function updatePrs(args: string[]): Promise<void> {
|
|
11
|
+
if (args.includes("--help")) {
|
|
12
|
+
console.log(`
|
|
13
|
+
gh-stack update-prs — Update PR descriptions with stack visualization
|
|
14
|
+
|
|
15
|
+
USAGE
|
|
16
|
+
gh-stack update-prs
|
|
17
|
+
|
|
18
|
+
Updates all PRs in the current stack with a standardized stack section
|
|
19
|
+
showing the tree structure, PR links, and review status.
|
|
20
|
+
`);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const meta = await ensureMetadata();
|
|
25
|
+
const branch = await git.currentBranch();
|
|
26
|
+
const stackName = findStackForBranch(meta, branch);
|
|
27
|
+
|
|
28
|
+
if (!stackName) {
|
|
29
|
+
p.cancel(`Branch ${pc.blue(branch)} not found in any stack`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const stack = meta.stacks[stackName]!;
|
|
34
|
+
const ordered = getOrderedBranches(stack);
|
|
35
|
+
|
|
36
|
+
p.intro(pc.cyan("Update PR Descriptions"));
|
|
37
|
+
p.log.info(`Stack: ${pc.yellow(stackName)}`);
|
|
38
|
+
p.log.info(`Found ${ordered.length} branch(es) in stack`);
|
|
39
|
+
console.log();
|
|
40
|
+
|
|
41
|
+
// Collect PR info for all branches
|
|
42
|
+
const s = p.spinner();
|
|
43
|
+
s.start("Fetching PR info from GitHub...");
|
|
44
|
+
|
|
45
|
+
interface BranchPrInfo {
|
|
46
|
+
branch: string;
|
|
47
|
+
prNumber: number | null;
|
|
48
|
+
prTitle: string;
|
|
49
|
+
reviewEmojiStr: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const branchInfos: BranchPrInfo[] = [];
|
|
53
|
+
|
|
54
|
+
for (const branchName of ordered) {
|
|
55
|
+
const branchMeta = stack.branches[branchName]!;
|
|
56
|
+
const prNumber = branchMeta.pr ?? null;
|
|
57
|
+
let prTitle = branchMeta.description || branchName;
|
|
58
|
+
let review = "PENDING";
|
|
59
|
+
|
|
60
|
+
if (prNumber) {
|
|
61
|
+
const info = await getPrInfo(prNumber);
|
|
62
|
+
if (info) {
|
|
63
|
+
prTitle = info.title || prTitle;
|
|
64
|
+
review = info.reviewDecision || "PENDING";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
branchInfos.push({
|
|
69
|
+
branch: branchName,
|
|
70
|
+
prNumber,
|
|
71
|
+
prTitle,
|
|
72
|
+
reviewEmojiStr: reviewEmoji(review),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
s.stop("Fetched PR info");
|
|
77
|
+
|
|
78
|
+
// Update each PR
|
|
79
|
+
let updated = 0;
|
|
80
|
+
let skipped = 0;
|
|
81
|
+
|
|
82
|
+
for (let targetIdx = 0; targetIdx < branchInfos.length; targetIdx++) {
|
|
83
|
+
const target = branchInfos[targetIdx]!;
|
|
84
|
+
|
|
85
|
+
if (!target.prNumber) {
|
|
86
|
+
p.log.info(`${pc.dim("⏭")} ${target.branch} — no PR, skipping`);
|
|
87
|
+
skipped++;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Build stack visualization for this PR
|
|
92
|
+
const stackViz = buildStackViz(branchInfos, targetIdx);
|
|
93
|
+
|
|
94
|
+
// Get current PR body and replace/append stack section
|
|
95
|
+
const currentBody = await getPrBody(target.prNumber);
|
|
96
|
+
if (currentBody === null) {
|
|
97
|
+
p.log.warn(`Could not fetch body for PR #${target.prNumber}`);
|
|
98
|
+
skipped++;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Remove existing stack section
|
|
103
|
+
let updatedBody = currentBody;
|
|
104
|
+
const stackSectionIdx = updatedBody.indexOf("### 📚 Stacked on");
|
|
105
|
+
if (stackSectionIdx !== -1) {
|
|
106
|
+
updatedBody = updatedBody.slice(0, stackSectionIdx).trimEnd();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Append new stack section
|
|
110
|
+
const newBody = updatedBody ? `${updatedBody}\n\n${stackViz}` : stackViz;
|
|
111
|
+
|
|
112
|
+
// Update PR
|
|
113
|
+
const ok = await updatePrBody(target.prNumber, newBody);
|
|
114
|
+
if (ok) {
|
|
115
|
+
p.log.success(`PR #${target.prNumber} ${target.prTitle}`);
|
|
116
|
+
updated++;
|
|
117
|
+
} else {
|
|
118
|
+
p.log.error(`Failed to update PR #${target.prNumber}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
console.log();
|
|
123
|
+
p.outro(
|
|
124
|
+
pc.green(
|
|
125
|
+
`Done! Updated ${updated} PR(s)${skipped > 0 ? `, skipped ${skipped} without PRs` : ""}`,
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface BranchPrInfo {
|
|
131
|
+
branch: string;
|
|
132
|
+
prNumber: number | null;
|
|
133
|
+
prTitle: string;
|
|
134
|
+
reviewEmojiStr: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function buildStackViz(branches: BranchPrInfo[], targetIndex: number): string {
|
|
138
|
+
const lines: string[] = ["### 📚 Stacked on", ""];
|
|
139
|
+
|
|
140
|
+
if (branches.length === 1) {
|
|
141
|
+
lines.push("- ⚫ **main**");
|
|
142
|
+
return lines.join("\n");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
lines.push("<pre>");
|
|
146
|
+
lines.push("⚫ main");
|
|
147
|
+
lines.push("┃");
|
|
148
|
+
|
|
149
|
+
for (let i = 0; i < branches.length; i++) {
|
|
150
|
+
const info = branches[i]!;
|
|
151
|
+
const isLast = i === branches.length - 1;
|
|
152
|
+
const isTarget = i === targetIndex;
|
|
153
|
+
|
|
154
|
+
// PR link
|
|
155
|
+
let prLink: string;
|
|
156
|
+
if (info.prNumber) {
|
|
157
|
+
prLink = `<a href="https://github.com/beehiiv/swarm/pull/${info.prNumber}">#${info.prNumber}</a>`;
|
|
158
|
+
} else {
|
|
159
|
+
prLink = "(no PR yet)";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// 👈 marker for the current PR
|
|
163
|
+
const marker = isTarget ? " 👈" : "";
|
|
164
|
+
|
|
165
|
+
// Tree character
|
|
166
|
+
const tree = isLast ? "┗━" : "┣━";
|
|
167
|
+
|
|
168
|
+
lines.push(`${tree} ${info.reviewEmojiStr} ${prLink} ${info.prTitle}${marker}`);
|
|
169
|
+
|
|
170
|
+
if (!isLast) {
|
|
171
|
+
lines.push("┃");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
lines.push("</pre>");
|
|
176
|
+
return lines.join("\n");
|
|
177
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// gh-stack — Unified stacked PR manager for squash-merge workflows
|
|
3
|
+
|
|
4
|
+
// Map GH_STACK_NO_COLOR → NO_COLOR so picocolors (and other tools) respect it
|
|
5
|
+
if (process.env.GH_STACK_NO_COLOR) {
|
|
6
|
+
process.env.NO_COLOR = "1";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
import { ensureGitRepo } from "./lib/safety.ts";
|
|
10
|
+
import { setAutoYes } from "./lib/ui.ts";
|
|
11
|
+
|
|
12
|
+
const { version: VERSION } = await import("../package.json");
|
|
13
|
+
|
|
14
|
+
const args = process.argv.slice(2);
|
|
15
|
+
const command = args[0] || "show";
|
|
16
|
+
|
|
17
|
+
// Handle global flags
|
|
18
|
+
if (args.includes("--version") || args.includes("-V")) {
|
|
19
|
+
console.log(`gh-stack v${VERSION}`);
|
|
20
|
+
process.exit(0);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (args.includes("--help") && !args[0]) {
|
|
24
|
+
printHelp();
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Global --yes flag: skip all interactive confirmations (for agents/CI)
|
|
29
|
+
if (args.includes("--yes") || args.includes("-y") || process.env.GH_STACK_YES === "1") {
|
|
30
|
+
setAutoYes(true);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Ensure we're in a git repo for all commands
|
|
34
|
+
await ensureGitRepo();
|
|
35
|
+
|
|
36
|
+
// Route to subcommand — strip global flags from command args
|
|
37
|
+
const commandArgs = args.slice(1).filter((a) => a !== "--yes" && a !== "-y");
|
|
38
|
+
|
|
39
|
+
switch (command) {
|
|
40
|
+
case "show":
|
|
41
|
+
await (await import("./commands/show.ts")).default(commandArgs);
|
|
42
|
+
break;
|
|
43
|
+
|
|
44
|
+
case "init":
|
|
45
|
+
await (await import("./commands/init.ts")).default(commandArgs);
|
|
46
|
+
break;
|
|
47
|
+
|
|
48
|
+
case "add":
|
|
49
|
+
await (await import("./commands/add.ts")).default(commandArgs);
|
|
50
|
+
break;
|
|
51
|
+
|
|
52
|
+
case "remove":
|
|
53
|
+
await (await import("./commands/remove.ts")).default(commandArgs);
|
|
54
|
+
break;
|
|
55
|
+
|
|
56
|
+
case "switch":
|
|
57
|
+
await (await import("./commands/switch.ts")).default(commandArgs);
|
|
58
|
+
break;
|
|
59
|
+
|
|
60
|
+
case "list":
|
|
61
|
+
case "ls":
|
|
62
|
+
await (await import("./commands/list.ts")).default(commandArgs);
|
|
63
|
+
break;
|
|
64
|
+
|
|
65
|
+
case "restack":
|
|
66
|
+
case "rebase":
|
|
67
|
+
await (await import("./commands/restack.ts")).default(commandArgs);
|
|
68
|
+
break;
|
|
69
|
+
|
|
70
|
+
case "sync":
|
|
71
|
+
await (await import("./commands/sync.ts")).default(commandArgs);
|
|
72
|
+
break;
|
|
73
|
+
|
|
74
|
+
case "update-prs":
|
|
75
|
+
await (await import("./commands/update-prs.ts")).default(commandArgs);
|
|
76
|
+
break;
|
|
77
|
+
|
|
78
|
+
case "status":
|
|
79
|
+
await (await import("./commands/status.ts")).default(commandArgs);
|
|
80
|
+
break;
|
|
81
|
+
|
|
82
|
+
case "merge":
|
|
83
|
+
await (await import("./commands/merge.ts")).default(commandArgs);
|
|
84
|
+
break;
|
|
85
|
+
|
|
86
|
+
case "undo":
|
|
87
|
+
await (await import("./commands/undo.ts")).default(commandArgs);
|
|
88
|
+
break;
|
|
89
|
+
|
|
90
|
+
case "archive":
|
|
91
|
+
await (await import("./commands/archive.ts")).default(commandArgs);
|
|
92
|
+
break;
|
|
93
|
+
|
|
94
|
+
case "--help":
|
|
95
|
+
case "help":
|
|
96
|
+
printHelp();
|
|
97
|
+
break;
|
|
98
|
+
|
|
99
|
+
default:
|
|
100
|
+
console.error(`Unknown command: ${command}\n`);
|
|
101
|
+
printHelp();
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function printHelp() {
|
|
106
|
+
console.log(`
|
|
107
|
+
gh-stack v${VERSION} — Stacked PR manager for squash-merge workflows
|
|
108
|
+
|
|
109
|
+
${bold("USAGE")}
|
|
110
|
+
gh-stack <command> [options]
|
|
111
|
+
|
|
112
|
+
${bold("COMMANDS")}
|
|
113
|
+
${green("show")} Display current stack tree ${dim("(default)")}
|
|
114
|
+
${green("list")} List branches with numbers ${dim("(alias: ls)")}
|
|
115
|
+
${green("init")} Create a new stack
|
|
116
|
+
${green("add")} Add a branch to the current stack
|
|
117
|
+
${green("remove")} Remove a branch from the stack
|
|
118
|
+
${green("switch")} Switch branch or stack
|
|
119
|
+
${green("restack")} Rebase children onto updated parents
|
|
120
|
+
${green("sync")} Sync base with main + restack all
|
|
121
|
+
${green("update-prs")} Update PR descriptions with stack viz
|
|
122
|
+
${green("status")} PR dashboard (CI, reviews)
|
|
123
|
+
${green("merge")} Local squash-merge top-down
|
|
124
|
+
${green("undo")} Restore from last snapshot
|
|
125
|
+
${green("archive")} Manage archived stacks
|
|
126
|
+
|
|
127
|
+
${bold("GLOBAL OPTIONS")}
|
|
128
|
+
--yes, -y Skip confirmations ${dim("(for agents/CI)")}
|
|
129
|
+
--help Show help
|
|
130
|
+
--version, -V Show version
|
|
131
|
+
|
|
132
|
+
${bold("ENVIRONMENT")}
|
|
133
|
+
GH_STACK_YES=1 Same as --yes
|
|
134
|
+
GH_STACK_NO_COLOR Disable colored output
|
|
135
|
+
`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function bold(s: string) {
|
|
139
|
+
return process.env.NO_COLOR ? s : `\x1b[1m${s}\x1b[0m`;
|
|
140
|
+
}
|
|
141
|
+
function green(s: string) {
|
|
142
|
+
return process.env.NO_COLOR ? s : `\x1b[32m${s}\x1b[0m`;
|
|
143
|
+
}
|
|
144
|
+
function dim(s: string) {
|
|
145
|
+
return process.env.NO_COLOR ? s : `\x1b[2m${s}\x1b[0m`;
|
|
146
|
+
}
|
package/src/lib/git.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Git command helpers using Bun's shell API
|
|
2
|
+
import { $ } from "bun";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Get the current branch name.
|
|
6
|
+
* Throws if in detached HEAD state.
|
|
7
|
+
*/
|
|
8
|
+
export async function currentBranch(): Promise<string> {
|
|
9
|
+
const result = await $`git rev-parse --abbrev-ref HEAD`.text();
|
|
10
|
+
const branch = result.trim();
|
|
11
|
+
if (branch === "HEAD") {
|
|
12
|
+
throw new Error("Detached HEAD state — please checkout a branch first");
|
|
13
|
+
}
|
|
14
|
+
return branch;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Check if we're inside a git repository.
|
|
19
|
+
*/
|
|
20
|
+
export async function isGitRepo(): Promise<boolean> {
|
|
21
|
+
try {
|
|
22
|
+
await $`git rev-parse --git-dir`.quiet();
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get the path to the .git directory (handles worktrees too).
|
|
31
|
+
*/
|
|
32
|
+
export async function gitDir(): Promise<string> {
|
|
33
|
+
const result = await $`git rev-parse --git-dir`.text();
|
|
34
|
+
return result.trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check if working tree is clean (no uncommitted changes).
|
|
39
|
+
*/
|
|
40
|
+
export async function isCleanWorkingTree(): Promise<boolean> {
|
|
41
|
+
try {
|
|
42
|
+
await $`git diff-index --quiet HEAD --`.quiet();
|
|
43
|
+
return true;
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Check if a rebase is currently in progress.
|
|
51
|
+
*/
|
|
52
|
+
export async function isRebaseInProgress(): Promise<boolean> {
|
|
53
|
+
const dir = await gitDir();
|
|
54
|
+
|
|
55
|
+
// Check if either directory exists by trying to read a known file
|
|
56
|
+
try {
|
|
57
|
+
const stat1 = await Bun.file(`${dir}/rebase-merge/interactive`).exists();
|
|
58
|
+
if (stat1) return true;
|
|
59
|
+
} catch {}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const stat2 = await Bun.file(`${dir}/rebase-apply/rebasing`).exists();
|
|
63
|
+
if (stat2) return true;
|
|
64
|
+
} catch {}
|
|
65
|
+
|
|
66
|
+
// Also check the directories themselves
|
|
67
|
+
const { exitCode: code1 } = await $`test -d ${dir}/rebase-merge`.nothrow().quiet();
|
|
68
|
+
const { exitCode: code2 } = await $`test -d ${dir}/rebase-apply`.nothrow().quiet();
|
|
69
|
+
|
|
70
|
+
return code1 === 0 || code2 === 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Checkout a branch.
|
|
75
|
+
*/
|
|
76
|
+
export async function checkout(branch: string): Promise<void> {
|
|
77
|
+
await $`git checkout ${branch}`.quiet();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Create and checkout a new branch from the current HEAD.
|
|
82
|
+
*/
|
|
83
|
+
export async function createBranch(name: string): Promise<void> {
|
|
84
|
+
await $`git checkout -b ${name}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get the SHA of a ref.
|
|
89
|
+
*/
|
|
90
|
+
export async function revParse(ref: string): Promise<string> {
|
|
91
|
+
const result = await $`git rev-parse ${ref}`.text();
|
|
92
|
+
return result.trim();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get the merge-base of two refs.
|
|
97
|
+
* Returns null if no common ancestor is found.
|
|
98
|
+
*/
|
|
99
|
+
export async function mergeBase(ref1: string, ref2: string): Promise<string | null> {
|
|
100
|
+
try {
|
|
101
|
+
const result = await $`git merge-base ${ref1} ${ref2}`.text();
|
|
102
|
+
return result.trim();
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Check if ref1 is an ancestor of ref2.
|
|
110
|
+
*/
|
|
111
|
+
export async function isAncestor(ancestor: string, descendant: string): Promise<boolean> {
|
|
112
|
+
const { exitCode } = await $`git merge-base --is-ancestor ${ancestor} ${descendant}`
|
|
113
|
+
.nothrow()
|
|
114
|
+
.quiet();
|
|
115
|
+
return exitCode === 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Force-push a branch with lease protection.
|
|
120
|
+
*/
|
|
121
|
+
export async function forcePushWithLease(branch?: string): Promise<boolean> {
|
|
122
|
+
try {
|
|
123
|
+
if (branch) {
|
|
124
|
+
await $`git push --force-with-lease origin ${branch}`;
|
|
125
|
+
} else {
|
|
126
|
+
await $`git push --force-with-lease`;
|
|
127
|
+
}
|
|
128
|
+
return true;
|
|
129
|
+
} catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Check if a branch exists on the remote.
|
|
136
|
+
*/
|
|
137
|
+
export async function remoteBranchExists(branch: string): Promise<boolean> {
|
|
138
|
+
const { exitCode } = await $`git ls-remote --exit-code --heads origin ${branch}`
|
|
139
|
+
.nothrow()
|
|
140
|
+
.quiet();
|
|
141
|
+
return exitCode === 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Create a temporary tag at a specific commit.
|
|
146
|
+
*/
|
|
147
|
+
export async function createTag(name: string, commit: string): Promise<void> {
|
|
148
|
+
await $`git tag -f ${name} ${commit}`.quiet();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Delete tags matching a pattern.
|
|
153
|
+
*/
|
|
154
|
+
export async function deleteTagsMatching(pattern: string): Promise<void> {
|
|
155
|
+
try {
|
|
156
|
+
const result = await $`git tag -l ${pattern}`.text();
|
|
157
|
+
const tags = result.trim().split("\n").filter(Boolean);
|
|
158
|
+
for (const tag of tags) {
|
|
159
|
+
await $`git tag -d ${tag}`.quiet();
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
// No tags to delete — that's fine
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Check if a tag exists.
|
|
168
|
+
*/
|
|
169
|
+
export async function tagExists(name: string): Promise<boolean> {
|
|
170
|
+
const { exitCode } = await $`git rev-parse --verify ${name}`.nothrow().quiet();
|
|
171
|
+
return exitCode === 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Perform a rebase: rebase branch onto newBase, starting from oldBase.
|
|
176
|
+
* Uses `git rebase --onto <newBase> <oldBase> <branch>`.
|
|
177
|
+
* Returns true if successful, false if conflicts occurred.
|
|
178
|
+
*/
|
|
179
|
+
export async function rebaseOnto(
|
|
180
|
+
newBase: string,
|
|
181
|
+
oldBase: string,
|
|
182
|
+
branch: string,
|
|
183
|
+
): Promise<boolean> {
|
|
184
|
+
const { exitCode } = await $`git rebase --onto ${newBase} ${oldBase} ${branch}`.nothrow();
|
|
185
|
+
return exitCode === 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Simple rebase onto a target.
|
|
190
|
+
* Returns true if successful, false if conflicts occurred.
|
|
191
|
+
*/
|
|
192
|
+
export async function rebase(target: string): Promise<boolean> {
|
|
193
|
+
const { exitCode } = await $`git rebase ${target}`.nothrow();
|
|
194
|
+
return exitCode === 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Squash-merge a branch into the current branch.
|
|
199
|
+
* Returns true if successful.
|
|
200
|
+
*/
|
|
201
|
+
export async function mergeSquash(branch: string): Promise<boolean> {
|
|
202
|
+
const { exitCode } = await $`git merge --squash ${branch}`.nothrow();
|
|
203
|
+
return exitCode === 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Commit with a message.
|
|
208
|
+
*/
|
|
209
|
+
export async function commit(message: string): Promise<void> {
|
|
210
|
+
await $`git commit -m ${message}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Fetch origin main.
|
|
215
|
+
*/
|
|
216
|
+
export async function fetchMain(): Promise<void> {
|
|
217
|
+
await $`git fetch origin main`.quiet();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Get count of commits between two refs.
|
|
222
|
+
*/
|
|
223
|
+
export async function commitCount(from: string, to: string): Promise<number> {
|
|
224
|
+
try {
|
|
225
|
+
const result = await $`git rev-list --count ${from}..${to}`.text();
|
|
226
|
+
return parseInt(result.trim(), 10);
|
|
227
|
+
} catch {
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Sanitize a branch name for use in tag names.
|
|
234
|
+
*/
|
|
235
|
+
export function sanitizeBranchForTag(branch: string): string {
|
|
236
|
+
return branch.replace(/[^a-zA-Z0-9-]/g, "_");
|
|
237
|
+
}
|