gh-stack 0.19.9 → 0.19.10
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 +8 -0
- package/package.json +1 -1
- package/src/commands/join.ts +1 -1
- package/src/commands/submit.ts +5 -2
- package/src/commands/undo.ts +2 -3
- package/src/index.ts +35 -0
- package/src/lib/branch-config.ts +8 -3
- package/src/lib/git.ts +181 -21
- package/src/lib/using-gh-stack.md +7 -0
package/README.md
CHANGED
|
@@ -731,6 +731,14 @@ you at `doctor`.
|
|
|
731
731
|
|
|
732
732
|
Before any destructive operation (restack, sync, merge, delete), gh-stack saves a snapshot of all branch HEADs as an append-only file under `snapshots/`, retained per-stack. Run `gh-stack undo` to restore.
|
|
733
733
|
|
|
734
|
+
### Busy Repositories (git locks)
|
|
735
|
+
|
|
736
|
+
Git doesn't wait for a lock: a checkout or rebase that finds `index.lock` held fails at once. The usual holder is a background `git status` (an editor, a watcher, another agent session), which takes the lock for a few milliseconds. So every git step gh-stack runs that takes a lock (checkout, rebase, reset, commit, branch and tag writes, branch config) waits and retries: after 0.1, 0.2, 0.4, 0.8, 1.6 and 2 seconds, about 5 seconds in all. A rebase that a lock interrupts midway is continued, not reported as a conflict.
|
|
737
|
+
|
|
738
|
+
If the lock is still held after that, gh-stack names it and stops with exit 1, never "Rebase Conflict". Nothing conflicted, and the step changed nothing: once the lock is gone, run the command again, or `gh-stack restack --resume` for an interrupted restack or sync. A lock left by a crashed git process stays until you remove it.
|
|
739
|
+
|
|
740
|
+
gh-stack also runs its own git calls with `GIT_OPTIONAL_LOCKS=0`, so its reads never take the lock.
|
|
741
|
+
|
|
734
742
|
## Example Workflow
|
|
735
743
|
|
|
736
744
|
```bash
|
package/package.json
CHANGED
package/src/commands/join.ts
CHANGED
|
@@ -277,7 +277,7 @@ export default async function join(args: string[]): Promise<void> {
|
|
|
277
277
|
await verifyResult(plan);
|
|
278
278
|
|
|
279
279
|
if (startBranch && startBranch !== (await git.currentBranch().catch(() => null))) {
|
|
280
|
-
await
|
|
280
|
+
await git.tryCheckout(startBranch);
|
|
281
281
|
}
|
|
282
282
|
|
|
283
283
|
const endMeta = await freshMeta();
|
package/src/commands/submit.ts
CHANGED
|
@@ -226,8 +226,11 @@ export default async function submit(args: string[]): Promise<void> {
|
|
|
226
226
|
await runSubmit(args, run, jsonMode);
|
|
227
227
|
} catch (err) {
|
|
228
228
|
// A ConfigError (bad config file or env value) is an expected stop too: in
|
|
229
|
-
// --json mode it becomes the document's error, like any refusal.
|
|
230
|
-
|
|
229
|
+
// --json mode it becomes the document's error, like any refusal. So is a
|
|
230
|
+
// git lock that outlasted the retries; outside --json, index.ts explains it.
|
|
231
|
+
if (!jsonMode && err instanceof git.GitLockError) throw err;
|
|
232
|
+
const expected =
|
|
233
|
+
err instanceof SubmitAbort || err instanceof ConfigError || err instanceof git.GitLockError;
|
|
231
234
|
if (!jsonMode && !expected) throw err;
|
|
232
235
|
if (!expected) console.error(err);
|
|
233
236
|
error = (err as Error).message;
|
package/src/commands/undo.ts
CHANGED
|
@@ -6,7 +6,6 @@ import { ensureMetadata, ensureCleanWorkingTree } from "../lib/safety.ts";
|
|
|
6
6
|
import { getLastSnapshot, popSnapshot } from "../lib/snapshot.ts";
|
|
7
7
|
import { clearRestackState, loadRestackState } from "../lib/metadata.ts";
|
|
8
8
|
import { confirmAction } from "../lib/ui.ts";
|
|
9
|
-
import { $ } from "bun";
|
|
10
9
|
|
|
11
10
|
export default async function undo(args: string[]): Promise<void> {
|
|
12
11
|
if (args.includes("--help")) {
|
|
@@ -60,9 +59,9 @@ a destructive operation (restack, merge, sync, remove).
|
|
|
60
59
|
for (const [branch, sha] of Object.entries(snapshot.branches)) {
|
|
61
60
|
try {
|
|
62
61
|
if (branch === activeBranch) {
|
|
63
|
-
await
|
|
62
|
+
await git.runGitOrThrow(["reset", "--hard", sha]);
|
|
64
63
|
} else {
|
|
65
|
-
await
|
|
64
|
+
await git.runGitOrThrow(["branch", "-f", branch, sha]);
|
|
66
65
|
}
|
|
67
66
|
p.log.success(`Restored ${pc.yellow(branch)} → ${sha.slice(0, 8)}`);
|
|
68
67
|
} catch {
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,12 @@ const colorOff =
|
|
|
19
19
|
process.env.GH_STACK_PLAIN === "1";
|
|
20
20
|
if (colorOff) process.env.NO_COLOR = "1";
|
|
21
21
|
|
|
22
|
+
// gh-stack's own read-only git calls (status, diff) shouldn't take index.lock
|
|
23
|
+
// to save a refreshed stat cache: that only contends with our own writes and
|
|
24
|
+
// with a gh-stack running in another worktree (#69). Bun's shell reads
|
|
25
|
+
// process.env at each call, so this reaches every git we run.
|
|
26
|
+
process.env.GIT_OPTIONAL_LOCKS ??= "0";
|
|
27
|
+
|
|
22
28
|
const { ensureGitRepo } = await import("./lib/safety.ts");
|
|
23
29
|
const { parseCliArgs } = await import("./lib/cli.ts");
|
|
24
30
|
const { setAutoYes } = await import("./lib/ui.ts");
|
|
@@ -64,6 +70,7 @@ const commandArgs = parsed.commandArgs;
|
|
|
64
70
|
// A config problem (malformed TOML, a non-boolean env value) is reported as
|
|
65
71
|
// one clean line, whichever command hit it.
|
|
66
72
|
const { ConfigError } = await import("./lib/config.ts");
|
|
73
|
+
const { GitLockError } = await import("./lib/git.ts");
|
|
67
74
|
try {
|
|
68
75
|
switch (command) {
|
|
69
76
|
case "log":
|
|
@@ -183,11 +190,39 @@ try {
|
|
|
183
190
|
process.exit(1);
|
|
184
191
|
}
|
|
185
192
|
} catch (err) {
|
|
193
|
+
if (err instanceof GitLockError) await reportHeldLock(err);
|
|
186
194
|
if (!(err instanceof ConfigError)) throw err;
|
|
187
195
|
console.error(`gh-stack: ${err.message}`);
|
|
188
196
|
process.exit(1);
|
|
189
197
|
}
|
|
190
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Another git process held a lock past every retry (#69). Say so plainly:
|
|
201
|
+
* nothing conflicted, and the step runs again once the lock is gone.
|
|
202
|
+
*/
|
|
203
|
+
async function reportHeldLock(err: InstanceType<typeof GitLockError>): Promise<never> {
|
|
204
|
+
const { findPendingRun } = await import("./lib/run-log.ts");
|
|
205
|
+
const pending = await findPendingRun().catch(() => null);
|
|
206
|
+
const lines = [
|
|
207
|
+
`gh-stack: another git process holds ${err.lockPath}, and still did after waiting ${(err.waitedMs / 1000).toFixed(1)}s.`,
|
|
208
|
+
` Nothing conflicted: ${err.command} couldn't take the lock, so it changed nothing.`,
|
|
209
|
+
];
|
|
210
|
+
if (err.rebaseInProgress) {
|
|
211
|
+
lines.push(
|
|
212
|
+
` The rebase is paused, not conflicted. Once the lock is gone:`,
|
|
213
|
+
` git rebase --continue`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
lines.push(
|
|
217
|
+
pending
|
|
218
|
+
? ` Once the lock is gone, pick the run up where it stopped:\n gh-stack restack --resume`
|
|
219
|
+
: ` Once the lock is gone, run the command again.`,
|
|
220
|
+
` If no other git process is running, the lock is stale; remove it:\n rm ${err.lockPath}`,
|
|
221
|
+
);
|
|
222
|
+
console.error(lines.join("\n"));
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
|
|
191
226
|
function printHelp() {
|
|
192
227
|
console.log(`
|
|
193
228
|
gh-stack v${VERSION} — Stacked PR manager for squash-merge workflows
|
package/src/lib/branch-config.ts
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import { $ } from "bun";
|
|
28
28
|
import { existsSync, readFileSync } from "node:fs";
|
|
29
29
|
import * as path from "node:path";
|
|
30
|
+
import { runGit } from "./git.ts";
|
|
30
31
|
|
|
31
32
|
export interface BranchMembership {
|
|
32
33
|
stack?: string;
|
|
@@ -68,12 +69,16 @@ async function setOrUnset(branch: string, key: string, value: string | undefined
|
|
|
68
69
|
await unsetKey(branch, key);
|
|
69
70
|
return;
|
|
70
71
|
}
|
|
71
|
-
|
|
72
|
+
// The repo's config is shared by every worktree, so another gh-stack (or any
|
|
73
|
+
// `git config` write) can hold config.lock for a moment: runGit waits it out
|
|
74
|
+
// instead of silently dropping the write.
|
|
75
|
+
await runGit(["config", configKey(branch, key), value]);
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
async function unsetKey(branch: string, key: string): Promise<void> {
|
|
75
|
-
// `--unset` exits 5 when the key is already absent — harmless, so
|
|
76
|
-
|
|
79
|
+
// `--unset` exits 5 when the key is already absent — harmless, so the exit
|
|
80
|
+
// code is ignored.
|
|
81
|
+
await runGit(["config", "--unset", configKey(branch, key)]);
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
/**
|
package/src/lib/git.ts
CHANGED
|
@@ -2,10 +2,168 @@
|
|
|
2
2
|
import { $ } from "bun";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { mkdir } from "node:fs/promises";
|
|
5
|
+
import pc from "picocolors";
|
|
5
6
|
|
|
6
7
|
export const STACK_SYNC_TAG_PREFIX = "stack-sync-base";
|
|
7
8
|
export const STACK_SYNC_TAG_GLOB = `${STACK_SYNC_TAG_PREFIX}-*`;
|
|
8
9
|
|
|
10
|
+
// ── Lock contention (#69) ──────────────────────────────────────────────────
|
|
11
|
+
//
|
|
12
|
+
// Git doesn't wait for a lock: a checkout, rebase or config write that finds
|
|
13
|
+
// `index.lock` (or `config.lock`, a ref lock) already present fails at once.
|
|
14
|
+
// The usual holder is a background reader (an editor's or agent's `git
|
|
15
|
+
// status`) that grabs index.lock for a few milliseconds to save a refreshed
|
|
16
|
+
// stat cache, so the lock is gone almost immediately. Every gh-stack git call
|
|
17
|
+
// that takes a lock goes through runGit, which waits and retries instead.
|
|
18
|
+
|
|
19
|
+
/** git's message for a lock file that already exists (lockfile.c). */
|
|
20
|
+
const LOCK_HELD = /Unable to create '([^']+\.lock)': File exists/;
|
|
21
|
+
/**
|
|
22
|
+
* `git config` words it differently, naming the config file rather than its
|
|
23
|
+
* lock (and it already waits about a second on its own before failing).
|
|
24
|
+
*/
|
|
25
|
+
const CONFIG_LOCK_HELD = /could not lock config file (.+): File exists/;
|
|
26
|
+
|
|
27
|
+
/** Waits between attempts: quick at first, then backing off (~5 s in all). */
|
|
28
|
+
const DEFAULT_LOCK_RETRY_DELAYS_MS = [100, 200, 400, 800, 1600, 2000];
|
|
29
|
+
let lockRetryDelaysMs = DEFAULT_LOCK_RETRY_DELAYS_MS;
|
|
30
|
+
|
|
31
|
+
/** Shorten the retry schedule in tests; `null` restores the default. */
|
|
32
|
+
export function setLockRetryDelays(delays: number[] | null): void {
|
|
33
|
+
lockRetryDelaysMs = delays ?? DEFAULT_LOCK_RETRY_DELAYS_MS;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The lock file another process holds, if git's output says one blocked it. */
|
|
37
|
+
export function heldLock(output: string): string | null {
|
|
38
|
+
const lock = LOCK_HELD.exec(output)?.[1];
|
|
39
|
+
if (lock) return lock;
|
|
40
|
+
const config = CONFIG_LOCK_HELD.exec(output)?.[1];
|
|
41
|
+
return config ? `${config}.lock` : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A lock was still held after every retry. Nothing conflicted: the step can
|
|
46
|
+
* simply run again once the other git process lets go.
|
|
47
|
+
*/
|
|
48
|
+
export class GitLockError extends Error {
|
|
49
|
+
constructor(
|
|
50
|
+
readonly lockPath: string,
|
|
51
|
+
readonly command: string,
|
|
52
|
+
/** The lock interrupted a rebase midway; it's paused, not conflicted. */
|
|
53
|
+
readonly rebaseInProgress: boolean,
|
|
54
|
+
/** How long gh-stack waited for it before giving up. */
|
|
55
|
+
readonly waitedMs: number,
|
|
56
|
+
) {
|
|
57
|
+
super(`another git process holds ${lockPath} (${command})`);
|
|
58
|
+
this.name = "GitLockError";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface GitResult {
|
|
63
|
+
exitCode: number;
|
|
64
|
+
stdout: string;
|
|
65
|
+
stderr: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Run `git <args>`, retrying while another process holds a lock it needs.
|
|
70
|
+
* Retrying is safe because git takes the lock before changing anything: the
|
|
71
|
+
* failed attempt left the repo as it was. Throws GitLockError when retries
|
|
72
|
+
* run out; any other failure is returned for the caller to judge.
|
|
73
|
+
*
|
|
74
|
+
* `echo` replays git's output to the terminal (rebase progress and conflict
|
|
75
|
+
* messages), leaving out the attempts a lock blocked.
|
|
76
|
+
*/
|
|
77
|
+
export async function runGit(args: string[], opts: { echo?: boolean } = {}): Promise<GitResult> {
|
|
78
|
+
return retryLocks(args, opts.echo ?? false, async () => ({ next: args }));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Run `git <args>` with lock retries, throwing if it fails for another reason. */
|
|
82
|
+
export async function runGitOrThrow(args: string[], opts: { echo?: boolean } = {}): Promise<void> {
|
|
83
|
+
const { exitCode, stderr } = await runGit(args, opts);
|
|
84
|
+
if (exitCode !== 0) {
|
|
85
|
+
throw new Error(`git ${args.join(" ")} failed (exit ${exitCode}): ${stderr.trim()}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface Retry {
|
|
90
|
+
next: string[];
|
|
91
|
+
/** The retry resumes a rebase that a lock paused midway. */
|
|
92
|
+
resumesRebase?: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* A rebase, retried around lock contention. A lock held when the rebase
|
|
97
|
+
* starts fails it before anything happens ("could not detach HEAD"), so it
|
|
98
|
+
* runs again as is. A lock that turns up midway makes git reschedule the
|
|
99
|
+
* pick it couldn't apply and pause, with nothing conflicted: `git rebase
|
|
100
|
+
* --continue` picks it up. Returns false only for a real conflict.
|
|
101
|
+
* (Exported for tests; commands use rebaseOnto and rebase.)
|
|
102
|
+
*/
|
|
103
|
+
export async function runRebase(args: string[]): Promise<boolean> {
|
|
104
|
+
const result = await retryLocks(args, true, async () => {
|
|
105
|
+
if (!(await isRebaseInProgress())) return { next: args };
|
|
106
|
+
// A conflict leaves unmerged paths; a lock-paused rebase doesn't.
|
|
107
|
+
if (await hasUnmergedPaths()) return null;
|
|
108
|
+
return { next: ["rebase", "--continue"], resumesRebase: true };
|
|
109
|
+
});
|
|
110
|
+
return result.exitCode === 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The retry loop. When an attempt fails because a lock is held, `onLocked`
|
|
115
|
+
* returns the command to retry with (or null to stop and return the failure).
|
|
116
|
+
*/
|
|
117
|
+
async function retryLocks(
|
|
118
|
+
args: string[],
|
|
119
|
+
echo: boolean,
|
|
120
|
+
onLocked: () => Promise<Retry | null>,
|
|
121
|
+
): Promise<GitResult> {
|
|
122
|
+
let retry: Retry = { next: args };
|
|
123
|
+
let announced = false;
|
|
124
|
+
let waitedMs = 0;
|
|
125
|
+
for (let attempt = 0; ; attempt++) {
|
|
126
|
+
let shell = $`git ${retry.next}`.quiet().nothrow();
|
|
127
|
+
// Resuming a lock-paused pick reuses the commit's message; never stop to
|
|
128
|
+
// open an editor for it.
|
|
129
|
+
if (retry.resumesRebase) shell = shell.env({ ...process.env, GIT_EDITOR: "true" });
|
|
130
|
+
const raw = await shell;
|
|
131
|
+
const result = {
|
|
132
|
+
exitCode: raw.exitCode,
|
|
133
|
+
stdout: raw.stdout.toString(),
|
|
134
|
+
stderr: raw.stderr.toString(),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const lock = result.exitCode === 0 ? null : heldLock(result.stderr);
|
|
138
|
+
const next = lock ? await onLocked() : null;
|
|
139
|
+
if (!lock || !next) {
|
|
140
|
+
if (echo) {
|
|
141
|
+
process.stdout.write(result.stdout);
|
|
142
|
+
process.stderr.write(result.stderr);
|
|
143
|
+
}
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const delay = lockRetryDelaysMs[attempt];
|
|
148
|
+
if (delay === undefined) {
|
|
149
|
+
throw new GitLockError(lock, `git ${args.join(" ")}`, next.resumesRebase ?? false, waitedMs);
|
|
150
|
+
}
|
|
151
|
+
if (!announced) {
|
|
152
|
+
console.error(pc.dim(` Another git process holds ${lock}; waiting for it to finish…`));
|
|
153
|
+
announced = true;
|
|
154
|
+
}
|
|
155
|
+
await Bun.sleep(delay);
|
|
156
|
+
waitedMs += delay;
|
|
157
|
+
retry = next;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Whether the index has unmerged (conflicted) entries. */
|
|
162
|
+
async function hasUnmergedPaths(): Promise<boolean> {
|
|
163
|
+
const out = await $`git diff --name-only --diff-filter=U`.quiet().nothrow().text();
|
|
164
|
+
return out.trim() !== "";
|
|
165
|
+
}
|
|
166
|
+
|
|
9
167
|
/**
|
|
10
168
|
* Get the current branch name.
|
|
11
169
|
* Throws if in detached HEAD state.
|
|
@@ -61,9 +219,13 @@ export async function repoRoot(): Promise<string> {
|
|
|
61
219
|
* Refreshes the index first to avoid false positives from stale stat info.
|
|
62
220
|
*/
|
|
63
221
|
export async function isCleanWorkingTree(): Promise<boolean> {
|
|
222
|
+
// Refresh index to avoid false positives from stat changes (timestamps, etc.).
|
|
223
|
+
// Outside the try: a lock that never frees must surface, not read as dirty.
|
|
224
|
+
// No -q: it also hides the lock message, and a refresh the lock blocked
|
|
225
|
+
// leaves stale stat info that makes a clean tree look dirty. The output
|
|
226
|
+
// ("needs update" lines) is captured and dropped.
|
|
227
|
+
await runGit(["update-index", "--refresh"]);
|
|
64
228
|
try {
|
|
65
|
-
// Refresh index to avoid false positives from stat changes (timestamps, etc.)
|
|
66
|
-
await $`git update-index -q --refresh`.quiet().nothrow();
|
|
67
229
|
await $`git diff-index --quiet HEAD --`.quiet();
|
|
68
230
|
return true;
|
|
69
231
|
} catch {
|
|
@@ -96,13 +258,13 @@ export async function isRebaseInProgress(): Promise<boolean> {
|
|
|
96
258
|
}
|
|
97
259
|
|
|
98
260
|
/**
|
|
99
|
-
* Checkout a branch. Throws
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
261
|
+
* Checkout a branch. Throws on failure — only use this where a failure is
|
|
262
|
+
* unexpected (e.g. after ensureCleanWorkingTree). For user-facing branch
|
|
263
|
+
* switching, prefer tryCheckout so a dirty-tree refusal can be rendered as a
|
|
264
|
+
* clean message instead of a stack trace.
|
|
103
265
|
*/
|
|
104
266
|
export async function checkout(branch: string): Promise<void> {
|
|
105
|
-
await
|
|
267
|
+
await runGitOrThrow(["checkout", branch]);
|
|
106
268
|
}
|
|
107
269
|
|
|
108
270
|
/**
|
|
@@ -111,15 +273,15 @@ export async function checkout(branch: string): Promise<void> {
|
|
|
111
273
|
* message and the offending file list when a dirty tree blocks the switch).
|
|
112
274
|
*/
|
|
113
275
|
export async function tryCheckout(branch: string): Promise<{ ok: boolean; stderr: string }> {
|
|
114
|
-
const { exitCode, stderr } = await
|
|
115
|
-
return { ok: exitCode === 0, stderr
|
|
276
|
+
const { exitCode, stderr } = await runGit(["checkout", branch]);
|
|
277
|
+
return { ok: exitCode === 0, stderr };
|
|
116
278
|
}
|
|
117
279
|
|
|
118
280
|
/**
|
|
119
281
|
* Create and checkout a new branch from the current HEAD.
|
|
120
282
|
*/
|
|
121
283
|
export async function createBranch(name: string): Promise<void> {
|
|
122
|
-
await
|
|
284
|
+
await runGitOrThrow(["checkout", "-b", name], { echo: true });
|
|
123
285
|
}
|
|
124
286
|
|
|
125
287
|
/**
|
|
@@ -324,7 +486,7 @@ export async function localBranchExists(branch: string): Promise<boolean> {
|
|
|
324
486
|
* the branch didn't exist or the delete failed.
|
|
325
487
|
*/
|
|
326
488
|
export async function deleteLocalBranch(branch: string): Promise<boolean> {
|
|
327
|
-
const { exitCode } = await
|
|
489
|
+
const { exitCode } = await runGit(["branch", "-D", branch]);
|
|
328
490
|
return exitCode === 0;
|
|
329
491
|
}
|
|
330
492
|
|
|
@@ -334,7 +496,7 @@ export async function deleteLocalBranch(branch: string): Promise<boolean> {
|
|
|
334
496
|
* Returns false if the rename failed (e.g. the new name already exists).
|
|
335
497
|
*/
|
|
336
498
|
export async function renameBranch(oldName: string, newName: string): Promise<boolean> {
|
|
337
|
-
const { exitCode } = await
|
|
499
|
+
const { exitCode } = await runGit(["branch", "-m", oldName, newName]);
|
|
338
500
|
return exitCode === 0;
|
|
339
501
|
}
|
|
340
502
|
|
|
@@ -350,7 +512,7 @@ export async function deleteRemoteBranch(branch: string): Promise<boolean> {
|
|
|
350
512
|
* Create a temporary tag at a specific commit.
|
|
351
513
|
*/
|
|
352
514
|
export async function createTag(name: string, commit: string): Promise<void> {
|
|
353
|
-
await
|
|
515
|
+
await runGitOrThrow(["tag", "-f", name, commit]);
|
|
354
516
|
}
|
|
355
517
|
|
|
356
518
|
/**
|
|
@@ -361,7 +523,7 @@ export async function deleteTagsMatching(pattern: string): Promise<void> {
|
|
|
361
523
|
const result = await $`git tag -l ${pattern}`.text();
|
|
362
524
|
const tags = result.trim().split("\n").filter(Boolean);
|
|
363
525
|
for (const tag of tags) {
|
|
364
|
-
await
|
|
526
|
+
await runGitOrThrow(["tag", "-d", tag]);
|
|
365
527
|
}
|
|
366
528
|
} catch {
|
|
367
529
|
// No tags to delete — that's fine
|
|
@@ -386,8 +548,7 @@ export async function rebaseOnto(
|
|
|
386
548
|
oldBase: string,
|
|
387
549
|
branch: string,
|
|
388
550
|
): Promise<boolean> {
|
|
389
|
-
|
|
390
|
-
return exitCode === 0;
|
|
551
|
+
return runRebase(["rebase", "--onto", newBase, oldBase, branch]);
|
|
391
552
|
}
|
|
392
553
|
|
|
393
554
|
/**
|
|
@@ -395,7 +556,7 @@ export async function rebaseOnto(
|
|
|
395
556
|
* Returns true if successful. Caller must have verified the tree is clean.
|
|
396
557
|
*/
|
|
397
558
|
export async function resetHard(ref: string): Promise<boolean> {
|
|
398
|
-
const { exitCode } = await
|
|
559
|
+
const { exitCode } = await runGit(["reset", "--hard", ref]);
|
|
399
560
|
return exitCode === 0;
|
|
400
561
|
}
|
|
401
562
|
|
|
@@ -404,8 +565,7 @@ export async function resetHard(ref: string): Promise<boolean> {
|
|
|
404
565
|
* Returns true if successful, false if conflicts occurred.
|
|
405
566
|
*/
|
|
406
567
|
export async function rebase(target: string): Promise<boolean> {
|
|
407
|
-
|
|
408
|
-
return exitCode === 0;
|
|
568
|
+
return runRebase(["rebase", target]);
|
|
409
569
|
}
|
|
410
570
|
|
|
411
571
|
/**
|
|
@@ -413,7 +573,7 @@ export async function rebase(target: string): Promise<boolean> {
|
|
|
413
573
|
* Returns true if successful.
|
|
414
574
|
*/
|
|
415
575
|
export async function mergeSquash(branch: string): Promise<boolean> {
|
|
416
|
-
const { exitCode } = await
|
|
576
|
+
const { exitCode } = await runGit(["merge", "--squash", branch], { echo: true });
|
|
417
577
|
return exitCode === 0;
|
|
418
578
|
}
|
|
419
579
|
|
|
@@ -421,7 +581,7 @@ export async function mergeSquash(branch: string): Promise<boolean> {
|
|
|
421
581
|
* Commit with a message.
|
|
422
582
|
*/
|
|
423
583
|
export async function commit(message: string): Promise<void> {
|
|
424
|
-
await
|
|
584
|
+
await runGitOrThrow(["commit", "-m", message], { echo: true });
|
|
425
585
|
}
|
|
426
586
|
|
|
427
587
|
/**
|
|
@@ -303,6 +303,13 @@ it (its PR would be diffed against the parent's old history); the run lists it
|
|
|
303
303
|
as held and exits 1 until `--resume` gets the parent pushed. `merge` and `join`
|
|
304
304
|
refuse to run while a restack/sync is pending.
|
|
305
305
|
|
|
306
|
+
**"Another git process holds …index.lock"**: some other process (often a
|
|
307
|
+
background `git status`) held a git lock. gh-stack waits and retries for about 5
|
|
308
|
+
seconds first, so seeing this means the lock outlasted that. It is not a
|
|
309
|
+
conflict and the step changed nothing: run the same command again, or
|
|
310
|
+
`gh-stack restack --resume` if a restack/sync was interrupted. If no git process
|
|
311
|
+
is running, the lock is stale; remove the file it names.
|
|
312
|
+
|
|
306
313
|
**Why did the push fail?** gh-stack prints the last lines of `git push`'s
|
|
307
314
|
output, which includes a pre-push hook's own messages, and saves all of it to
|
|
308
315
|
`.git/.gh-stack/push-failure.log`. Fix the cause (a failing check, say), then
|