@brainervirus/workit-core 8.0.1 → 8.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "8.0.1",
3
+ "version": "8.1.0",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -9,17 +9,34 @@ const PREFIXES = {
9
9
  hotfix: "hotfix/*",
10
10
  };
11
11
 
12
+ /**
13
+ * Local branch names plus origin's remote-tracking ones (`origin/develop`
14
+ * counts as `develop`; a fork or mirror remote's branches do not).
15
+ */
16
+ const branchNames = (workspaceRoot: string): Set<string> => {
17
+ const r = spawnSync(
18
+ "git",
19
+ ["for-each-ref", "--format=%(refname)", "refs/heads", "refs/remotes/origin"],
20
+ { cwd: workspaceRoot, encoding: "utf8" },
21
+ );
22
+ if (r.status !== 0) return new Set();
23
+ return new Set(
24
+ (r.stdout ?? "")
25
+ .split("\n")
26
+ .filter(Boolean)
27
+ .map((ref) =>
28
+ ref.startsWith("refs/heads/")
29
+ ? ref.slice("refs/heads/".length)
30
+ : ref.slice("refs/remotes/origin/".length),
31
+ ),
32
+ );
33
+ };
34
+
12
35
  export const detectBranchPolicy = (workspaceRoot: string) => {
13
- const branchExists = (name: string): boolean => {
14
- const r = spawnSync("git", ["branch", "--list", name], {
15
- cwd: workspaceRoot,
16
- encoding: "utf8",
17
- });
18
- return r.status === 0 && (r.stdout ?? "").trim() !== "";
19
- };
20
- const develop = branchExists("develop");
21
- const main = branchExists("main");
22
- const master = branchExists("master");
36
+ const names = branchNames(workspaceRoot);
37
+ const develop = names.has("develop");
38
+ const main = names.has("main");
39
+ const master = names.has("master");
23
40
  const root = main ? "main" : master ? "master" : null;
24
41
 
25
42
  if (develop) {
@@ -61,3 +78,13 @@ export const detectBranchPolicy = (workspaceRoot: string) => {
61
78
  prefixes: PREFIXES,
62
79
  };
63
80
  };
81
+
82
+ /**
83
+ * The preset this repository's branches imply (develop -> gitflow, main ->
84
+ * github-flow, master -> trunk-based), or null when they imply none. Used
85
+ * only when no branch policy is configured anywhere (core/config.ts).
86
+ */
87
+ export const repoBranchPreset = (workspaceRoot: string): BranchPreset | null => {
88
+ const detected = detectBranchPolicy(workspaceRoot);
89
+ return detected.developBranch || detected.allowed.length ? detected.preset : null;
90
+ };
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { createHash } from "node:crypto";
4
4
  import path from "node:path";
5
+ import { repoBranchPreset } from "./branch-policy";
5
6
  import { matchCommitFlavor } from "./commit-flavors";
6
7
  import { gitContext } from "./git";
7
8
  import {
@@ -100,6 +101,7 @@ function effectiveWorkspacePolicy(
100
101
  resolveConfiguredBranchPolicy(
101
102
  config,
102
103
  selected.policy ? { branchPolicy: selected.policy as WorkspaceBranchPolicy } : null,
104
+ () => repoBranchPreset(workspaceRoot),
103
105
  ),
104
106
  ),
105
107
  provenance: { branchPolicy: selected.source },
@@ -138,6 +138,12 @@ const DEFAULTS: ToolkitConfig = {
138
138
  commitPolicy: { preset: "conventional" },
139
139
  };
140
140
 
141
+ // Configs whose branch policy nobody chose (no config.json, or one without a
142
+ // valid branchPolicy.preset). For them the repository's own branches pick the
143
+ // preset (resolveBranchPolicy's `repoPreset`), so a main-only repository is
144
+ // not held to gitflow's `develop`.
145
+ const unchosenBranchPolicy = new WeakSet<ToolkitConfig>([DEFAULTS]);
146
+
141
147
  const readSafe = (p: string): string | null => {
142
148
  try {
143
149
  return readFileSync(p, "utf8");
@@ -191,36 +197,35 @@ const parseConfigResult = (raw: string | null, file: string): ReaderResult<Toolk
191
197
  ? input.commitPolicy?.preset
192
198
  : "conventional"
193
199
  ) as CommitFlavorPreset;
194
- return {
195
- status: "valid",
196
- path: file,
197
- config: {
198
- locale,
199
- localeOptions: Array.isArray(input.localeOptions)
200
- ? input.localeOptions
201
- : DEFAULTS.localeOptions,
202
- // RL-02/CA-23: the persisted preset is authoritative; derived allowed /
203
- // protected fields always reset from PRESETS via the one shared merge.
204
- branchPolicy: mergePreset(
205
- preset,
206
- {
207
- allowed: Array.isArray(input.branchPolicy?.allowed)
208
- ? input.branchPolicy.allowed
209
- : undefined,
210
- protectedNames: Array.isArray(input.branchPolicy?.protected)
211
- ? input.branchPolicy.protected
212
- : undefined,
213
- },
214
- DEFAULTS,
215
- ),
216
- commitPolicy: {
217
- preset: commitPreset,
218
- ...(typeof input.commitPolicy?.pattern === "string"
219
- ? { pattern: input.commitPolicy.pattern }
220
- : {}),
200
+ const config: ToolkitConfig = {
201
+ locale,
202
+ localeOptions: Array.isArray(input.localeOptions)
203
+ ? input.localeOptions
204
+ : DEFAULTS.localeOptions,
205
+ // RL-02/CA-23: the persisted preset is authoritative; derived allowed /
206
+ // protected fields always reset from PRESETS via the one shared merge.
207
+ branchPolicy: mergePreset(
208
+ preset,
209
+ {
210
+ allowed: Array.isArray(input.branchPolicy?.allowed)
211
+ ? input.branchPolicy.allowed
212
+ : undefined,
213
+ protectedNames: Array.isArray(input.branchPolicy?.protected)
214
+ ? input.branchPolicy.protected
215
+ : undefined,
221
216
  },
217
+ DEFAULTS,
218
+ ),
219
+ commitPolicy: {
220
+ preset: commitPreset,
221
+ ...(typeof input.commitPolicy?.pattern === "string"
222
+ ? { pattern: input.commitPolicy.pattern }
223
+ : {}),
222
224
  },
223
225
  };
226
+ if (!Object.hasOwn(PRESETS, input.branchPolicy?.preset as string))
227
+ unchosenBranchPolicy.add(config);
228
+ return { status: "valid", path: file, config };
224
229
  };
225
230
 
226
231
  export const readConfigTyped = (dir?: string): ReaderResult<ToolkitConfig> => {
@@ -298,19 +303,28 @@ export const branchGlobPattern = (glob: string): RegExp =>
298
303
  export const resolveBranchPolicy = (
299
304
  config: ToolkitConfig,
300
305
  workspace?: { branchPolicy?: Record<string, any> } | null,
306
+ /** The preset the repository's branches imply (core/branch-policy.ts repoBranchPreset). */
307
+ repoPreset?: () => BranchPreset | null,
301
308
  ): {
302
309
  preset: BranchPreset;
303
310
  allowed: RegExp[];
304
311
  protected: Set<string>;
305
312
  integration: "pr" | "merge";
306
313
  defaultTargetBranch: string;
314
+ /** No policy was configured; the repository's branches chose the preset. */
315
+ detected: boolean;
307
316
  } => {
308
317
  const wp = workspace?.branchPolicy ?? {};
309
318
  // An invalid workspace preset (e.g. a typo) falls back to the global preset,
310
319
  // preserving resolution order workspace > global > preset, instead of
311
320
  // crashing on PRESETS[preset] (mirrors parseConfigResult's Object.hasOwn).
321
+ const workspacePreset = Object.hasOwn(PRESETS, wp.preset);
322
+ // With no policy chosen anywhere, the repository's branches pick the preset
323
+ // (M10); the gitflow protected names stay protected regardless.
324
+ const detected =
325
+ !workspacePreset && unchosenBranchPolicy.has(config) ? (repoPreset?.() ?? null) : null;
312
326
  const preset = (
313
- Object.hasOwn(PRESETS, wp.preset) ? wp.preset : (config.branchPolicy?.preset ?? "gitflow")
327
+ workspacePreset ? wp.preset : (detected ?? config.branchPolicy?.preset ?? "gitflow")
314
328
  ) as BranchPreset;
315
329
  // RL-02: the preset is authoritative — allowed/protected re-derive from the
316
330
  // workspace's own values or the preset table, never the global config's, and
@@ -327,11 +341,16 @@ export const resolveBranchPolicy = (
327
341
  return {
328
342
  preset,
329
343
  allowed,
330
- protected: new Set(merged.protected.map((p) => p.toLowerCase())),
344
+ protected: new Set(
345
+ [...merged.protected, ...(detected ? PRESETS.gitflow.protected : [])].map((p) =>
346
+ p.toLowerCase(),
347
+ ),
348
+ ),
331
349
  integration: wp.integration === "merge" ? "merge" : "pr",
332
350
  // CA-05: preset-aware default target when vcs.defaultTargetBranch is unset.
333
351
  defaultTargetBranch:
334
352
  preset === "github-flow" ? "main" : preset === "trunk-based" ? "master" : "develop",
353
+ detected: detected !== null,
335
354
  };
336
355
  };
337
356
 
@@ -14,6 +14,7 @@ import {
14
14
  type ToolkitConfig,
15
15
  } from "./config";
16
16
  import type { CommitFlavorPreset } from "./commit-flavors";
17
+ import { repoBranchPreset } from "./branch-policy";
17
18
 
18
19
  export type VcsProvider = "gitlab" | "github";
19
20
 
@@ -731,6 +732,8 @@ export const resolveWorkspacePolicy = (
731
732
  config: ToolkitConfig,
732
733
  workspace: WorkspaceConfig | null,
733
734
  profileName?: string,
735
+ /** The preset the checkout's branches imply, used when no policy is configured. */
736
+ repoPreset?: () => BranchPreset | null,
734
737
  ): WorkspacePolicyResolution | { status: "invalid"; error: string } => {
735
738
  const selected = workspace
736
739
  ? resolveWorkspaceProfile(workspace, profileName)
@@ -745,7 +748,7 @@ export const resolveWorkspacePolicy = (
745
748
  status: "resolved",
746
749
  workspace: selected.workspace,
747
750
  profileName: selected.profileName,
748
- branchPolicy: resolveConfiguredBranchPolicy(config, selected.workspace),
751
+ branchPolicy: resolveConfiguredBranchPolicy(config, selected.workspace, repoPreset),
749
752
  commitPolicy: resolveConfiguredCommitPolicy(config, selected.workspace),
750
753
  provenance: selected.provenance,
751
754
  };
@@ -792,6 +795,7 @@ export function workspaceDefaultTarget(cwd: string, globalDefault: unknown): str
792
795
  const policyDefault = resolveConfiguredBranchPolicy(
793
796
  readConfig(),
794
797
  selected.policy ? { branchPolicy: selected.policy as WorkspaceBranchPolicy } : null,
798
+ () => repoBranchPreset(cwd),
795
799
  ).defaultTargetBranch;
796
800
  return String(
797
801
  (ws?.vcs as { defaultTargetBranch?: unknown } | undefined)?.defaultTargetBranch ??
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 = {
@@ -578,13 +875,38 @@ export function executePush(
578
875
  `lease_mismatch: ${plan.remote}/${plan.branch} moved while pushing`,
579
876
  `git fetch ${plan.remote} ${plan.branch} && git rebase ${plan.remote}/${plan.branch} # or merge it; then: workit git push`,
580
877
  );
878
+ const rejected = /non-fast-forward|fetch first|\[rejected\]/iu.test(text);
879
+ const integrated =
880
+ rejected && before.sha !== null && includesRemoteTip(cwd, plan.branch, before.sha);
881
+ const equivalent =
882
+ rejected &&
883
+ !integrated &&
884
+ before.sha !== null &&
885
+ patchEquivalent(cwd, plan.branch, before.sha);
886
+ if (before.sha !== null && (integrated || equivalent)) {
887
+ // An amend or rebase rewrote commits that are already pushed: a fetch
888
+ // and rebase would re-apply the old ones. The lease is the tip seen now.
889
+ const lease =
890
+ recordedRemoteTip(cwd, plan) === before.sha
891
+ ? "workit git push --force-with-lease"
892
+ : `workit git push --force-with-lease --expect ${before.sha}`;
893
+ return failure(
894
+ "failed",
895
+ integrated
896
+ ? `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`
897
+ : `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`,
898
+ integrated
899
+ ? lease
900
+ : `${lease} --overwrite-unintegrated # after checking ${plan.remote}/${plan.branch} holds nothing else`,
901
+ );
902
+ }
581
903
  if (/non-fast-forward|fetch first|\[rejected\]/iu.test(text))
582
904
  return failure(
583
905
  "failed",
584
906
  `non_fast_forward: ${plan.remote}/${plan.branch} has commits this branch does not`,
585
907
  `git fetch ${plan.remote} && git rebase ${plan.remote}/${plan.branch} # or, if the rewrite is intended: workit git push --force-with-lease`,
586
908
  );
587
- return failure("failed", `git push to ${plan.url} failed: ${gitError(run)}`);
909
+ return failure("failed", `git push to ${plan.url} failed:\n${gitOutput(run)}`);
588
910
  }
589
911
  pushed = true;
590
912
  }
@@ -669,6 +991,10 @@ export function deleteRemoteBranch(
669
991
  if (run.ok) return { ok: true };
670
992
  const text = `${run.stderr}\n${run.stdout}`;
671
993
  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" }
994
+ ? {
995
+ ok: false,
996
+ lease: true,
997
+ error: "the remote branch tip changed before deletion",
998
+ }
673
999
  : { ok: false, lease: false, error: gitError(run) };
674
1000
  }