@brainervirus/workit-core 8.0.1 → 8.1.1

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/src/git/ops.ts CHANGED
@@ -18,6 +18,8 @@
18
18
  // the tip workit last recorded, and success only when the remote tip
19
19
  // observed afterwards equals the local SHA (`push.verified` row).
20
20
  import { spawnSync } from "node:child_process";
21
+ import { existsSync, readFileSync } from "node:fs";
22
+ import path from "node:path";
21
23
  import {
22
24
  classifyBranchDirt,
23
25
  isProtectedTarget,
@@ -51,12 +53,17 @@ import {
51
53
  const COMMIT_TIMEOUT_MS = 10 * 60_000;
52
54
  const PUSH_TIMEOUT_MS = 120_000;
53
55
 
54
- type Run = { ok: boolean; status: number | null; stdout: string; stderr: string };
56
+ type Run = {
57
+ ok: boolean;
58
+ status: number | null;
59
+ stdout: string;
60
+ stderr: string;
61
+ };
55
62
 
56
63
  const git = (
57
64
  cwd: string,
58
65
  args: string[],
59
- options: { timeoutMs?: number; env?: NodeJS.ProcessEnv } = {},
66
+ options: { timeoutMs?: number; env?: NodeJS.ProcessEnv; input?: string } = {},
60
67
  ): Run => {
61
68
  const result = spawnSync("git", args, {
62
69
  cwd,
@@ -66,7 +73,9 @@ const git = (
66
73
  timeout: options.timeoutMs ?? GIT_TIMEOUTS.local,
67
74
  killSignal: "SIGKILL",
68
75
  windowsHide: true,
69
- stdio: ["ignore", "pipe", "pipe"],
76
+ ...(options.input === undefined
77
+ ? { stdio: ["ignore", "pipe", "pipe"] as const }
78
+ : { input: options.input, stdio: ["pipe", "pipe", "pipe"] as const }),
70
79
  });
71
80
  return {
72
81
  ok: result.status === 0,
@@ -85,6 +94,96 @@ const gitError = (run: { stderr: string; stdout?: string }, max = 3): string =>
85
94
  return lines.slice(-max).join(" / ").slice(0, 400) || "git failed";
86
95
  };
87
96
 
97
+ /** Past this many lines, hook output keeps its head and tail. */
98
+ const OUTPUT_LINES = 200;
99
+ /** A longer output line (a minified bundle, a JSON blob) is cut here. */
100
+ const OUTPUT_LINE_CHARS = 1000;
101
+
102
+ /**
103
+ * Everything git and its hooks printed, redacted and without `hint:` lines, so
104
+ * a failing lint hook's errors reach the agent. Long output keeps the first
105
+ * and last OUTPUT_LINES / 2 lines.
106
+ */
107
+ const gitOutput = (run: { stderr: string; stdout?: string }): string => {
108
+ const lines = redactText(`${run.stderr}\n${run.stdout ?? ""}`)
109
+ .split(/\r?\n/u)
110
+ .map((value) => value.trimEnd())
111
+ .filter((value) => value.trim() && !value.trimStart().startsWith("hint:"))
112
+ .map((value) =>
113
+ value.length > OUTPUT_LINE_CHARS ? `${value.slice(0, OUTPUT_LINE_CHARS)}…` : value,
114
+ );
115
+ if (!lines.length) return "git failed";
116
+ if (lines.length <= OUTPUT_LINES) return lines.join("\n");
117
+ const half = OUTPUT_LINES / 2;
118
+ return [
119
+ ...lines.slice(0, half),
120
+ `… ${lines.length - OUTPUT_LINES} lines omitted …`,
121
+ ...lines.slice(-half),
122
+ ].join("\n");
123
+ };
124
+
125
+ type Operation = "rebase" | "am" | "merge" | "cherry-pick" | "revert";
126
+
127
+ type InProgress = {
128
+ operation: Operation;
129
+ /**
130
+ * A commit is the legitimate next step: an interactive rebase stopped at an
131
+ * `edit`, or a revert or merge without conflicts (`--no-commit`).
132
+ */
133
+ commitAllowed: boolean;
134
+ /** The branch being rebased (HEAD is detached meanwhile). */
135
+ branch: string | null;
136
+ };
137
+
138
+ /** The multi-step git operation the checkout is in the middle of, if any. */
139
+ function operationInProgress(cwd: string): InProgress | null {
140
+ const markers: [string, Operation][] = [
141
+ ["rebase-merge", "rebase"],
142
+ ["rebase-apply", "rebase"],
143
+ ["MERGE_HEAD", "merge"],
144
+ ["CHERRY_PICK_HEAD", "cherry-pick"],
145
+ ["REVERT_HEAD", "revert"],
146
+ ];
147
+ const run = git(cwd, ["rev-parse", ...markers.flatMap(([name]) => ["--git-path", name])]);
148
+ if (!run.ok) return null;
149
+ const found = run.stdout.split("\n").map((line) => path.resolve(cwd, line.trim()));
150
+ const conflicted = () => git(cwd, ["ls-files", "-u"]).stdout.trim() !== "";
151
+ for (const [index, [, operation]] of markers.entries()) {
152
+ const marker = found[index];
153
+ if (!marker || !existsSync(marker)) continue;
154
+ // `git am` also uses rebase-apply; its `applying` file tells them apart.
155
+ if (index === 1 && existsSync(path.join(marker, "applying")))
156
+ return { operation: "am", commitAllowed: false, branch: null };
157
+ // An `edit` stop leaves rebase-merge/amend; the rebased branch is head-name.
158
+ if (index === 0 && existsSync(path.join(marker, "amend")) && !conflicted()) {
159
+ let headName = "";
160
+ try {
161
+ headName = readFileSync(path.join(marker, "head-name"), "utf8").trim();
162
+ } catch {
163
+ // No head-name: a rebase of a detached HEAD; there is no branch to commit for.
164
+ }
165
+ const branch = headName.startsWith("refs/heads/")
166
+ ? headName.slice("refs/heads/".length)
167
+ : null;
168
+ return { operation, commitAllowed: branch !== null, branch };
169
+ }
170
+ if ((operation === "revert" || operation === "merge") && !conflicted())
171
+ return { operation, commitAllowed: true, branch: null };
172
+ return { operation, commitAllowed: false, branch: null };
173
+ }
174
+ return null;
175
+ }
176
+
177
+ /** A refusal that names the operation in progress and how to finish or leave it. */
178
+ function inProgressFailure<T>(operation: Operation, verb: string): ForgeResult<T> {
179
+ const command = operation === "am" ? "git am" : `git ${operation}`;
180
+ return failure(
181
+ "blocked",
182
+ `${operation.replace("-", "_")}_in_progress: a ${operation === "am" ? "git am" : operation} is in progress; finish or abort it before workit can ${verb}`,
183
+ `resolve the conflicts, git add <files>, then ${command} --continue # or ${command} --abort to return to where you started`,
184
+ );
185
+ }
186
+
88
187
  const validRefName = (cwd: string, name: string): boolean =>
89
188
  !name.startsWith("-") && git(cwd, ["check-ref-format", "--branch", name]).ok;
90
189
 
@@ -138,6 +237,8 @@ export type BranchInput = {
138
237
  export function gitBranch(cwd: string, input: BranchInput): ForgeResult<BranchOutcome> {
139
238
  if (!headSha(cwd) && currentBranch(cwd) === null)
140
239
  return failure("invalid_input", "not inside a git repository with a commit");
240
+ const progress = operationInProgress(cwd);
241
+ if (progress) return inProgressFailure(progress.operation, "create a branch");
141
242
  // One resolution: the default target, the base for new branches and the
142
243
  // release track (core/release-tracks.ts) all come from vcsConfig. With
143
244
  // --base the track is the base's line (it decides the --kind naming);
@@ -264,6 +365,10 @@ export type CommitOutcome = {
264
365
  /** Changes left uncommitted (not selected). */
265
366
  leftDirty: number;
266
367
  session: string | null;
368
+ /** With --amend: the commit this one replaced. */
369
+ amended: string | null;
370
+ /** Follow-ups the agent should know (an amended commit that was already pushed). */
371
+ notes: string[];
267
372
  /** The ledger row id, or why it could not be written (the commit stands). */
268
373
  recorded: { id: string } | { error: string };
269
374
  };
@@ -296,21 +401,109 @@ export function lintCommitMessage(
296
401
 
297
402
  const SESSION_SAFE = /^[\w.:@/+-]{1,128}$/u;
298
403
 
404
+ // Fixed trailer syntax, whatever the user's trailer.* config says.
405
+ const TRAILER_CONFIG = ["-c", "trailer.separators=:", "-c", "trailer.where=end"];
406
+
407
+ /** The trailers (`Key: value`) of a commit message. */
408
+ function messageTrailers(cwd: string, message: string): string[] {
409
+ const run = git(cwd, [...TRAILER_CONFIG, "interpret-trailers", "--parse", "--no-divider"], {
410
+ input: `${message}\n`,
411
+ });
412
+ return run.ok ? run.stdout.split("\n").filter((line) => line.trim()) : [];
413
+ }
414
+
415
+ /**
416
+ * The message with `trailers` appended, keeping every existing trailer and
417
+ * never repeating an identical one (an amend of a commit this session already
418
+ * made). `--no-divider`: a `---` line in the body is text, not a patch start.
419
+ */
420
+ function withTrailers(cwd: string, message: string, trailers: readonly string[]): string {
421
+ if (!trailers.length) return message;
422
+ const run = git(
423
+ cwd,
424
+ [
425
+ ...TRAILER_CONFIG,
426
+ "interpret-trailers",
427
+ "--no-divider",
428
+ "--if-exists",
429
+ "addIfDifferent",
430
+ ...trailers.flatMap((trailer) => ["--trailer", trailer]),
431
+ ],
432
+ { input: `${message}\n` },
433
+ );
434
+ return run.ok ? run.stdout.trim() : `${message}\n\n${trailers.join("\n")}`;
435
+ }
436
+
437
+ /** Short names (`main`, `develop`) of the refs that contain `sha`, local or remote. */
438
+ function branchesContaining(cwd: string, sha: string): Set<string> {
439
+ const run = git(cwd, [
440
+ "for-each-ref",
441
+ "--format=%(refname)",
442
+ "--contains",
443
+ sha,
444
+ "refs/heads",
445
+ "refs/remotes",
446
+ ]);
447
+ const names = new Set<string>();
448
+ for (const ref of run.ok ? run.stdout.split("\n") : []) {
449
+ if (ref.startsWith("refs/heads/")) names.add(ref.slice("refs/heads/".length));
450
+ else if (ref.startsWith("refs/remotes/")) {
451
+ const name = ref.split("/").slice(3).join("/");
452
+ if (name && name !== "HEAD") names.add(name);
453
+ }
454
+ }
455
+ return names;
456
+ }
457
+
458
+ /**
459
+ * The shared branch (protected, or the default target or base) that already
460
+ * contains `sha`: amending it would rewrite a commit that is not this
461
+ * branch's own. Null when `sha` is the branch's own.
462
+ */
463
+ function sharedBranchContaining(cwd: string, sha: string, branch: string): string | null {
464
+ const resolved = vcsConfig("resolve", cwd);
465
+ const defaults = new Set(
466
+ resolved.ok === false
467
+ ? []
468
+ : [resolved.defaultTargetBranch, resolved.baseBranch]
469
+ .map((value) => String(value ?? ""))
470
+ .filter(Boolean),
471
+ );
472
+ for (const name of branchesContaining(cwd, sha))
473
+ if (name !== branch && (defaults.has(name) || isProtectedTarget(cwd, name))) return name;
474
+ return null;
475
+ }
476
+
477
+ const nothingToCommit = (what: string) =>
478
+ failure<CommitOutcome>(
479
+ "failed",
480
+ `nothing_to_commit: ${what}`,
481
+ "workit git commit --allow-empty -m '<msg>' # only to record an empty commit on purpose",
482
+ );
483
+
299
484
  export function gitCommit(
300
485
  cwd: string,
301
486
  input: {
302
- message: string;
487
+ /** The new message; with `amend` and no message the amended commit's is kept. */
488
+ message?: string | null;
303
489
  all?: boolean;
304
490
  paths?: readonly string[];
491
+ /** Replace the branch's last commit (`git commit --amend`). */
492
+ amend?: boolean;
493
+ /** Record a commit even when it changes no file. */
494
+ allowEmpty?: boolean;
305
495
  actor: LedgerActor;
306
496
  env?: NodeJS.ProcessEnv;
307
497
  },
308
498
  ): ForgeResult<CommitOutcome> {
309
- const message = input.message.trim();
310
- if (!message) return failure("invalid_input", "a commit message is required (-m <msg>)");
499
+ const given = input.message?.trim() ?? "";
500
+ if (!given && !input.amend)
501
+ return failure("invalid_input", "a commit message is required (-m <msg> or -F <file>)");
311
502
  const paths = input.paths ?? [];
312
503
  if (input.all && paths.length) return failure("invalid_input", "pass --all or paths, not both");
313
- const branch = currentBranch(cwd);
504
+ const progress = operationInProgress(cwd);
505
+ if (progress && !progress.commitAllowed) return inProgressFailure(progress.operation, "commit");
506
+ const branch = progress?.branch ?? currentBranch(cwd);
314
507
  if (!branch)
315
508
  return failure(
316
509
  "invalid_input",
@@ -322,52 +515,120 @@ export function gitCommit(
322
515
  `protected_branch: ${branch} is protected by the workspace branch policy; workit never commits to it`,
323
516
  "workit git branch <feature/name> # then commit there",
324
517
  );
325
- const lint = lintCommitMessage(cwd, message);
326
- if (!lint.ok) return failure("blocked", lint.error, lint.correction);
518
+ const previous = input.amend ? headSha(cwd) : null;
519
+ if (input.amend && !previous)
520
+ return failure("invalid_input", `nothing to amend: ${branch} has no commit yet`);
521
+ if (previous) {
522
+ const shared = sharedBranchContaining(cwd, previous, branch);
523
+ if (shared)
524
+ return failure(
525
+ "blocked",
526
+ `amend_shared_commit: ${previous.slice(0, 12)} is already on ${shared}, so it is not ${branch}'s own commit; workit never rewrites it`,
527
+ "workit git commit -m '<msg>' -- <paths> # add a new commit instead",
528
+ );
529
+ }
530
+ let message = given;
531
+ let carried: string[] = [];
532
+ if (given) {
533
+ const lint = lintCommitMessage(cwd, given);
534
+ if (!lint.ok) return failure("blocked", lint.error, lint.correction);
535
+ }
536
+ if (previous) {
537
+ const kept = git(cwd, ["log", "-1", "--format=%B", previous]);
538
+ if (!kept.ok)
539
+ return failure("failed", `could not read the message of ${branch}: ${gitError(kept)}`);
540
+ // --no-edit keeps the message whole; a new message carries the old trailers over.
541
+ if (given) carried = messageTrailers(cwd, kept.stdout.trim());
542
+ else message = kept.stdout.trim();
543
+ }
327
544
  const session = input.actor.session;
328
545
  if (session && !SESSION_SAFE.test(session))
329
546
  return failure(
330
547
  "invalid_input",
331
548
  "WORKIT_SESSION_ID must be 1-128 characters of [A-Za-z0-9_.:@/+-]",
332
549
  );
550
+ // A commit that only rewords (amend) or is empty on purpose needs no change.
551
+ const changeRequired = !input.amend && !input.allowEmpty;
333
552
 
334
- const args = ["commit", "--no-edit", "-m", message];
335
- if (session) args.push("--trailer", `Workit-Session: ${session}`);
553
+ // Amending an empty commit (a CI retrigger) keeps it empty without --allow-empty.
554
+ const emptyAmend =
555
+ previous !== null &&
556
+ git(cwd, ["rev-parse", "--verify", "-q", `${previous}^{tree}`]).stdout.trim() ===
557
+ git(cwd, ["rev-parse", "--verify", "-q", `${previous}~1^{tree}`]).stdout.trim();
558
+ const args = ["commit", "--no-edit"];
559
+ if (input.amend) args.push("--amend");
560
+ if (input.allowEmpty || emptyAmend) args.push("--allow-empty");
561
+ args.push(
562
+ "-m",
563
+ withTrailers(cwd, message, [...carried, ...(session ? [`Workit-Session: ${session}`] : [])]),
564
+ );
336
565
  if (paths.length) {
337
566
  if (paths.some((value) => value.startsWith("-") || !value.trim()))
338
567
  return failure("invalid_input", "paths must not be empty or start with -");
339
- const added = git(cwd, ["add", "-A", "--", ...paths]);
340
- if (!added.ok) return failure("failed", `git add failed: ${gitError(added)}`);
568
+ // A path already removed from disk and index (`git rm`) has nothing to
569
+ // add, and `git add` would fail on it; the commit below still records it.
570
+ const tracked = new Set(nulList(git(cwd, ["ls-files", "-z", "--", ...paths]).stdout));
571
+ const addable = paths.filter(
572
+ (value) =>
573
+ existsSync(path.resolve(cwd, value)) ||
574
+ [...tracked].some((file) => file === value || file.startsWith(`${value}/`)),
575
+ );
576
+ if (addable.length) {
577
+ const added = git(cwd, ["add", "-A", "--", ...addable]);
578
+ if (!added.ok) return failure("failed", `git add failed: ${gitError(added)}`);
579
+ }
580
+ if (changeRequired && git(cwd, ["diff", "--cached", "--quiet", "HEAD", "--", ...paths]).ok)
581
+ return nothingToCommit(`${paths.join(", ")} has no change to commit`);
341
582
  args.push("--", ...paths);
342
583
  } else if (input.all) {
343
584
  const added = git(cwd, ["add", "-A"]);
344
585
  if (!added.ok) return failure("failed", `git add failed: ${gitError(added)}`);
345
- } else {
586
+ if (changeRequired && git(cwd, ["diff", "--cached", "--quiet"]).ok)
587
+ return nothingToCommit("the working tree is clean");
588
+ } else if (changeRequired) {
346
589
  const staged = git(cwd, ["diff", "--cached", "--name-only", "-z"]);
347
590
  if (staged.ok && !staged.stdout.replaceAll("\0", "")) {
348
591
  const dirt = dirtEntries(cwd);
592
+ if (!dirt.length) return nothingToCommit("the working tree is clean");
349
593
  return failure(
350
- dirt.length ? "blocked" : "failed",
351
- dirt.length
352
- ? `nothing_staged: nothing is staged and workit never commits unrelated changes implicitly (${dirt.length} changed: ${dirt
353
- .slice(0, 5)
354
- .map((entry) => entry.slice(3))
355
- .join(", ")}${dirt.length > 5 ? ", …" : ""})`
356
- : "nothing_to_commit: the working tree is clean",
357
- dirt.length
358
- ? `workit git commit -m '<msg>' -- <paths> # or --all to commit every change`
359
- : undefined,
594
+ "blocked",
595
+ `nothing_staged: nothing is staged and workit never commits unrelated changes implicitly (${dirt.length} changed: ${dirt
596
+ .slice(0, 5)
597
+ .map((entry) => entry.slice(3))
598
+ .join(", ")}${dirt.length > 5 ? ", …" : ""})`,
599
+ `workit git commit -m '<msg>' -- <paths> # or --all to commit every change`,
360
600
  );
361
601
  }
362
602
  }
363
- const committed = git(cwd, args, { timeoutMs: COMMIT_TIMEOUT_MS, env: input.env });
364
- if (!committed.ok) return failure("failed", `git commit failed: ${gitError(committed, 6)}`);
603
+ const committed = git(cwd, args, {
604
+ timeoutMs: COMMIT_TIMEOUT_MS,
605
+ env: input.env,
606
+ });
607
+ if (!committed.ok)
608
+ return failure(
609
+ "failed",
610
+ `git commit failed (a hook or git refused it):\n${gitOutput(committed)}`,
611
+ );
365
612
  const sha = headSha(cwd);
366
613
  if (!sha) return failure("failed", "the commit did not resolve");
614
+ // A merge commit lists what it brought in relative to its first parent.
615
+ const merge = git(cwd, ["rev-parse", "-q", "--verify", `${sha}^2`]).ok;
367
616
  const files = nulList(
368
- git(cwd, ["diff-tree", "--root", "--no-commit-id", "--name-only", "-r", "-z", sha]).stdout,
617
+ git(
618
+ cwd,
619
+ merge
620
+ ? ["diff", "--name-only", "-z", `${sha}^1`, sha]
621
+ : ["diff-tree", "--root", "--no-commit-id", "--name-only", "-r", "-z", sha],
622
+ ).stdout,
369
623
  );
370
624
  const leftDirty = dirtEntries(cwd).length;
625
+ const notes: string[] = [];
626
+ // Only a commit on the branch's own upstream needs a forced push.
627
+ const upstream = `refs/remotes/${pushRemoteName(cwd, branch) ?? "origin"}/${branch}`;
628
+ if (previous && git(cwd, ["merge-base", "--is-ancestor", previous, upstream]).ok)
629
+ notes.push(
630
+ `the amended commit ${previous.slice(0, 12)} was already pushed (${upstream.slice("refs/remotes/".length)}); publish the rewrite with: workit git push --force-with-lease`,
631
+ );
371
632
 
372
633
  // Bounded row (MAX_LINE_BYTES): the subject and the first files only.
373
634
  const listed: string[] = [];
@@ -388,6 +649,7 @@ export function gitCommit(
388
649
  subject: (message.split("\n", 1)[0] ?? "").slice(0, 200),
389
650
  files: listed,
390
651
  fileCount: files.length,
652
+ ...(previous ? { amended: previous } : {}),
391
653
  });
392
654
  return success({
393
655
  sha,
@@ -396,6 +658,8 @@ export function gitCommit(
396
658
  files,
397
659
  leftDirty,
398
660
  session,
661
+ amended: previous,
662
+ notes,
399
663
  recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },
400
664
  });
401
665
  }
@@ -420,6 +684,8 @@ export function pushPreflight(
420
684
  cwd: string,
421
685
  options: { branch?: string | null } = {},
422
686
  ): ForgeResult<PushPlan> {
687
+ const progress = operationInProgress(cwd);
688
+ if (progress) return inProgressFailure(progress.operation, "push");
423
689
  const branch = options.branch ?? currentBranch(cwd);
424
690
  if (!branch) return failure("invalid_input", "HEAD is detached; push a branch");
425
691
  if (isProtectedTarget(cwd, branch))
@@ -490,13 +756,44 @@ export function recordedRemoteTip(cwd: string, plan: PushPlan): string | undefin
490
756
 
491
757
  /**
492
758
  * `--force-if-includes` semantics: the remote tip was integrated locally, i.e.
493
- * it is reachable from the local branch or appears in the branch's reflog
494
- * (the commit was this branch's tip before a rebase/amend).
759
+ * it is reachable from the local branch or from a former tip in the branch's
760
+ * reflog (the branch held it before an amend or rebase). Reachability only:
761
+ * this guards what a forced push may drop.
495
762
  */
496
763
  function includesRemoteTip(cwd: string, branch: string, tip: string): boolean {
497
- if (git(cwd, ["merge-base", "--is-ancestor", tip, `refs/heads/${branch}`]).ok) return true;
498
- const reflog = git(cwd, ["reflog", "show", "--format=%H", `refs/heads/${branch}`, "--"]);
499
- return reflog.ok && reflog.stdout.split("\n").some((line) => line.trim() === tip);
764
+ const ref = `refs/heads/${branch}`;
765
+ if (git(cwd, ["merge-base", "--is-ancestor", tip, ref]).ok) return true;
766
+ const reflog = git(cwd, ["reflog", "show", "--format=%H", ref, "--"]);
767
+ const former = [
768
+ ...new Set(
769
+ (reflog.ok ? reflog.stdout : "")
770
+ .split("\n")
771
+ .map((line) => line.trim())
772
+ .filter(Boolean),
773
+ ),
774
+ ];
775
+ if (former.includes(tip)) return true;
776
+ if (!former.length) return false;
777
+ // Commits reachable from the tip but from no former tip: none means included.
778
+ const outside = git(cwd, ["rev-list", "-n", "1", "--stdin"], {
779
+ input: `${tip}\n${former.map((sha) => `^${sha}`).join("\n")}\n`,
780
+ });
781
+ return outside.ok && !outside.stdout.trim();
782
+ }
783
+
784
+ /**
785
+ * Every commit the remote tip has and the branch lacks is a non-merge commit
786
+ * with a patch-equivalent local commit (the same work, rebased elsewhere).
787
+ * Only words the push advice; it never relaxes the lease guard, because a
788
+ * merge commit can carry changes no patch-id shows.
789
+ */
790
+ function patchEquivalent(cwd: string, branch: string, tip: string): boolean {
791
+ const ref = `refs/heads/${branch}`;
792
+ const merges = git(cwd, ["rev-list", "--count", "--merges", `${ref}..${tip}`]);
793
+ if (!merges.ok || merges.stdout.trim() !== "0") return false;
794
+ const cherry = git(cwd, ["cherry", ref, tip]);
795
+ const lines = cherry.ok ? cherry.stdout.split("\n").filter(Boolean) : [];
796
+ return lines.length > 0 && lines.every((line) => line.startsWith("-"));
500
797
  }
501
798
 
502
799
  export type PushOutcome = {
@@ -526,6 +823,8 @@ export function executePush(
526
823
  setUpstream?: boolean;
527
824
  actor: LedgerActor;
528
825
  timeoutMs?: number;
826
+ /** The pre-push identity check's outcome, kept on the ledger row. */
827
+ identity?: Record<string, unknown>;
529
828
  },
530
829
  ): ForgeResult<PushOutcome> {
531
830
  const before = remoteRefTip(cwd, plan.rawUrl, `refs/heads/${plan.branch}`);
@@ -578,13 +877,38 @@ export function executePush(
578
877
  `lease_mismatch: ${plan.remote}/${plan.branch} moved while pushing`,
579
878
  `git fetch ${plan.remote} ${plan.branch} && git rebase ${plan.remote}/${plan.branch} # or merge it; then: workit git push`,
580
879
  );
880
+ const rejected = /non-fast-forward|fetch first|\[rejected\]/iu.test(text);
881
+ const integrated =
882
+ rejected && before.sha !== null && includesRemoteTip(cwd, plan.branch, before.sha);
883
+ const equivalent =
884
+ rejected &&
885
+ !integrated &&
886
+ before.sha !== null &&
887
+ patchEquivalent(cwd, plan.branch, before.sha);
888
+ if (before.sha !== null && (integrated || equivalent)) {
889
+ // An amend or rebase rewrote commits that are already pushed: a fetch
890
+ // and rebase would re-apply the old ones. The lease is the tip seen now.
891
+ const lease =
892
+ recordedRemoteTip(cwd, plan) === before.sha
893
+ ? "workit git push --force-with-lease"
894
+ : `workit git push --force-with-lease --expect ${before.sha}`;
895
+ return failure(
896
+ "failed",
897
+ integrated
898
+ ? `non_fast_forward: local ${plan.branch} was rewritten (amend or rebase) after ${plan.remote}/${plan.branch} (${before.sha.slice(0, 12)}) was pushed; that tip is already part of the local history, so do not fetch and rebase`
899
+ : `non_fast_forward: every commit on ${plan.remote}/${plan.branch} (${before.sha.slice(0, 12)}) has a patch-equivalent commit in local ${plan.branch} (rebased elsewhere); fetching and rebasing would apply them twice`,
900
+ integrated
901
+ ? lease
902
+ : `${lease} --overwrite-unintegrated # after checking ${plan.remote}/${plan.branch} holds nothing else`,
903
+ );
904
+ }
581
905
  if (/non-fast-forward|fetch first|\[rejected\]/iu.test(text))
582
906
  return failure(
583
907
  "failed",
584
908
  `non_fast_forward: ${plan.remote}/${plan.branch} has commits this branch does not`,
585
909
  `git fetch ${plan.remote} && git rebase ${plan.remote}/${plan.branch} # or, if the rewrite is intended: workit git push --force-with-lease`,
586
910
  );
587
- return failure("failed", `git push to ${plan.url} failed: ${gitError(run)}`);
911
+ return failure("failed", `git push to ${plan.url} failed:\n${gitOutput(run)}`);
588
912
  }
589
913
  pushed = true;
590
914
  }
@@ -618,6 +942,7 @@ export function executePush(
618
942
  previous: before.sha,
619
943
  pushed,
620
944
  forced,
945
+ ...(input.identity ? { identity: input.identity } : {}),
621
946
  });
622
947
  return success({
623
948
  remote: plan.remote,
@@ -669,6 +994,10 @@ export function deleteRemoteBranch(
669
994
  if (run.ok) return { ok: true };
670
995
  const text = `${run.stderr}\n${run.stdout}`;
671
996
  return /stale info|cannot lock ref|fetch first|non-fast-forward/iu.test(text)
672
- ? { ok: false, lease: true, error: "the remote branch tip changed before deletion" }
997
+ ? {
998
+ ok: false,
999
+ lease: true,
1000
+ error: "the remote branch tip changed before deletion",
1001
+ }
673
1002
  : { ok: false, lease: false, error: gitError(run) };
674
1003
  }
package/src/git/rev.ts CHANGED
@@ -879,15 +879,20 @@ export function pushForge(
879
879
  const forge = deriveForge(url, options);
880
880
  if (!forge) {
881
881
  // Never echo the raw URL: it may carry a token (https://user:tok@…).
882
- const host = parseRemoteUrl(url)?.host || null;
882
+ const parsed = parseRemoteUrl(url);
883
+ const host = parsed?.host || null;
883
884
  return {
884
885
  ok: false,
885
886
  code: "unavailable",
886
887
  error: host
887
- ? `cannot tell whether push host "${host}" is GitHub or GitLab`
888
+ ? parsed?.protocol === "ssh" && !host.includes(".")
889
+ ? `unsupported_forge: could not resolve ssh alias "${host}" to GitHub or GitLab; PR and CI verbs need one (workit git push still pushes with git)`
890
+ : parsed?.protocol === "ssh"
891
+ ? `unsupported_forge: ssh host "${host}" is not a GitHub/GitLab host or an alias workit can resolve; PR and CI verbs need one (workit git push still pushes with git)`
892
+ : `unsupported_forge: cannot tell whether push host "${host}" is GitHub or GitLab; PR and CI verbs need one (workit git push still pushes with git)`
888
893
  : `push URL ${redactRemote(url)} of remote "${remote}" is not a GitHub/GitLab URL`,
889
894
  unblock: host
890
- ? `map the alias in ~/.ssh/config (Host ${host} / HostName github.com|gitlab.com) or set github.host / gitlab.host in ~/.config/workit/vcs.json`
895
+ ? `if ${host} is an ssh alias of GitHub/GitLab, add "Host ${host}" with "HostName github.com" (or gitlab.com) to ~/.ssh/config; if it is a self-hosted GitHub/GitLab, set github.host or gitlab.host in ~/.config/workit/vcs.json; other forges (Bitbucket, Gitea…) have no PR/CI verbs`
891
896
  : `git remote set-url --push ${remote} <ssh-or-https-url>`,
892
897
  };
893
898
  }
@@ -906,7 +911,7 @@ export function forgeConflict(
906
911
  if (!configured || configured === derived.kind) return null;
907
912
  return {
908
913
  code: "blocked",
909
- error: `forge_mismatch: push remote ${derived.host} is ${derived.kind} but the workspace vcs.provider is ${configured}`,
910
- unblock: `set vcs.provider to "${derived.kind}" for this repo in ~/.config/workit/workspaces.json (workit-github-override), or push to a ${configured} remote`,
914
+ error: `forge_mismatch: push remote ${derived.host} is ${derived.kind} but the workspace vcs.provider is ${configured}, so its account and grants do not apply; PR and CI verbs stop here (workit git push still pushes with git)`,
915
+ unblock: `add a workspace whose glob matches only this repo, with "vcs": {"provider": "${derived.kind}"} (and its account), to ~/.config/workit/workspaces.json; the most specific glob wins`,
911
916
  };
912
917
  }