gh-stack 0.19.4 → 0.19.6
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 +4 -0
- package/package.json +1 -1
- package/src/commands/add.ts +2 -1
- package/src/lib/chain.ts +19 -3
- package/src/lib/git.ts +49 -15
- package/src/lib/paths.ts +1 -0
- package/src/lib/using-gh-stack.md +10 -0
package/README.md
CHANGED
|
@@ -92,6 +92,9 @@ add [branch] [--parent <branch>]
|
|
|
92
92
|
stack — without pushing or opening a PR. Walks local ancestry: if the
|
|
93
93
|
branch sits on top of an existing stack it's adopted into it (parent
|
|
94
94
|
auto-detected); otherwise a new stack is created from the chain.
|
|
95
|
+
A PR that gh-stack already recorded for a branch in git config (in
|
|
96
|
+
another worktree of the repo) is kept, so a stack submitted from a
|
|
97
|
+
worktree can be tracked in the main checkout and merged from there.
|
|
95
98
|
|
|
96
99
|
--parent <branch>: stack it directly on <branch> (which it must
|
|
97
100
|
already be based on), tracking <branch> first if needed. Use this
|
|
@@ -650,6 +653,7 @@ per-stack files plus git-native branch config, rather than a single JSON blob:
|
|
|
650
653
|
deleted/<stack>.json tombstones (recoverable)
|
|
651
654
|
snapshots/<ts>__<stack>.json append-only, retained per-stack
|
|
652
655
|
restack-state.json the last restack/sync/join run: resume point + action log
|
|
656
|
+
push-failure.log full output of the last refused push (a failing pre-push hook, say)
|
|
653
657
|
```
|
|
654
658
|
|
|
655
659
|
A single `active/<stack>.json` looks like:
|
package/package.json
CHANGED
package/src/commands/add.ts
CHANGED
|
@@ -38,7 +38,8 @@ OPTIONS
|
|
|
38
38
|
its stack and has no PR (e.g. a mistaken ${"`"}init${"`"} at trunk).
|
|
39
39
|
|
|
40
40
|
Network-free — nothing is pushed and no PR is opened. Run ${"`"}gh-stack submit${"`"} when
|
|
41
|
-
you're ready to publish.
|
|
41
|
+
you're ready to publish. A PR already recorded for the branch in git config
|
|
42
|
+
(by gh-stack in another worktree of this repo) is kept.
|
|
42
43
|
|
|
43
44
|
EXAMPLES
|
|
44
45
|
gh-stack add Track the current branch
|
package/src/lib/chain.ts
CHANGED
|
@@ -4,9 +4,25 @@
|
|
|
4
4
|
// auto-create/register a stack from a bare set of local branches so the
|
|
5
5
|
// end state is always stack registered + branches pushed + PRs created).
|
|
6
6
|
import * as git from "./git.ts";
|
|
7
|
+
import { allBranchMemberships, type BranchMembership } from "./branch-config.ts";
|
|
7
8
|
import { findStackForBranch, getOrderedBranches, stackBase } from "./metadata.ts";
|
|
8
9
|
import type { StackMetadata, Branch } from "../types.ts";
|
|
9
10
|
|
|
11
|
+
/**
|
|
12
|
+
* A new stack entry for a branch, keeping the PR number and id already
|
|
13
|
+
* recorded for it in git config. Git config is shared by every worktree of a
|
|
14
|
+
* repo, but stack files are not: a branch submitted in one worktree and then
|
|
15
|
+
* tracked in another (the dependency sweep's handoff) would otherwise lose its
|
|
16
|
+
* PR, and `sync` and `merge --base` would not see it merge.
|
|
17
|
+
*/
|
|
18
|
+
function newBranchEntry(parent: string, recorded: BranchMembership | undefined): Branch {
|
|
19
|
+
return {
|
|
20
|
+
parent,
|
|
21
|
+
...(recorded?.pr != null && { pr: recorded.pr }),
|
|
22
|
+
...(recorded?.id && { id: recorded.id }),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
10
26
|
/**
|
|
11
27
|
* Detect a chain of branches from trunk to the current branch.
|
|
12
28
|
*
|
|
@@ -107,6 +123,7 @@ export async function resolveOrCreateStack(
|
|
|
107
123
|
trunk: string,
|
|
108
124
|
): Promise<StackResolution> {
|
|
109
125
|
const fullChain = await detectBranchChain(branch, trunk); // trunk→current
|
|
126
|
+
const recorded = await allBranchMemberships();
|
|
110
127
|
|
|
111
128
|
// Walk from just below `branch` downward; the first tracked ancestor is the
|
|
112
129
|
// attachment point. Everything above it (up to `branch`) is the untracked
|
|
@@ -143,7 +160,7 @@ export async function resolveOrCreateStack(
|
|
|
143
160
|
}
|
|
144
161
|
|
|
145
162
|
if (stack.branches[name]) continue; // already tracked — leave as-is
|
|
146
|
-
stack.branches[name] =
|
|
163
|
+
stack.branches[name] = newBranchEntry(parent, recorded.get(name));
|
|
147
164
|
stack.last_branch = name;
|
|
148
165
|
addedBranches.push(name);
|
|
149
166
|
}
|
|
@@ -177,8 +194,7 @@ export async function resolveOrCreateStack(
|
|
|
177
194
|
const name = fullChain[i]!;
|
|
178
195
|
if (stack.branches[name]) continue;
|
|
179
196
|
const parent = i === 0 ? trunk : fullChain[i - 1]!;
|
|
180
|
-
|
|
181
|
-
stack.branches[name] = branchData;
|
|
197
|
+
stack.branches[name] = newBranchEntry(parent, recorded.get(name));
|
|
182
198
|
stack.last_branch = name;
|
|
183
199
|
addedBranches.push(name);
|
|
184
200
|
}
|
package/src/lib/git.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Git command helpers using Bun's shell API
|
|
2
2
|
import { $ } from "bun";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
|
+
import { mkdir } from "node:fs/promises";
|
|
4
5
|
|
|
5
6
|
export const STACK_SYNC_TAG_PREFIX = "stack-sync-base";
|
|
6
7
|
export const STACK_SYNC_TAG_GLOB = `${STACK_SYNC_TAG_PREFIX}-*`;
|
|
@@ -153,9 +154,18 @@ export type PushResult =
|
|
|
153
154
|
* moved since the last fetch. `rejected`: anything else.
|
|
154
155
|
*/
|
|
155
156
|
reason: "not-integrated" | "stale-lease" | "rejected";
|
|
156
|
-
|
|
157
|
+
/**
|
|
158
|
+
* git's stdout and stderr together. A local pre-push hook's output lands
|
|
159
|
+
* on either, depending on the git version (stdout as of git 2.56).
|
|
160
|
+
*/
|
|
161
|
+
output: string;
|
|
162
|
+
/** Where a `rejected` push's full output was saved, when that worked. */
|
|
163
|
+
logFile?: string;
|
|
157
164
|
};
|
|
158
165
|
|
|
166
|
+
/** How many lines of a failed push's output `pushFailureHint` shows. */
|
|
167
|
+
const PUSH_OUTPUT_TAIL = 20;
|
|
168
|
+
|
|
159
169
|
/**
|
|
160
170
|
* The one way gh-stack force-pushes a branch (restack, sync, merge's advance,
|
|
161
171
|
* submit, join). Sets upstream, and works whether or not the branch exists on
|
|
@@ -179,17 +189,35 @@ export async function forcePushWithLease(
|
|
|
179
189
|
const lease = opts.expect
|
|
180
190
|
? `--force-with-lease=refs/heads/${branch}:${opts.expect}`
|
|
181
191
|
: "--force-with-lease";
|
|
182
|
-
const { exitCode,
|
|
183
|
-
|
|
184
|
-
.quiet();
|
|
192
|
+
const { exitCode, stdout } =
|
|
193
|
+
await $`git push -u ${lease} --force-if-includes origin ${branch} 2>&1`.nothrow().quiet();
|
|
185
194
|
if (exitCode === 0) return { ok: true };
|
|
186
|
-
const
|
|
187
|
-
const reason =
|
|
195
|
+
const output = stdout.toString();
|
|
196
|
+
const reason = output.includes("remote ref updated since checkout")
|
|
188
197
|
? "not-integrated"
|
|
189
|
-
:
|
|
198
|
+
: output.includes("stale info")
|
|
190
199
|
? "stale-lease"
|
|
191
200
|
: "rejected";
|
|
192
|
-
return { ok: false, reason,
|
|
201
|
+
if (reason !== "rejected") return { ok: false, reason, output };
|
|
202
|
+
return { ok: false, reason, output, logFile: await savePushFailure(output) };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Save a failed push's full output to `.git/.gh-stack/push-failure.log`. A
|
|
207
|
+
* failing pre-push hook (a test suite, say) can print a thousand lines, so
|
|
208
|
+
* `pushFailureHint` shows only the end and points here for the rest. Best
|
|
209
|
+
* effort: a failed write returns undefined.
|
|
210
|
+
*/
|
|
211
|
+
async function savePushFailure(text: string): Promise<string | undefined> {
|
|
212
|
+
try {
|
|
213
|
+
const dir = `${await gitDir()}/.gh-stack`;
|
|
214
|
+
await mkdir(dir, { recursive: true });
|
|
215
|
+
const file = `${dir}/push-failure.log`;
|
|
216
|
+
await Bun.write(file, text);
|
|
217
|
+
return file;
|
|
218
|
+
} catch {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
193
221
|
}
|
|
194
222
|
|
|
195
223
|
/** Why a force-push was refused, and what to do, in gh-stack terms (plain lines). */
|
|
@@ -208,13 +236,19 @@ export function pushFailureHint(branch: string, result: PushResult): string[] {
|
|
|
208
236
|
`origin/${branch} moved since your last fetch, so the lease refused.`,
|
|
209
237
|
`Look at what's there first: git fetch origin ${branch} && git log ${branch}..origin/${branch}`,
|
|
210
238
|
];
|
|
211
|
-
default:
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
];
|
|
239
|
+
default: {
|
|
240
|
+
// Show the end of git's output, not just its "failed to push" line: a
|
|
241
|
+
// local pre-push hook prints its reason above that line, and a remote
|
|
242
|
+
// rejection names the rule that refused the push.
|
|
243
|
+
const lines = result.output.split("\n").filter((l) => l.trim() !== "");
|
|
244
|
+
const tail = lines.slice(-PUSH_OUTPUT_TAIL);
|
|
245
|
+
const hint = ["git push failed. The end of its output (a pre-push hook prints here too):"];
|
|
246
|
+
hint.push(...tail.map((l) => ` ${l.trimEnd()}`));
|
|
247
|
+
if (result.logFile && lines.length > tail.length) {
|
|
248
|
+
hint.push(`Full output (${lines.length} lines): ${result.logFile}`);
|
|
249
|
+
}
|
|
250
|
+
return hint;
|
|
251
|
+
}
|
|
218
252
|
}
|
|
219
253
|
}
|
|
220
254
|
|
package/src/lib/paths.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// deleted/<stack>.json tombstones (recoverable)
|
|
8
8
|
// snapshots/<ts>__<stack>.json append-only pre-operation branch tips
|
|
9
9
|
// restack-state.json last restack/sync/join run: resume point + action log
|
|
10
|
+
// push-failure.log full output of the last rejected push (git.forcePushWithLease)
|
|
10
11
|
//
|
|
11
12
|
// Everything lives inside `.git/` and is never committed — gh-stack is a
|
|
12
13
|
// single-developer tool, so there's no need to share metadata across clones.
|
|
@@ -129,6 +129,11 @@ This resolves the catch-22 where `sync`/`restack` said "not in any stack" for a
|
|
|
129
129
|
tip you hadn't submitted yet. (`submit` still auto-tracks too — `add` is just the
|
|
130
130
|
no-publish path.)
|
|
131
131
|
|
|
132
|
+
**Handing a stack over from another worktree.** Stack files are per worktree,
|
|
133
|
+
but the PR numbers gh-stack records in git config are shared. So after
|
|
134
|
+
submitting from a worktree, `gh-stack add <top-branch>` in the main checkout
|
|
135
|
+
tracks the whole chain _with_ its PRs, and `sync`/`merge --base` work there.
|
|
136
|
+
|
|
132
137
|
**Splice an existing branch/PR into a stack — `gh-stack join`.** For a branch
|
|
133
138
|
that should have been stacked but was cut from main on its own (often with its
|
|
134
139
|
own PR already open):
|
|
@@ -296,6 +301,11 @@ it (its PR would be diffed against the parent's old history); the run lists it
|
|
|
296
301
|
as held and exits 1 until `--resume` gets the parent pushed. `merge` and `join`
|
|
297
302
|
refuse to run while a restack/sync is pending.
|
|
298
303
|
|
|
304
|
+
**Why did the push fail?** gh-stack prints the last lines of `git push`'s
|
|
305
|
+
output, which includes a pre-push hook's own messages, and saves all of it to
|
|
306
|
+
`.git/.gh-stack/push-failure.log`. Fix the cause (a failing check, say), then
|
|
307
|
+
`--resume`.
|
|
308
|
+
|
|
299
309
|
**"Origin has commits that the local branches don't"** (restack/sync exit 1
|
|
300
310
|
before changing anything): a branch's copy on origin has commits it never had,
|
|
301
311
|
often the squash commit a `merge --collapse` added on GitHub. Run the
|