@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/package.json +1 -1
- package/src/core/branch-policy.ts +37 -10
- package/src/core/branch.ts +2 -0
- package/src/core/config.ts +48 -29
- package/src/core/workspaces.ts +89 -39
- package/src/forge/exec.ts +72 -3
- package/src/forge/report.ts +4 -3
- package/src/forge/resolve.ts +328 -78
- package/src/git/ops.ts +363 -34
- package/src/git/rev.ts +10 -5
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 = {
|
|
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
|
-
|
|
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
|
|
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
|
|
310
|
-
if (!
|
|
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
|
|
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
|
|
326
|
-
if (
|
|
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
|
-
|
|
335
|
-
|
|
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
|
-
|
|
340
|
-
|
|
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
|
-
|
|
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
|
-
|
|
351
|
-
dirt.length
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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, {
|
|
364
|
-
|
|
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(
|
|
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
|
|
494
|
-
* (the
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
|
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
|
-
? {
|
|
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
|
|
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
|
-
?
|
|
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
|
-
? `
|
|
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: `
|
|
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
|
}
|