gh-stack 0.19.3 → 0.19.5

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 CHANGED
@@ -650,6 +650,7 @@ per-stack files plus git-native branch config, rather than a single JSON blob:
650
650
  deleted/<stack>.json tombstones (recoverable)
651
651
  snapshots/<ts>__<stack>.json append-only, retained per-stack
652
652
  restack-state.json the last restack/sync/join run: resume point + action log
653
+ push-failure.log full output of the last refused push (a failing pre-push hook, say)
653
654
  ```
654
655
 
655
656
  A single `active/<stack>.json` looks like:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gh-stack",
3
- "version": "0.19.3",
3
+ "version": "0.19.5",
4
4
  "description": "Stacked PR manager for squash-merge workflows",
5
5
  "author": "Kiliman <kiliman@gmail.com>",
6
6
  "license": "MIT",
@@ -51,11 +51,11 @@
51
51
  ]
52
52
  },
53
53
  "devDependencies": {
54
- "@types/bun": "1.3.10",
54
+ "@types/bun": "1.4.4",
55
55
  "husky": "^9.1.7",
56
56
  "lint-staged": "^17.6.0",
57
- "oxfmt": "^0.74.0",
58
- "oxlint": "^1.89.0"
57
+ "oxfmt": "^0.75.0",
58
+ "oxlint": "^1.90.0"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "typescript": "^5"
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
- stderr: string;
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, stderr } = await $`git push -u ${lease} --force-if-includes origin ${branch}`
183
- .nothrow()
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 text = stderr.toString();
187
- const reason = text.includes("remote ref updated since checkout")
195
+ const output = stdout.toString();
196
+ const reason = output.includes("remote ref updated since checkout")
188
197
  ? "not-integrated"
189
- : text.includes("stale info")
198
+ : output.includes("stale info")
190
199
  ? "stale-lease"
191
200
  : "rejected";
192
- return { ok: false, reason, stderr: text };
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
- return [
213
- result.stderr
214
- .trim()
215
- .split("\n")
216
- .find((l) => l.includes("rejected") || l.startsWith("error")) ?? result.stderr.trim(),
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.
@@ -296,6 +296,11 @@ it (its PR would be diffed against the parent's old history); the run lists it
296
296
  as held and exits 1 until `--resume` gets the parent pushed. `merge` and `join`
297
297
  refuse to run while a restack/sync is pending.
298
298
 
299
+ **Why did the push fail?** gh-stack prints the last lines of `git push`'s
300
+ output, which includes a pre-push hook's own messages, and saves all of it to
301
+ `.git/.gh-stack/push-failure.log`. Fix the cause (a failing check, say), then
302
+ `--resume`.
303
+
299
304
  **"Origin has commits that the local branches don't"** (restack/sync exit 1
300
305
  before changing anything): a branch's copy on origin has commits it never had,
301
306
  often the squash commit a `merge --collapse` added on GitHub. Run the