gh-stack 0.18.0 → 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.
- package/README.md +232 -16
- package/package.json +9 -8
- package/src/commands/config.ts +133 -0
- package/src/commands/join.ts +877 -0
- package/src/commands/learn.ts +295 -36
- package/src/commands/merge.ts +263 -5
- package/src/commands/restack.ts +943 -237
- package/src/commands/submit.ts +257 -51
- package/src/commands/sync.ts +140 -154
- package/src/commands/undo.ts +11 -0
- package/src/commands/update-prs.ts +11 -6
- package/src/index.ts +155 -125
- package/src/lib/advance.ts +29 -4
- package/src/lib/collapse-guard.ts +325 -0
- package/src/lib/config.ts +259 -0
- package/src/lib/git.ts +84 -22
- package/src/lib/github.ts +63 -5
- package/src/lib/metadata.ts +34 -7
- package/src/lib/output.ts +29 -8
- package/src/lib/paths.ts +1 -1
- package/src/lib/remote-guard.ts +281 -0
- package/src/lib/run-log.ts +117 -0
- package/src/lib/using-gh-stack.md +124 -4
- package/src/types.ts +62 -4
package/src/lib/git.ts
CHANGED
|
@@ -143,30 +143,92 @@ export async function isAncestor(ancestor: string, descendant: string): Promise<
|
|
|
143
143
|
return exitCode === 0;
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
146
|
+
export type PushResult =
|
|
147
|
+
| { ok: true }
|
|
148
|
+
| {
|
|
149
|
+
ok: false;
|
|
150
|
+
/**
|
|
151
|
+
* `not-integrated`: origin's tip was fetched but isn't in the local
|
|
152
|
+
* branch's history (--force-if-includes refused). `stale-lease`: origin
|
|
153
|
+
* moved since the last fetch. `rejected`: anything else.
|
|
154
|
+
*/
|
|
155
|
+
reason: "not-integrated" | "stale-lease" | "rejected";
|
|
156
|
+
stderr: string;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The one way gh-stack force-pushes a branch (restack, sync, merge's advance,
|
|
161
|
+
* submit, join). Sets upstream, and works whether or not the branch exists on
|
|
162
|
+
* origin yet.
|
|
163
|
+
*
|
|
164
|
+
* By default it's `--force-with-lease --force-if-includes`: the lease refuses
|
|
165
|
+
* if origin moved since the last fetch, and --force-if-includes refuses if the
|
|
166
|
+
* fetched tip was never brought into the local branch. That second check is
|
|
167
|
+
* #28's root cause (a collapse's squash commit fetched, never pulled, then
|
|
168
|
+
* overwritten), covered here for every command.
|
|
169
|
+
*
|
|
170
|
+
* `expect` gives the lease an explicit remote sha instead. Use it only for a
|
|
171
|
+
* tip whose content was checked first (see lib/remote-guard.ts): git makes
|
|
172
|
+
* --force-if-includes a no-op then, and the push still fails if origin moved
|
|
173
|
+
* off that sha.
|
|
174
|
+
*/
|
|
175
|
+
export async function forcePushWithLease(
|
|
176
|
+
branch: string,
|
|
177
|
+
opts: { expect?: string } = {},
|
|
178
|
+
): Promise<PushResult> {
|
|
179
|
+
const lease = opts.expect
|
|
180
|
+
? `--force-with-lease=refs/heads/${branch}:${opts.expect}`
|
|
181
|
+
: "--force-with-lease";
|
|
182
|
+
const { exitCode, stderr } = await $`git push -u ${lease} --force-if-includes origin ${branch}`
|
|
183
|
+
.nothrow()
|
|
184
|
+
.quiet();
|
|
185
|
+
if (exitCode === 0) return { ok: true };
|
|
186
|
+
const text = stderr.toString();
|
|
187
|
+
const reason = text.includes("remote ref updated since checkout")
|
|
188
|
+
? "not-integrated"
|
|
189
|
+
: text.includes("stale info")
|
|
190
|
+
? "stale-lease"
|
|
191
|
+
: "rejected";
|
|
192
|
+
return { ok: false, reason, stderr: text };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Why a force-push was refused, and what to do, in gh-stack terms (plain lines). */
|
|
196
|
+
export function pushFailureHint(branch: string, result: PushResult): string[] {
|
|
197
|
+
if (result.ok) return [];
|
|
198
|
+
switch (result.reason) {
|
|
199
|
+
case "not-integrated":
|
|
200
|
+
return [
|
|
201
|
+
`origin/${branch} has commits you fetched but never brought into local ${branch}, so git refused to overwrite them (--force-if-includes).`,
|
|
202
|
+
`That's how a collapsed PR's squash commit gets lost (#28). See them: git log ${branch}..origin/${branch}`,
|
|
203
|
+
`Bring them in (git pull --rebase origin ${branch}, or cherry-pick), then retry.`,
|
|
204
|
+
`To drop them on purpose: gh-stack restack --overwrite-remote`,
|
|
205
|
+
];
|
|
206
|
+
case "stale-lease":
|
|
207
|
+
return [
|
|
208
|
+
`origin/${branch} moved since your last fetch, so the lease refused.`,
|
|
209
|
+
`Look at what's there first: git fetch origin ${branch} && git log ${branch}..origin/${branch}`,
|
|
210
|
+
];
|
|
211
|
+
default:
|
|
212
|
+
return [
|
|
213
|
+
result.stderr
|
|
214
|
+
.trim()
|
|
215
|
+
.split("\n")
|
|
216
|
+
.find((l) => l.includes("rejected") || l.startsWith("error")) ?? result.stderr.trim(),
|
|
217
|
+
];
|
|
159
218
|
}
|
|
160
219
|
}
|
|
161
220
|
|
|
162
221
|
/**
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
222
|
+
* Fetch commits from origin without moving any ref: no remote-tracking ref
|
|
223
|
+
* update (`--refmap=`) and no FETCH_HEAD. The tracking refs are what
|
|
224
|
+
* `--force-with-lease` and `--force-if-includes` compare against, so a check
|
|
225
|
+
* that needs origin's objects must never advance them.
|
|
167
226
|
*/
|
|
168
|
-
export async function
|
|
169
|
-
|
|
227
|
+
export async function fetchObjectsOnly(refs: string[]): Promise<boolean> {
|
|
228
|
+
if (refs.length === 0) return true;
|
|
229
|
+
const { exitCode } = await $`git fetch --quiet --no-write-fetch-head --refmap= origin ${refs}`
|
|
230
|
+
.nothrow()
|
|
231
|
+
.quiet();
|
|
170
232
|
return exitCode === 0;
|
|
171
233
|
}
|
|
172
234
|
|
|
@@ -264,10 +326,10 @@ export async function deleteTagsMatching(pattern: string): Promise<void> {
|
|
|
264
326
|
}
|
|
265
327
|
|
|
266
328
|
/**
|
|
267
|
-
*
|
|
329
|
+
* Whether a ref (branch, tag, remote-tracking ref, or any revision) resolves.
|
|
268
330
|
*/
|
|
269
|
-
export async function
|
|
270
|
-
const { exitCode } = await $`git rev-parse --verify ${
|
|
331
|
+
export async function refExists(ref: string): Promise<boolean> {
|
|
332
|
+
const { exitCode } = await $`git rev-parse --verify --quiet ${ref}`.nothrow().quiet();
|
|
271
333
|
return exitCode === 0;
|
|
272
334
|
}
|
|
273
335
|
|
package/src/lib/github.ts
CHANGED
|
@@ -203,17 +203,75 @@ export async function getPrBody(prNumber: number): Promise<string | null> {
|
|
|
203
203
|
* squash-merge lands on this branch. Compare `headRefOid` against the live ref
|
|
204
204
|
* (`getBranchRefSha`) to detect that staleness — see `merge.ts`.
|
|
205
205
|
*/
|
|
206
|
-
|
|
206
|
+
/** A PR's merge state, plus what merging it left behind. */
|
|
207
|
+
export interface PrMergeResult {
|
|
207
208
|
state: string;
|
|
208
209
|
mergeable: string;
|
|
209
210
|
mergeStateStatus: string;
|
|
210
211
|
headRefName: string;
|
|
212
|
+
/** The PR's head; for a merged PR, its head when it merged. */
|
|
211
213
|
headRefOid: string;
|
|
212
|
-
|
|
214
|
+
/** The branch it targets (or merged into). */
|
|
215
|
+
baseRefName: string;
|
|
216
|
+
/** The commit merging it created on its base (for a squash merge, the squash commit). */
|
|
217
|
+
mergeCommit: string | null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export async function getPrMergeState(prNumber: number): Promise<PrMergeResult | null> {
|
|
213
221
|
try {
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
222
|
+
const data = JSON.parse(
|
|
223
|
+
(
|
|
224
|
+
await $`gh pr view ${prNumber} --json state,mergeable,mergeStateStatus,headRefName,headRefOid,baseRefName,mergeCommit`.text()
|
|
225
|
+
).trim(),
|
|
226
|
+
);
|
|
227
|
+
return { ...data, mergeCommit: data.mergeCommit?.oid ?? null };
|
|
228
|
+
} catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** A commit as a patch (`git format-patch` style), from the GitHub API. Null if unavailable. */
|
|
234
|
+
export async function getCommitPatch(sha: string): Promise<string | null> {
|
|
235
|
+
const nwo = await getRepoNwo();
|
|
236
|
+
if (!nwo) return null;
|
|
237
|
+
const { stdout, exitCode } =
|
|
238
|
+
await $`gh api -H ${"Accept: application/vnd.github.patch"} repos/${nwo}/commits/${sha}`
|
|
239
|
+
.nothrow()
|
|
240
|
+
.quiet();
|
|
241
|
+
const text = stdout.toString();
|
|
242
|
+
return exitCode === 0 && text.trim() ? text : null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The files a commit changed (path, status, blob sha after the commit), from
|
|
247
|
+
* the GitHub API. Null if unavailable, or if GitHub truncated the list (it
|
|
248
|
+
* returns at most 300 files): a partial list can't prove anything.
|
|
249
|
+
*/
|
|
250
|
+
export async function getCommitFiles(
|
|
251
|
+
sha: string,
|
|
252
|
+
): Promise<
|
|
253
|
+
{ filename: string; status: string; sha: string | null; before?: string | null }[] | null
|
|
254
|
+
> {
|
|
255
|
+
const nwo = await getRepoNwo();
|
|
256
|
+
if (!nwo) return null;
|
|
257
|
+
const { stdout, exitCode } =
|
|
258
|
+
await $`gh api repos/${nwo}/commits/${sha} --jq ${"[.files[] | {filename, status, sha}]"}`
|
|
259
|
+
.nothrow()
|
|
260
|
+
.quiet();
|
|
261
|
+
if (exitCode !== 0) return null;
|
|
262
|
+
try {
|
|
263
|
+
const files: { filename: string; status: string; sha: string | null }[] = JSON.parse(
|
|
264
|
+
stdout.toString(),
|
|
265
|
+
);
|
|
266
|
+
if (!Array.isArray(files) || files.length === 0 || files.length >= 300) return null;
|
|
267
|
+
// The API gives no blob from before the commit, except that an added
|
|
268
|
+
// file had none; a removed file has no blob after it.
|
|
269
|
+
return files.map((f) => ({
|
|
270
|
+
filename: f.filename,
|
|
271
|
+
status: f.status,
|
|
272
|
+
sha: f.status === "removed" ? null : f.sha,
|
|
273
|
+
before: f.status === "added" ? null : undefined,
|
|
274
|
+
}));
|
|
217
275
|
} catch {
|
|
218
276
|
return null;
|
|
219
277
|
}
|
package/src/lib/metadata.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// • A stack can never silently vanish — on write, a stale stack file is moved
|
|
12
12
|
// to `deleted/` (a recoverable tombstone), never just unlinked.
|
|
13
13
|
import type { StackMetadata, Stack, Branch, RestackState } from "../types.ts";
|
|
14
|
+
import { RUN_STATE_VERSION } from "../types.ts";
|
|
14
15
|
import { mkdir, readdir, rename, unlink, stat } from "node:fs/promises";
|
|
15
16
|
import {
|
|
16
17
|
ghStackDir,
|
|
@@ -605,7 +606,7 @@ export function getOrderedBranches(stack: Stack): string[] {
|
|
|
605
606
|
*/
|
|
606
607
|
export function getChildren(stack: Stack, branchName: string): string[] {
|
|
607
608
|
return Object.entries(stack.branches)
|
|
608
|
-
.filter(([
|
|
609
|
+
.filter(([, b]) => b.parent === branchName)
|
|
609
610
|
.map(([name]) => name);
|
|
610
611
|
}
|
|
611
612
|
|
|
@@ -655,23 +656,49 @@ export async function updateLastBranch(
|
|
|
655
656
|
// ── Restack state (for --resume) ──
|
|
656
657
|
|
|
657
658
|
/**
|
|
658
|
-
* Save
|
|
659
|
+
* Save a run's state (resume point + action log). Written after every step, so
|
|
660
|
+
* it goes through a temp file and a rename: a kill mid-write leaves the
|
|
661
|
+
* previous state, never a truncated file.
|
|
659
662
|
*/
|
|
660
663
|
export async function saveRestackState(state: RestackState): Promise<void> {
|
|
661
664
|
await ensureDirs();
|
|
662
|
-
|
|
665
|
+
const path = await restackStateFile();
|
|
666
|
+
const tmp = `${path}.tmp`;
|
|
667
|
+
await Bun.write(tmp, JSON.stringify(state, null, 2) + "\n");
|
|
668
|
+
await rename(tmp, path);
|
|
663
669
|
}
|
|
664
670
|
|
|
665
671
|
/**
|
|
666
|
-
* Load
|
|
667
|
-
*
|
|
672
|
+
* Load the saved run state. Returns null if there is none. A file that can't
|
|
673
|
+
* be parsed, or was written by another version of gh-stack, throws
|
|
674
|
+
* InvalidRunStateError: it is never guessed at or upgraded.
|
|
668
675
|
*/
|
|
669
676
|
export async function loadRestackState(): Promise<RestackState | null> {
|
|
670
|
-
const
|
|
677
|
+
const path = await restackStateFile();
|
|
678
|
+
const file = Bun.file(path);
|
|
671
679
|
if (!(await file.exists())) {
|
|
672
680
|
return null;
|
|
673
681
|
}
|
|
674
|
-
|
|
682
|
+
let raw: Partial<RestackState>;
|
|
683
|
+
try {
|
|
684
|
+
raw = await file.json();
|
|
685
|
+
} catch {
|
|
686
|
+
throw new InvalidRunStateError(path, "it isn't valid JSON");
|
|
687
|
+
}
|
|
688
|
+
if (raw?.version !== RUN_STATE_VERSION) {
|
|
689
|
+
throw new InvalidRunStateError(path, "it was written by an older gh-stack");
|
|
690
|
+
}
|
|
691
|
+
return raw as RestackState;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** The saved run state exists but can't be used (unreadable, or another version's shape). */
|
|
695
|
+
export class InvalidRunStateError extends Error {
|
|
696
|
+
constructor(
|
|
697
|
+
readonly path: string,
|
|
698
|
+
readonly reason: string,
|
|
699
|
+
) {
|
|
700
|
+
super(`The saved run state at ${path} can't be used: ${reason}.`);
|
|
701
|
+
}
|
|
675
702
|
}
|
|
676
703
|
|
|
677
704
|
/**
|
package/src/lib/output.ts
CHANGED
|
@@ -15,11 +15,32 @@
|
|
|
15
15
|
import * as clack from "@clack/prompts";
|
|
16
16
|
|
|
17
17
|
let _plain = false;
|
|
18
|
+
let stderrOnly = false;
|
|
18
19
|
|
|
19
20
|
export function setPlain(enabled: boolean): void {
|
|
20
21
|
_plain = enabled;
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* `--json` mode: plain output, all of it on stderr, so stdout carries only the
|
|
26
|
+
* JSON document the command writes itself.
|
|
27
|
+
*/
|
|
28
|
+
export function setStderrOnly(): void {
|
|
29
|
+
_plain = true;
|
|
30
|
+
stderrOnly = true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Plain-mode line: stdout normally, stderr in --json mode. */
|
|
34
|
+
function say(line: string): void {
|
|
35
|
+
if (stderrOnly) console.error(line);
|
|
36
|
+
else console.log(line);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A raw line of output (tree drawings, spacing), routed like the log calls. */
|
|
40
|
+
export function print(line = ""): void {
|
|
41
|
+
say(_plain ? strip(line) : line);
|
|
42
|
+
}
|
|
43
|
+
|
|
23
44
|
export function isPlain(): boolean {
|
|
24
45
|
return _plain;
|
|
25
46
|
}
|
|
@@ -65,7 +86,7 @@ export const select: typeof clack.select = (opts) => {
|
|
|
65
86
|
// ── Banner / error ──
|
|
66
87
|
export function intro(message?: string): void {
|
|
67
88
|
if (_plain) {
|
|
68
|
-
if (message)
|
|
89
|
+
if (message) say(strip(message));
|
|
69
90
|
} else {
|
|
70
91
|
clack.intro(message);
|
|
71
92
|
}
|
|
@@ -73,7 +94,7 @@ export function intro(message?: string): void {
|
|
|
73
94
|
|
|
74
95
|
export function outro(message?: string): void {
|
|
75
96
|
if (_plain) {
|
|
76
|
-
if (message)
|
|
97
|
+
if (message) say(strip(message));
|
|
77
98
|
} else {
|
|
78
99
|
clack.outro(message);
|
|
79
100
|
}
|
|
@@ -89,11 +110,11 @@ export function cancel(message?: string): void {
|
|
|
89
110
|
|
|
90
111
|
// ── log.* ──
|
|
91
112
|
export const log = {
|
|
92
|
-
info: (msg: string) => (_plain ?
|
|
93
|
-
success: (msg: string) => (_plain ?
|
|
113
|
+
info: (msg: string) => (_plain ? say(strip(msg)) : clack.log.info(msg)),
|
|
114
|
+
success: (msg: string) => (_plain ? say(strip(msg)) : clack.log.success(msg)),
|
|
94
115
|
warn: (msg: string) => (_plain ? console.warn(strip(msg)) : clack.log.warn(msg)),
|
|
95
116
|
error: (msg: string) => (_plain ? console.error(strip(msg)) : clack.log.error(msg)),
|
|
96
|
-
step: (msg: string) => (_plain ?
|
|
117
|
+
step: (msg: string) => (_plain ? say(strip(msg)) : clack.log.step(msg)),
|
|
97
118
|
};
|
|
98
119
|
|
|
99
120
|
// ── Spinner ──
|
|
@@ -107,13 +128,13 @@ export function spinner(): Spinner {
|
|
|
107
128
|
if (!_plain) return clack.spinner() as Spinner;
|
|
108
129
|
return {
|
|
109
130
|
start: (msg) => {
|
|
110
|
-
if (msg)
|
|
131
|
+
if (msg) say(strip(msg));
|
|
111
132
|
},
|
|
112
133
|
stop: (msg) => {
|
|
113
|
-
if (msg)
|
|
134
|
+
if (msg) say(strip(msg));
|
|
114
135
|
},
|
|
115
136
|
message: (msg) => {
|
|
116
|
-
if (msg)
|
|
137
|
+
if (msg) say(strip(msg));
|
|
117
138
|
},
|
|
118
139
|
};
|
|
119
140
|
}
|
package/src/lib/paths.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// archived/<stack>.json merged/closed stacks
|
|
7
7
|
// deleted/<stack>.json tombstones (recoverable)
|
|
8
8
|
// snapshots/<ts>__<stack>.json append-only pre-operation branch tips
|
|
9
|
-
// restack-state.json
|
|
9
|
+
// restack-state.json last restack/sync/join run: resume point + action log
|
|
10
10
|
//
|
|
11
11
|
// Everything lives inside `.git/` and is never committed — gh-stack is a
|
|
12
12
|
// single-developer tool, so there's no need to share metadata across clones.
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Before a run rewrites branches, make sure origin doesn't hold content the
|
|
2
|
+
// local branches lack (#28).
|
|
3
|
+
//
|
|
4
|
+
// The incident: `merge --collapse` squash-merged PR2 into PR1's branch on
|
|
5
|
+
// GitHub. The local PR1 never pulled that squash commit, a `sync` rebased the
|
|
6
|
+
// local PR1 and force-pushed it, and the base PR silently lost every child's
|
|
7
|
+
// content. `--force-with-lease` doesn't protect against this once the
|
|
8
|
+
// remote-tracking ref has been fetched.
|
|
9
|
+
//
|
|
10
|
+
// The hard part is not crying wolf. After a `--no-push` restack, or a run
|
|
11
|
+
// interrupted before its pushes, origin legitimately holds the *pre-rewrite*
|
|
12
|
+
// versions of local commits. Those aren't new: local had them and rewrote them
|
|
13
|
+
// on purpose. So a commit on origin counts as new content only if
|
|
14
|
+
// 1. it was never in the local branch's history (its reflog, or a tip a
|
|
15
|
+
// snapshot recorded), and
|
|
16
|
+
// 2. it isn't patch-equivalent to a local commit.
|
|
17
|
+
import { $ } from "bun";
|
|
18
|
+
import pc from "picocolors";
|
|
19
|
+
import * as p from "./output.ts";
|
|
20
|
+
import * as git from "./git.ts";
|
|
21
|
+
import { isAutoYes } from "./ui.ts";
|
|
22
|
+
import type { StackMetadata } from "../types.ts";
|
|
23
|
+
|
|
24
|
+
/** A branch whose origin copy differs but couldn't be checked. */
|
|
25
|
+
export interface Unverified {
|
|
26
|
+
branch: string;
|
|
27
|
+
remoteSha: string;
|
|
28
|
+
reason: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RemoteOnlyContent {
|
|
32
|
+
branch: string;
|
|
33
|
+
localSha: string;
|
|
34
|
+
remoteSha: string;
|
|
35
|
+
/** "sha subject", oldest first. */
|
|
36
|
+
commits: string[];
|
|
37
|
+
/** Local is simply behind origin (a fast-forward brings the commits in). */
|
|
38
|
+
behind: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** How many reflog entries count as "versions local already had". */
|
|
42
|
+
const REFLOG_DEPTH = 300;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* For each branch with a copy on origin that differs from local, the commits
|
|
46
|
+
* origin has that are new content (see the module comment). `cleared` maps the
|
|
47
|
+
* branches whose origin copy differs but holds nothing new to the origin tip
|
|
48
|
+
* that was checked. `unverified` lists branches whose origin copy couldn't be
|
|
49
|
+
* checked (not fetched, or git failed): never cleared, the caller fails closed.
|
|
50
|
+
*/
|
|
51
|
+
export async function findRemoteOnlyContent(
|
|
52
|
+
meta: StackMetadata,
|
|
53
|
+
branches: string[],
|
|
54
|
+
): Promise<{
|
|
55
|
+
found: RemoteOnlyContent[];
|
|
56
|
+
cleared: Map<string, string>;
|
|
57
|
+
unverified: Unverified[];
|
|
58
|
+
}> {
|
|
59
|
+
const found: RemoteOnlyContent[] = [];
|
|
60
|
+
const cleared = new Map<string, string>();
|
|
61
|
+
const unverified: Unverified[] = [];
|
|
62
|
+
const heads = await git.remoteHeads();
|
|
63
|
+
const trunk = await git.trunkBranch();
|
|
64
|
+
|
|
65
|
+
const targets: { branch: string; local: string; remote: string }[] = [];
|
|
66
|
+
for (const branch of new Set(branches)) {
|
|
67
|
+
if (branch === trunk || branch === "main" || branch === "master") continue;
|
|
68
|
+
const remote = heads.get(branch);
|
|
69
|
+
if (!remote || !(await git.localBranchExists(branch))) continue;
|
|
70
|
+
const local = await git.revParse(branch);
|
|
71
|
+
if (remote !== local) targets.push({ branch, local, remote });
|
|
72
|
+
}
|
|
73
|
+
if (targets.length === 0) return { found, cleared, unverified };
|
|
74
|
+
|
|
75
|
+
// Origin's trunk too: commits that came from it (GitHub's "Update branch"
|
|
76
|
+
// merges main into a PR branch) aren't the user's to lose.
|
|
77
|
+
const trunkTip = heads.get(trunk);
|
|
78
|
+
await git.fetchObjectsOnly([
|
|
79
|
+
...targets.map((t) => `refs/heads/${t.branch}`),
|
|
80
|
+
...(trunkTip ? [`refs/heads/${trunk}`] : []),
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
for (const { branch, local, remote } of targets) {
|
|
84
|
+
if (!(await git.refExists(`${remote}^{commit}`))) {
|
|
85
|
+
unverified.push({
|
|
86
|
+
branch,
|
|
87
|
+
remoteSha: remote,
|
|
88
|
+
reason: `origin/${branch} couldn't be fetched`,
|
|
89
|
+
});
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (await git.isAncestor(remote, local)) {
|
|
93
|
+
cleared.set(branch, remote); // local already has all of it
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const known = [
|
|
98
|
+
local,
|
|
99
|
+
...(await reflogTips(branch)),
|
|
100
|
+
...snapshotTips(meta, branch),
|
|
101
|
+
...(trunkTip ? [trunkTip] : []),
|
|
102
|
+
];
|
|
103
|
+
const candidates = await revListLines([
|
|
104
|
+
"--ignore-missing",
|
|
105
|
+
"--reverse",
|
|
106
|
+
"--no-merges",
|
|
107
|
+
remote,
|
|
108
|
+
"--not",
|
|
109
|
+
...new Set(known),
|
|
110
|
+
]);
|
|
111
|
+
const unmatched = await revListLines([
|
|
112
|
+
"--cherry-pick",
|
|
113
|
+
"--right-only",
|
|
114
|
+
"--no-merges",
|
|
115
|
+
`${local}...${remote}`,
|
|
116
|
+
]);
|
|
117
|
+
if (candidates === null || unmatched === null) {
|
|
118
|
+
// No verdict is not a clean verdict: this branch stays unchecked.
|
|
119
|
+
unverified.push({ branch, remoteSha: remote, reason: "git rev-list failed comparing it" });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const fresh = candidates.filter((sha) => unmatched.includes(sha));
|
|
123
|
+
if (fresh.length === 0) {
|
|
124
|
+
cleared.set(branch, remote);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
found.push({
|
|
128
|
+
branch,
|
|
129
|
+
localSha: local,
|
|
130
|
+
remoteSha: remote,
|
|
131
|
+
commits: await oneLines(fresh),
|
|
132
|
+
behind: await git.isAncestor(local, remote),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return { found, cleared, unverified };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Earlier tips of `refs/heads/<branch>`, newest first. */
|
|
139
|
+
async function reflogTips(branch: string): Promise<string[]> {
|
|
140
|
+
const { stdout } = await $`git reflog show --format=%H refs/heads/${branch}`.nothrow().quiet();
|
|
141
|
+
return stdout.toString().split("\n").filter(Boolean).slice(0, REFLOG_DEPTH);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Tips of `branch` that snapshots recorded (the pre-rewrite versions). */
|
|
145
|
+
function snapshotTips(meta: StackMetadata, branch: string): string[] {
|
|
146
|
+
const tips = new Set<string>();
|
|
147
|
+
for (const snap of meta.snapshots ?? []) {
|
|
148
|
+
const sha = snap.branches[branch];
|
|
149
|
+
if (sha) tips.add(sha);
|
|
150
|
+
}
|
|
151
|
+
return [...tips];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `git rev-list` output, or null if it failed (never an empty "nothing found"). */
|
|
155
|
+
async function revListLines(args: string[]): Promise<string[] | null> {
|
|
156
|
+
const { stdout, exitCode } = await $`git rev-list ${args}`.nothrow().quiet();
|
|
157
|
+
if (exitCode !== 0) return null;
|
|
158
|
+
return stdout.toString().split("\n").filter(Boolean);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** "abbrev subject" for each sha, in the given order, from one `git log`. */
|
|
162
|
+
async function oneLines(shas: string[]): Promise<string[]> {
|
|
163
|
+
const { stdout } = await $`git log --no-walk=unsorted --format=${"%h %s"} ${shas}`
|
|
164
|
+
.nothrow()
|
|
165
|
+
.quiet();
|
|
166
|
+
return stdout.toString().split("\n").filter(Boolean);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Stop a run before it rewrites a branch whose origin copy has new content.
|
|
171
|
+
* Interactive: ask (default no). Under --yes: refuse, since no flag the user
|
|
172
|
+
* gave said "drop commits from origin". `--overwrite-remote` proceeds. A dry
|
|
173
|
+
* run only reports.
|
|
174
|
+
*/
|
|
175
|
+
export async function guardRemoteContent(
|
|
176
|
+
meta: StackMetadata,
|
|
177
|
+
branches: string[],
|
|
178
|
+
opts: { command: string; overwrite: boolean; dryRun: boolean },
|
|
179
|
+
): Promise<Map<string, string>> {
|
|
180
|
+
const { found, cleared, unverified } = await findRemoteOnlyContent(meta, branches);
|
|
181
|
+
if (found.length === 0 && unverified.length === 0) return cleared;
|
|
182
|
+
|
|
183
|
+
if (unverified.length > 0) {
|
|
184
|
+
console.log();
|
|
185
|
+
p.log.error(
|
|
186
|
+
"Couldn't check these branches' copies on origin for commits the local branches lack:",
|
|
187
|
+
);
|
|
188
|
+
for (const u of unverified) {
|
|
189
|
+
console.log(
|
|
190
|
+
` ${pc.yellow(u.branch)} ${pc.dim(`(origin ${u.remoteSha.slice(0, 8)}: ${u.reason})`)}`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
console.log();
|
|
194
|
+
console.log(
|
|
195
|
+
` ${opts.command} would rebase or force-push them without knowing what's there. Look first:`,
|
|
196
|
+
);
|
|
197
|
+
for (const u of unverified) {
|
|
198
|
+
console.log(
|
|
199
|
+
` ${pc.green(`git fetch origin ${u.branch} && git log ${u.branch}..origin/${u.branch}`)}`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
console.log(` Then re-run ${pc.green(`gh-stack ${opts.command}`)}.`);
|
|
203
|
+
console.log(
|
|
204
|
+
` Or, to overwrite them regardless: ${pc.green(`gh-stack ${opts.command} --overwrite-remote`)}`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
if (found.length === 0) return decide(cleared, [], unverified, opts);
|
|
208
|
+
|
|
209
|
+
console.log();
|
|
210
|
+
p.log.error(
|
|
211
|
+
"Origin has commits that the local branches don't, and they aren't earlier versions of local commits:",
|
|
212
|
+
);
|
|
213
|
+
for (const f of found) {
|
|
214
|
+
console.log(
|
|
215
|
+
` ${pc.yellow(f.branch)} ${pc.dim(`(origin ${f.remoteSha.slice(0, 8)}, local ${f.localSha.slice(0, 8)})`)}`,
|
|
216
|
+
);
|
|
217
|
+
for (const c of f.commits) console.log(` ${pc.red("+")} ${c}`);
|
|
218
|
+
}
|
|
219
|
+
console.log();
|
|
220
|
+
console.log(
|
|
221
|
+
` ${opts.command} would rebase or force-push ${found.length === 1 ? "this branch" : "these branches"} without them, and they'd be lost from origin and the PR.`,
|
|
222
|
+
);
|
|
223
|
+
console.log(
|
|
224
|
+
" This is what happens after `merge --collapse` squashes a child PR into its parent on GitHub.",
|
|
225
|
+
);
|
|
226
|
+
console.log();
|
|
227
|
+
console.log(" Bring them into the local branch first:");
|
|
228
|
+
for (const f of found) {
|
|
229
|
+
const shas = f.commits.map((c) => c.split(" ")[0]).join(" ");
|
|
230
|
+
console.log(
|
|
231
|
+
` ${pc.green(f.behind ? `git checkout ${f.branch} && git pull --ff-only origin ${f.branch}` : `git checkout ${f.branch} && git cherry-pick ${shas}`)}`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
console.log(` Then re-run ${pc.green(`gh-stack ${opts.command}`)}.`);
|
|
235
|
+
console.log(
|
|
236
|
+
` Or, to drop them on purpose: ${pc.green(`gh-stack ${opts.command} --overwrite-remote`)}`,
|
|
237
|
+
);
|
|
238
|
+
return decide(cleared, found, unverified, opts);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* What to do about branches with new or unchecked content on origin: report
|
|
243
|
+
* (dry run), proceed (--overwrite-remote, or an interactive yes), or stop
|
|
244
|
+
* (--yes, or no). Proceeding clears their checked tips for an explicit lease.
|
|
245
|
+
*/
|
|
246
|
+
async function decide(
|
|
247
|
+
cleared: Map<string, string>,
|
|
248
|
+
found: RemoteOnlyContent[],
|
|
249
|
+
unverified: Unverified[],
|
|
250
|
+
opts: { overwrite: boolean; dryRun: boolean },
|
|
251
|
+
): Promise<Map<string, string>> {
|
|
252
|
+
console.log();
|
|
253
|
+
if (opts.dryRun) {
|
|
254
|
+
p.log.warn("[DRY RUN] A real run would stop here.");
|
|
255
|
+
return cleared;
|
|
256
|
+
}
|
|
257
|
+
const overwrite = () => {
|
|
258
|
+
for (const f of found) cleared.set(f.branch, f.remoteSha);
|
|
259
|
+
for (const u of unverified) cleared.set(u.branch, u.remoteSha);
|
|
260
|
+
return cleared;
|
|
261
|
+
};
|
|
262
|
+
if (opts.overwrite) {
|
|
263
|
+
p.log.warn(
|
|
264
|
+
"--overwrite-remote: continuing. Origin's copies of those branches will be overwritten.",
|
|
265
|
+
);
|
|
266
|
+
return overwrite();
|
|
267
|
+
}
|
|
268
|
+
if (isAutoYes()) {
|
|
269
|
+
p.cancel("Stopped before rewriting anything (--yes never overwrites what it couldn't check).");
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
const ok = await p.confirm({
|
|
273
|
+
message: "Rewrite anyway, overwriting origin's copies of those branches?",
|
|
274
|
+
initialValue: false,
|
|
275
|
+
});
|
|
276
|
+
if (p.isCancel(ok) || !ok) {
|
|
277
|
+
p.cancel("Stopped before rewriting anything.");
|
|
278
|
+
process.exit(1);
|
|
279
|
+
}
|
|
280
|
+
return overwrite();
|
|
281
|
+
}
|