@brainervirus/workit-core 2.4.0 → 2.5.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 +1 -1
- package/src/autonomy.ts +135 -0
- package/src/core/external-action-effects.ts +4 -9
- package/src/core/pr-create.ts +2 -71
- package/src/forge/exec.ts +40 -0
- package/src/forge/github.ts +84 -1
- package/src/forge/gitlab.ts +102 -1
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-ops.ts +450 -0
- package/src/forge/report.ts +1 -1
- package/src/forge/types.ts +30 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/ops.ts +626 -0
- package/src/git/rev.ts +24 -7
- package/src/ledger.ts +17 -1
package/src/git/ops.ts
ADDED
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
// `workit git branch|commit|push` (design §2.1 S11): policy-checked local git
|
|
2
|
+
// effects plus an observed, verified push. The CLI verb only parses flags and
|
|
3
|
+
// resolves the forge identity; every rule lives here.
|
|
4
|
+
//
|
|
5
|
+
// - branch: the name must pass the workspace branch policy (protected names
|
|
6
|
+
// and allowed patterns, core/branch.ts). The base defaults to the policy's
|
|
7
|
+
// default target and is taken from the freshly fetched remote branch; a
|
|
8
|
+
// local, unprotected, non-default base is a stack parent and is used as is.
|
|
9
|
+
// Dirt outside docs/ and untracked files needs --carry.
|
|
10
|
+
// - commit: never on a protected branch; the subject must pass the workspace
|
|
11
|
+
// commit convention (auto = the flavor the history uses). Only the index,
|
|
12
|
+
// the named paths (`--only` semantics) or, with --all, everything is
|
|
13
|
+
// committed: unrelated dirt is never swept in implicitly. The acting session
|
|
14
|
+
// goes into a `Workit-Session:` trailer and a `commit.recorded` ledger row,
|
|
15
|
+
// so a verdict from the same session reads as the author's (D18).
|
|
16
|
+
// - push: never to a protected branch, an exact SHA (a commit that lands while
|
|
17
|
+
// pushing is not pushed by accident), force only as --force-with-lease with
|
|
18
|
+
// the tip workit last recorded, and success only when the remote tip
|
|
19
|
+
// observed afterwards equals the local SHA (`push.verified` row).
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
21
|
+
import {
|
|
22
|
+
classifyBranchDirt,
|
|
23
|
+
isProtectedTarget,
|
|
24
|
+
resolveCommitPolicyFor,
|
|
25
|
+
validateBranchNameFor,
|
|
26
|
+
validateCommitMessageFor,
|
|
27
|
+
} from "../core/branch";
|
|
28
|
+
import { detectCommitFlavor, matchCommitFlavor } from "../core/commit-flavors";
|
|
29
|
+
import { pushTargetIsStable } from "../core/pr-create";
|
|
30
|
+
import { vcsConfig } from "../core/vcs-config";
|
|
31
|
+
import { redactText } from "../forge/redact";
|
|
32
|
+
import { failure, success, type ForgeResult } from "../forge/types";
|
|
33
|
+
import { appendObserved, readLedger, type LedgerActor } from "../ledger";
|
|
34
|
+
import {
|
|
35
|
+
GIT_TIMEOUTS,
|
|
36
|
+
currentBranch,
|
|
37
|
+
fetchRefs,
|
|
38
|
+
gitNetwork,
|
|
39
|
+
headSha,
|
|
40
|
+
parseRemoteUrl,
|
|
41
|
+
pushRemoteName,
|
|
42
|
+
pushUrl,
|
|
43
|
+
redactRemote,
|
|
44
|
+
remoteNames,
|
|
45
|
+
remoteRefTip,
|
|
46
|
+
resolveRef,
|
|
47
|
+
} from "./rev";
|
|
48
|
+
|
|
49
|
+
/** Commits run the repository's hooks (lint, tests), so they get a long bound. */
|
|
50
|
+
const COMMIT_TIMEOUT_MS = 10 * 60_000;
|
|
51
|
+
const PUSH_TIMEOUT_MS = 120_000;
|
|
52
|
+
|
|
53
|
+
type Run = { ok: boolean; status: number | null; stdout: string; stderr: string };
|
|
54
|
+
|
|
55
|
+
const git = (
|
|
56
|
+
cwd: string,
|
|
57
|
+
args: string[],
|
|
58
|
+
options: { timeoutMs?: number; env?: NodeJS.ProcessEnv } = {},
|
|
59
|
+
): Run => {
|
|
60
|
+
const result = spawnSync("git", args, {
|
|
61
|
+
cwd,
|
|
62
|
+
encoding: "utf8",
|
|
63
|
+
env: { ...(options.env ?? process.env), GIT_TERMINAL_PROMPT: "0" },
|
|
64
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
65
|
+
timeout: options.timeoutMs ?? GIT_TIMEOUTS.local,
|
|
66
|
+
killSignal: "SIGKILL",
|
|
67
|
+
windowsHide: true,
|
|
68
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
69
|
+
});
|
|
70
|
+
return {
|
|
71
|
+
ok: result.status === 0,
|
|
72
|
+
status: result.status,
|
|
73
|
+
stdout: result.stdout ?? "",
|
|
74
|
+
stderr: result.stderr ?? "",
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** The last meaningful lines of git's stderr, redacted, for an error message. */
|
|
79
|
+
const gitError = (run: { stderr: string; stdout?: string }, max = 3): string => {
|
|
80
|
+
const lines = redactText(`${run.stderr}\n${run.stdout ?? ""}`)
|
|
81
|
+
.split(/\r?\n/u)
|
|
82
|
+
.map((value) => value.trim())
|
|
83
|
+
.filter((value) => value && !value.startsWith("hint:"));
|
|
84
|
+
return lines.slice(-max).join(" / ").slice(0, 400) || "git failed";
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const validRefName = (cwd: string, name: string): boolean =>
|
|
88
|
+
!name.startsWith("-") && git(cwd, ["check-ref-format", "--branch", name]).ok;
|
|
89
|
+
|
|
90
|
+
/** Porcelain v1 entries (`XY path`), NUL-separated so any file name survives. */
|
|
91
|
+
function dirtEntries(cwd: string): string[] {
|
|
92
|
+
const run = git(cwd, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
|
|
93
|
+
if (!run.ok) return [];
|
|
94
|
+
const parts = run.stdout.split("\0");
|
|
95
|
+
const out: string[] = [];
|
|
96
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
97
|
+
const entry = parts[index];
|
|
98
|
+
if (!entry) continue;
|
|
99
|
+
out.push(entry);
|
|
100
|
+
// A rename/copy carries its source path in the next field.
|
|
101
|
+
if (entry[0] === "R" || entry[0] === "C") index += 1;
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const nulList = (text: string): string[] => text.split("\0").filter(Boolean);
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// branch
|
|
110
|
+
|
|
111
|
+
export type BranchOutcome = {
|
|
112
|
+
branch: string;
|
|
113
|
+
previous: string | null;
|
|
114
|
+
base: string;
|
|
115
|
+
/** The ref the branch was created from (refs/remotes/origin/main, refs/heads/feature/a, a sha). */
|
|
116
|
+
baseRef: string;
|
|
117
|
+
baseSha: string;
|
|
118
|
+
/** Uncommitted changes came along to the new branch. */
|
|
119
|
+
carried: boolean;
|
|
120
|
+
/** Fallbacks taken (fetch failed, base used from the local copy). */
|
|
121
|
+
notes: string[];
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export function gitBranch(
|
|
125
|
+
cwd: string,
|
|
126
|
+
input: { name: string; base?: string | null; carry?: boolean },
|
|
127
|
+
): ForgeResult<BranchOutcome> {
|
|
128
|
+
const name = input.name.trim();
|
|
129
|
+
if (!headSha(cwd) && currentBranch(cwd) === null)
|
|
130
|
+
return failure("invalid_input", "not inside a git repository with a commit");
|
|
131
|
+
if (!validRefName(cwd, name))
|
|
132
|
+
return failure("invalid_input", `invalid branch name ${JSON.stringify(name)}`);
|
|
133
|
+
const policy = validateBranchNameFor(cwd, name);
|
|
134
|
+
if (!policy.ok) return failure("blocked", policy.error, policy.correction);
|
|
135
|
+
if (resolveRef(cwd, `refs/heads/${name}`))
|
|
136
|
+
return failure("failed", `branch_exists: ${name} already exists`, `git switch ${name}`);
|
|
137
|
+
|
|
138
|
+
let base = input.base?.trim() || null;
|
|
139
|
+
let defaultTarget: string | null = null;
|
|
140
|
+
const resolved = vcsConfig("resolve", cwd);
|
|
141
|
+
if (resolved.ok === false && !base)
|
|
142
|
+
return failure(
|
|
143
|
+
"blocked",
|
|
144
|
+
String(resolved.error),
|
|
145
|
+
"fix ~/.config/workit/vcs.json or pass --base <branch>",
|
|
146
|
+
);
|
|
147
|
+
if (resolved.ok !== false) defaultTarget = String(resolved.defaultTargetBranch ?? "") || null;
|
|
148
|
+
base ??= defaultTarget;
|
|
149
|
+
if (!base)
|
|
150
|
+
return failure("invalid_input", "no default target branch is configured; pass --base <branch>");
|
|
151
|
+
if (base.startsWith("-")) return failure("invalid_input", "--base must not start with -");
|
|
152
|
+
|
|
153
|
+
const notes: string[] = [];
|
|
154
|
+
const remotes = remoteNames(cwd);
|
|
155
|
+
const remote = remotes.includes("origin") ? "origin" : pushRemoteName(cwd);
|
|
156
|
+
const localTip = resolveRef(cwd, `refs/heads/${base}`);
|
|
157
|
+
// A local, unprotected branch that is not the default target is a stack
|
|
158
|
+
// parent: its local tip is the truth (it may not be pushed yet).
|
|
159
|
+
const stackParent = localTip !== null && base !== defaultTarget && !isProtectedTarget(cwd, base);
|
|
160
|
+
let baseRef: string | null = null;
|
|
161
|
+
if (stackParent) baseRef = `refs/heads/${base}`;
|
|
162
|
+
else if (remote && validRefName(cwd, base)) {
|
|
163
|
+
const tracking = `refs/remotes/${remote}/${base}`;
|
|
164
|
+
const fetched = fetchRefs(cwd, remote, [`+refs/heads/${base}:${tracking}`]);
|
|
165
|
+
if (fetched.ok && resolveRef(cwd, tracking)) baseRef = tracking;
|
|
166
|
+
else if (resolveRef(cwd, tracking)) {
|
|
167
|
+
baseRef = tracking;
|
|
168
|
+
notes.push(
|
|
169
|
+
`fetch of ${remote}/${base} failed; branched from the last fetched ${remote}/${base}`,
|
|
170
|
+
);
|
|
171
|
+
} else if (localTip) {
|
|
172
|
+
baseRef = `refs/heads/${base}`;
|
|
173
|
+
notes.push(`${remote}/${base} is not available; branched from local ${base}`);
|
|
174
|
+
}
|
|
175
|
+
} else if (localTip) baseRef = `refs/heads/${base}`;
|
|
176
|
+
if (!baseRef && git(cwd, ["rev-parse", "--verify", "-q", `${base}^{commit}`]).ok) baseRef = base;
|
|
177
|
+
const baseSha = baseRef
|
|
178
|
+
? git(cwd, ["rev-parse", "--verify", "-q", `${baseRef}^{commit}`]).stdout.trim()
|
|
179
|
+
: "";
|
|
180
|
+
if (!baseRef || !baseSha)
|
|
181
|
+
return failure(
|
|
182
|
+
"not_found",
|
|
183
|
+
`base ${base} does not resolve (no ${remote ?? "remote"}/${base}, no local ${base})`,
|
|
184
|
+
`git fetch ${remote ?? "origin"} ${base} # or pass --base <ref>`,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
const dirt = classifyBranchDirt(cwd);
|
|
188
|
+
if (dirt === "stash-required" && !input.carry)
|
|
189
|
+
return failure(
|
|
190
|
+
"blocked",
|
|
191
|
+
"dirty_worktree: tracked changes outside docs/ would ride along to the new branch",
|
|
192
|
+
`commit them first (workit git commit -m … -- <paths>), or pass --carry to bring them along`,
|
|
193
|
+
);
|
|
194
|
+
const previous = currentBranch(cwd);
|
|
195
|
+
const switched = git(cwd, ["switch", "--no-track", "-c", name, baseSha]);
|
|
196
|
+
if (!switched.ok) return failure("failed", `git switch -c ${name} failed: ${gitError(switched)}`);
|
|
197
|
+
return success({
|
|
198
|
+
branch: name,
|
|
199
|
+
previous,
|
|
200
|
+
base,
|
|
201
|
+
baseRef,
|
|
202
|
+
baseSha,
|
|
203
|
+
carried: dirt !== "clean",
|
|
204
|
+
notes,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// commit
|
|
210
|
+
|
|
211
|
+
export type CommitOutcome = {
|
|
212
|
+
sha: string;
|
|
213
|
+
branch: string;
|
|
214
|
+
message: string;
|
|
215
|
+
files: string[];
|
|
216
|
+
/** Changes left uncommitted (not selected). */
|
|
217
|
+
leftDirty: number;
|
|
218
|
+
session: string | null;
|
|
219
|
+
/** The ledger row id, or why it could not be written (the commit stands). */
|
|
220
|
+
recorded: { id: string } | { error: string };
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/** The commit convention check: the configured preset, or the history's flavor for `auto`. */
|
|
224
|
+
export function lintCommitMessage(
|
|
225
|
+
cwd: string,
|
|
226
|
+
message: string,
|
|
227
|
+
): { ok: true } | { ok: false; error: string; correction: string } {
|
|
228
|
+
const check = validateCommitMessageFor(cwd, message);
|
|
229
|
+
if (!check.ok) return { ok: false, error: check.error, correction: check.correction };
|
|
230
|
+
let preset: string;
|
|
231
|
+
try {
|
|
232
|
+
preset = resolveCommitPolicyFor(cwd).preset;
|
|
233
|
+
} catch {
|
|
234
|
+
return { ok: true };
|
|
235
|
+
}
|
|
236
|
+
if (preset !== "auto") return { ok: true };
|
|
237
|
+
const log = git(cwd, ["log", "-n", "50", "--no-merges", "--format=%s"]);
|
|
238
|
+
const subjects = log.ok ? log.stdout.split("\n").filter(Boolean) : [];
|
|
239
|
+
const detected = detectCommitFlavor(subjects).flavor;
|
|
240
|
+
if (!detected || matchCommitFlavor(message, detected)) return { ok: true };
|
|
241
|
+
const subject = message.split("\n", 1)[0] ?? "";
|
|
242
|
+
return {
|
|
243
|
+
ok: false,
|
|
244
|
+
error: `commit_style: commit subject ${JSON.stringify(subject)} does not follow the ${detected} style this repository uses (commitPolicy auto)`,
|
|
245
|
+
correction: `use a ${detected} commit subject`,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const SESSION_SAFE = /^[\w.:@/+-]{1,128}$/u;
|
|
250
|
+
|
|
251
|
+
export function gitCommit(
|
|
252
|
+
cwd: string,
|
|
253
|
+
input: {
|
|
254
|
+
message: string;
|
|
255
|
+
all?: boolean;
|
|
256
|
+
paths?: readonly string[];
|
|
257
|
+
actor: LedgerActor;
|
|
258
|
+
env?: NodeJS.ProcessEnv;
|
|
259
|
+
},
|
|
260
|
+
): ForgeResult<CommitOutcome> {
|
|
261
|
+
const message = input.message.trim();
|
|
262
|
+
if (!message) return failure("invalid_input", "a commit message is required (-m <msg>)");
|
|
263
|
+
const paths = input.paths ?? [];
|
|
264
|
+
if (input.all && paths.length) return failure("invalid_input", "pass --all or paths, not both");
|
|
265
|
+
const branch = currentBranch(cwd);
|
|
266
|
+
if (!branch)
|
|
267
|
+
return failure(
|
|
268
|
+
"invalid_input",
|
|
269
|
+
"HEAD is detached; commit on a branch (workit git branch <name>)",
|
|
270
|
+
);
|
|
271
|
+
if (isProtectedTarget(cwd, branch))
|
|
272
|
+
return failure(
|
|
273
|
+
"blocked",
|
|
274
|
+
`protected_branch: ${branch} is protected by the workspace branch policy; workit never commits to it`,
|
|
275
|
+
"workit git branch <feature/name> # then commit there",
|
|
276
|
+
);
|
|
277
|
+
const lint = lintCommitMessage(cwd, message);
|
|
278
|
+
if (!lint.ok) return failure("blocked", lint.error, lint.correction);
|
|
279
|
+
const session = input.actor.session;
|
|
280
|
+
if (session && !SESSION_SAFE.test(session))
|
|
281
|
+
return failure(
|
|
282
|
+
"invalid_input",
|
|
283
|
+
"WORKIT_SESSION_ID must be 1-128 characters of [A-Za-z0-9_.:@/+-]",
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const args = ["commit", "--no-edit", "-m", message];
|
|
287
|
+
if (session) args.push("--trailer", `Workit-Session: ${session}`);
|
|
288
|
+
if (paths.length) {
|
|
289
|
+
if (paths.some((value) => value.startsWith("-") || !value.trim()))
|
|
290
|
+
return failure("invalid_input", "paths must not be empty or start with -");
|
|
291
|
+
const added = git(cwd, ["add", "-A", "--", ...paths]);
|
|
292
|
+
if (!added.ok) return failure("failed", `git add failed: ${gitError(added)}`);
|
|
293
|
+
args.push("--", ...paths);
|
|
294
|
+
} else if (input.all) {
|
|
295
|
+
const added = git(cwd, ["add", "-A"]);
|
|
296
|
+
if (!added.ok) return failure("failed", `git add failed: ${gitError(added)}`);
|
|
297
|
+
} else {
|
|
298
|
+
const staged = git(cwd, ["diff", "--cached", "--name-only", "-z"]);
|
|
299
|
+
if (staged.ok && !staged.stdout.replaceAll("\0", "")) {
|
|
300
|
+
const dirt = dirtEntries(cwd);
|
|
301
|
+
return failure(
|
|
302
|
+
dirt.length ? "blocked" : "failed",
|
|
303
|
+
dirt.length
|
|
304
|
+
? `nothing_staged: nothing is staged and workit never commits unrelated changes implicitly (${dirt.length} changed: ${dirt
|
|
305
|
+
.slice(0, 5)
|
|
306
|
+
.map((entry) => entry.slice(3))
|
|
307
|
+
.join(", ")}${dirt.length > 5 ? ", …" : ""})`
|
|
308
|
+
: "nothing_to_commit: the working tree is clean",
|
|
309
|
+
dirt.length
|
|
310
|
+
? `workit git commit -m '<msg>' -- <paths> # or --all to commit every change`
|
|
311
|
+
: undefined,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const committed = git(cwd, args, { timeoutMs: COMMIT_TIMEOUT_MS, env: input.env });
|
|
316
|
+
if (!committed.ok) return failure("failed", `git commit failed: ${gitError(committed, 6)}`);
|
|
317
|
+
const sha = headSha(cwd);
|
|
318
|
+
if (!sha) return failure("failed", "the commit did not resolve");
|
|
319
|
+
const files = nulList(
|
|
320
|
+
git(cwd, ["diff-tree", "--root", "--no-commit-id", "--name-only", "-r", "-z", sha]).stdout,
|
|
321
|
+
);
|
|
322
|
+
const leftDirty = dirtEntries(cwd).length;
|
|
323
|
+
|
|
324
|
+
// Bounded row (MAX_LINE_BYTES): the subject and the first files only.
|
|
325
|
+
const listed: string[] = [];
|
|
326
|
+
let size = 0;
|
|
327
|
+
for (const file of files) {
|
|
328
|
+
size += file.length + 4;
|
|
329
|
+
if (size > 1500) break;
|
|
330
|
+
listed.push(file);
|
|
331
|
+
}
|
|
332
|
+
const row = appendObserved(cwd, {
|
|
333
|
+
type: "commit.recorded",
|
|
334
|
+
actor: input.actor,
|
|
335
|
+
branch,
|
|
336
|
+
head: sha,
|
|
337
|
+
sha,
|
|
338
|
+
session,
|
|
339
|
+
agentId: input.actor.agentId,
|
|
340
|
+
subject: (message.split("\n", 1)[0] ?? "").slice(0, 200),
|
|
341
|
+
files: listed,
|
|
342
|
+
fileCount: files.length,
|
|
343
|
+
});
|
|
344
|
+
return success({
|
|
345
|
+
sha,
|
|
346
|
+
branch,
|
|
347
|
+
message,
|
|
348
|
+
files,
|
|
349
|
+
leftDirty,
|
|
350
|
+
session,
|
|
351
|
+
recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
// push
|
|
357
|
+
|
|
358
|
+
export type PushPlan = {
|
|
359
|
+
branch: string;
|
|
360
|
+
sha: string;
|
|
361
|
+
remote: string;
|
|
362
|
+
/** Redacted push URL (safe to print). */
|
|
363
|
+
url: string;
|
|
364
|
+
/** The push URL as configured (ls-remote target); never printed. */
|
|
365
|
+
rawUrl: string;
|
|
366
|
+
/** The remote is a local path/file URL: there is no forge account to check. */
|
|
367
|
+
local: boolean;
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
/** Everything `git push` needs that does not touch the network. */
|
|
371
|
+
export function pushPreflight(
|
|
372
|
+
cwd: string,
|
|
373
|
+
options: { branch?: string | null } = {},
|
|
374
|
+
): ForgeResult<PushPlan> {
|
|
375
|
+
const branch = options.branch ?? currentBranch(cwd);
|
|
376
|
+
if (!branch) return failure("invalid_input", "HEAD is detached; push a branch");
|
|
377
|
+
if (isProtectedTarget(cwd, branch))
|
|
378
|
+
return failure(
|
|
379
|
+
"blocked",
|
|
380
|
+
`protected_branch: ${branch} is protected by the workspace branch policy; workit never pushes to it`,
|
|
381
|
+
"workit git branch <feature/name> # push a feature branch and open a PR",
|
|
382
|
+
);
|
|
383
|
+
const sha = resolveRef(cwd, `refs/heads/${branch}`);
|
|
384
|
+
if (!sha) return failure("not_found", `branch ${branch} has no commit to push`);
|
|
385
|
+
const remote = pushRemoteName(cwd, branch);
|
|
386
|
+
if (!remote)
|
|
387
|
+
return failure(
|
|
388
|
+
"not_found",
|
|
389
|
+
"no push remote is configured for this branch",
|
|
390
|
+
"git remote add origin <url>",
|
|
391
|
+
);
|
|
392
|
+
const raw = pushUrl(cwd, remote);
|
|
393
|
+
if (!raw)
|
|
394
|
+
return failure(
|
|
395
|
+
"blocked",
|
|
396
|
+
`remote "${remote}" has no single push URL`,
|
|
397
|
+
`git remote get-url --push --all ${remote} # keep exactly one push URL`,
|
|
398
|
+
);
|
|
399
|
+
const parsed = parseRemoteUrl(raw);
|
|
400
|
+
const local = parsed?.protocol === "file";
|
|
401
|
+
// `git remote get-url --push` already applied the rewrites; a URL that a
|
|
402
|
+
// rule would rewrite again is ambiguous. A local path is taken as is.
|
|
403
|
+
if (!local && !pushTargetIsStable(cwd, raw))
|
|
404
|
+
return failure(
|
|
405
|
+
"blocked",
|
|
406
|
+
`push_target_rewritten: the push URL of "${remote}" is rewritten by a url.*.insteadOf rule, so the destination is not the one checked`,
|
|
407
|
+
"git config --get-regexp '^url\\.' # remove the rewrite for this remote",
|
|
408
|
+
);
|
|
409
|
+
return success({
|
|
410
|
+
branch,
|
|
411
|
+
sha,
|
|
412
|
+
remote,
|
|
413
|
+
url: redactRemote(raw),
|
|
414
|
+
rawUrl: raw,
|
|
415
|
+
local,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* The remote tip workit last pushed for `branch`: the newest `push.verified`
|
|
421
|
+
* row where workit actually moved the ref (`pushed: true`). A no-op push only
|
|
422
|
+
* observed someone's tip (`push.noop`) and is never a lease anchor. The remote-tracking ref is never a lease (a plain `git fetch`
|
|
423
|
+
* moves it to someone else's tip, which would make the force overwrite
|
|
424
|
+
* them). `undefined` means workit has no record.
|
|
425
|
+
*/
|
|
426
|
+
export function recordedRemoteTip(cwd: string, plan: PushPlan): string | undefined {
|
|
427
|
+
const ledger = readLedger(cwd);
|
|
428
|
+
if (ledger.ok)
|
|
429
|
+
for (let index = ledger.value.rows.length - 1; index >= 0; index -= 1) {
|
|
430
|
+
const row = ledger.value.rows[index];
|
|
431
|
+
if (
|
|
432
|
+
row.type === "push.verified" &&
|
|
433
|
+
row.pushed === true &&
|
|
434
|
+
row.branch === plan.branch &&
|
|
435
|
+
row.observer === "workit_cli" &&
|
|
436
|
+
row.head
|
|
437
|
+
)
|
|
438
|
+
return row.head;
|
|
439
|
+
}
|
|
440
|
+
return undefined;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* `--force-if-includes` semantics: the remote tip was integrated locally, i.e.
|
|
445
|
+
* it is reachable from the local branch or appears in the branch's reflog
|
|
446
|
+
* (the commit was this branch's tip before a rebase/amend).
|
|
447
|
+
*/
|
|
448
|
+
function includesRemoteTip(cwd: string, branch: string, tip: string): boolean {
|
|
449
|
+
if (git(cwd, ["merge-base", "--is-ancestor", tip, `refs/heads/${branch}`]).ok) return true;
|
|
450
|
+
const reflog = git(cwd, ["reflog", "show", "--format=%H", `refs/heads/${branch}`, "--"]);
|
|
451
|
+
return reflog.ok && reflog.stdout.split("\n").some((line) => line.trim() === tip);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export type PushOutcome = {
|
|
455
|
+
remote: string;
|
|
456
|
+
url: string;
|
|
457
|
+
branch: string;
|
|
458
|
+
sha: string;
|
|
459
|
+
/** The remote tip before the push (null: the branch was new). */
|
|
460
|
+
previous: string | null;
|
|
461
|
+
pushed: boolean;
|
|
462
|
+
forced: boolean;
|
|
463
|
+
delivered: true;
|
|
464
|
+
upstream: boolean;
|
|
465
|
+
recorded: { id: string } | { error: string };
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const SHA = /^[0-9a-f]{40,64}$/u;
|
|
469
|
+
|
|
470
|
+
export function executePush(
|
|
471
|
+
cwd: string,
|
|
472
|
+
plan: PushPlan,
|
|
473
|
+
input: {
|
|
474
|
+
forceWithLease?: boolean;
|
|
475
|
+
/** Allow a lease on a remote tip local history never contained (drops those commits). */
|
|
476
|
+
overwriteUnintegrated?: boolean;
|
|
477
|
+
expect?: string | null;
|
|
478
|
+
setUpstream?: boolean;
|
|
479
|
+
actor: LedgerActor;
|
|
480
|
+
timeoutMs?: number;
|
|
481
|
+
},
|
|
482
|
+
): ForgeResult<PushOutcome> {
|
|
483
|
+
const before = remoteRefTip(cwd, plan.rawUrl, `refs/heads/${plan.branch}`);
|
|
484
|
+
if (!before.ok) return failure(before.code, before.error);
|
|
485
|
+
let forced = false;
|
|
486
|
+
let pushed = false;
|
|
487
|
+
if (before.sha !== plan.sha) {
|
|
488
|
+
const args = ["push", "--porcelain", "--no-follow-tags", "--recurse-submodules=no"];
|
|
489
|
+
if (input.forceWithLease) {
|
|
490
|
+
const explicit = input.expect !== undefined && input.expect !== null;
|
|
491
|
+
if (explicit && !SHA.test(input.expect as string))
|
|
492
|
+
return failure("invalid_input", "--expect must be a full commit sha");
|
|
493
|
+
const integrate = `git fetch ${plan.remote} ${plan.branch} && git rebase ${plan.remote}/${plan.branch} # or merge it; then: workit git push`;
|
|
494
|
+
const recorded = explicit ? (input.expect as string) : recordedRemoteTip(cwd, plan);
|
|
495
|
+
// A new branch needs no lease; otherwise workit must have recorded the tip.
|
|
496
|
+
if (recorded === undefined && before.sha !== null)
|
|
497
|
+
return failure(
|
|
498
|
+
"blocked",
|
|
499
|
+
`lease_unknown: workit has no recorded push of ${plan.branch}, so it cannot tell whose commits ${plan.remote}/${plan.branch} (${before.sha.slice(0, 12)}) holds`,
|
|
500
|
+
`${integrate} # or, after reviewing that tip: workit git push --force-with-lease --expect <sha you reviewed> (--overwrite-unintegrated only to drop commits you never had)`,
|
|
501
|
+
);
|
|
502
|
+
const expected = recorded ?? null;
|
|
503
|
+
if (before.sha !== expected)
|
|
504
|
+
return failure(
|
|
505
|
+
"blocked",
|
|
506
|
+
`lease_mismatch: ${plan.remote}/${plan.branch} is at ${before.sha?.slice(0, 12) ?? "(absent)"} but ${explicit ? "--expect names" : "workit last recorded"} ${expected?.slice(0, 12) ?? "(absent)"}; someone else pushed`,
|
|
507
|
+
integrate,
|
|
508
|
+
);
|
|
509
|
+
if (
|
|
510
|
+
!input.overwriteUnintegrated &&
|
|
511
|
+
before.sha !== null &&
|
|
512
|
+
!includesRemoteTip(cwd, plan.branch, before.sha)
|
|
513
|
+
)
|
|
514
|
+
return failure(
|
|
515
|
+
"blocked",
|
|
516
|
+
`lease_not_integrated: ${plan.remote}/${plan.branch} (${before.sha.slice(0, 12)}) was never part of local ${plan.branch}; forcing would drop it`,
|
|
517
|
+
`${integrate} # or, to drop those commits on purpose: workit git push --force-with-lease --expect ${before.sha} --overwrite-unintegrated`,
|
|
518
|
+
);
|
|
519
|
+
args.push(`--force-with-lease=refs/heads/${plan.branch}:${expected ?? ""}`);
|
|
520
|
+
forced = true;
|
|
521
|
+
}
|
|
522
|
+
args.push(plan.remote, `${plan.sha}:refs/heads/${plan.branch}`);
|
|
523
|
+
const run = gitNetwork(cwd, args, input.timeoutMs ?? PUSH_TIMEOUT_MS);
|
|
524
|
+
if (!run.ok) {
|
|
525
|
+
const text = `${run.stderr}\n${run.stdout}`;
|
|
526
|
+
if (run.timedOut) return failure("unavailable", `git push to ${plan.url} timed out`);
|
|
527
|
+
if (/stale info/iu.test(text))
|
|
528
|
+
return failure(
|
|
529
|
+
"blocked",
|
|
530
|
+
`lease_mismatch: ${plan.remote}/${plan.branch} moved while pushing`,
|
|
531
|
+
`git fetch ${plan.remote} ${plan.branch} && git rebase ${plan.remote}/${plan.branch} # or merge it; then: workit git push`,
|
|
532
|
+
);
|
|
533
|
+
if (/non-fast-forward|fetch first|\[rejected\]/iu.test(text))
|
|
534
|
+
return failure(
|
|
535
|
+
"failed",
|
|
536
|
+
`non_fast_forward: ${plan.remote}/${plan.branch} has commits this branch does not`,
|
|
537
|
+
`git fetch ${plan.remote} && git rebase ${plan.remote}/${plan.branch} # or, if the rewrite is intended: workit git push --force-with-lease`,
|
|
538
|
+
);
|
|
539
|
+
return failure("failed", `git push to ${plan.url} failed: ${gitError(run)}`);
|
|
540
|
+
}
|
|
541
|
+
pushed = true;
|
|
542
|
+
}
|
|
543
|
+
const after = remoteRefTip(cwd, plan.rawUrl, `refs/heads/${plan.branch}`);
|
|
544
|
+
if (!after.ok)
|
|
545
|
+
return failure(
|
|
546
|
+
after.code,
|
|
547
|
+
`push sent but not verified: ${after.error}`,
|
|
548
|
+
"workit verify-delivery push",
|
|
549
|
+
);
|
|
550
|
+
if (after.sha !== plan.sha)
|
|
551
|
+
return failure(
|
|
552
|
+
"failed",
|
|
553
|
+
`push_unverified: ${plan.remote}/${plan.branch} is at ${after.sha?.slice(0, 12) ?? "(absent)"}, not the pushed ${plan.sha.slice(0, 12)}`,
|
|
554
|
+
"workit verify-delivery push",
|
|
555
|
+
);
|
|
556
|
+
let upstream = false;
|
|
557
|
+
if (input.setUpstream) {
|
|
558
|
+
upstream =
|
|
559
|
+
git(cwd, ["config", `branch.${plan.branch}.remote`, plan.remote]).ok &&
|
|
560
|
+
git(cwd, ["config", `branch.${plan.branch}.merge`, `refs/heads/${plan.branch}`]).ok;
|
|
561
|
+
}
|
|
562
|
+
// Only a push that moved the ref is a lease anchor; a no-op merely observed it.
|
|
563
|
+
const row = appendObserved(cwd, {
|
|
564
|
+
type: pushed ? "push.verified" : "push.noop",
|
|
565
|
+
actor: input.actor,
|
|
566
|
+
branch: plan.branch,
|
|
567
|
+
head: plan.sha,
|
|
568
|
+
remote: plan.remote,
|
|
569
|
+
url: plan.url,
|
|
570
|
+
previous: before.sha,
|
|
571
|
+
pushed,
|
|
572
|
+
forced,
|
|
573
|
+
});
|
|
574
|
+
return success({
|
|
575
|
+
remote: plan.remote,
|
|
576
|
+
url: plan.url,
|
|
577
|
+
branch: plan.branch,
|
|
578
|
+
sha: plan.sha,
|
|
579
|
+
previous: before.sha,
|
|
580
|
+
pushed,
|
|
581
|
+
forced,
|
|
582
|
+
delivered: true,
|
|
583
|
+
upstream,
|
|
584
|
+
recorded: row.ok ? { id: String(row.value.id) } : { error: row.error },
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Delete a remote branch only while it still points at `expectedTip`
|
|
590
|
+
* (`--force-with-lease=refs/heads/<b>:<tip> --delete`). `lease` is false when
|
|
591
|
+
* the tip moved; nothing is deleted then.
|
|
592
|
+
*/
|
|
593
|
+
export function deleteRemoteBranch(
|
|
594
|
+
cwd: string,
|
|
595
|
+
target: string,
|
|
596
|
+
branch: string,
|
|
597
|
+
expectedTip: string,
|
|
598
|
+
options: { timeoutMs?: number } = {},
|
|
599
|
+
): { ok: true } | { ok: false; lease: boolean; error: string } {
|
|
600
|
+
if (target.startsWith("-") || branch.startsWith("-") || !SHA.test(expectedTip))
|
|
601
|
+
return { ok: false, lease: false, error: "invalid branch deletion target" };
|
|
602
|
+
if (isProtectedTarget(cwd, branch))
|
|
603
|
+
return {
|
|
604
|
+
ok: false,
|
|
605
|
+
lease: false,
|
|
606
|
+
error: `${branch} is a protected branch; it is never deleted`,
|
|
607
|
+
};
|
|
608
|
+
const run = gitNetwork(
|
|
609
|
+
cwd,
|
|
610
|
+
[
|
|
611
|
+
"push",
|
|
612
|
+
"--no-follow-tags",
|
|
613
|
+
"--recurse-submodules=no",
|
|
614
|
+
`--force-with-lease=refs/heads/${branch}:${expectedTip}`,
|
|
615
|
+
"--delete",
|
|
616
|
+
target,
|
|
617
|
+
branch,
|
|
618
|
+
],
|
|
619
|
+
options.timeoutMs ?? PUSH_TIMEOUT_MS,
|
|
620
|
+
);
|
|
621
|
+
if (run.ok) return { ok: true };
|
|
622
|
+
const text = `${run.stderr}\n${run.stdout}`;
|
|
623
|
+
return /stale info|cannot lock ref|fetch first|non-fast-forward/iu.test(text)
|
|
624
|
+
? { ok: false, lease: true, error: "the remote branch tip changed before deletion" }
|
|
625
|
+
: { ok: false, lease: false, error: gitError(run) };
|
|
626
|
+
}
|
package/src/git/rev.ts
CHANGED
|
@@ -130,8 +130,8 @@ child.on("exit", (code) => {
|
|
|
130
130
|
|
|
131
131
|
const WATCHDOG_TIMEOUT_EXIT = 124;
|
|
132
132
|
|
|
133
|
-
/** A network git call under the process-group watchdog (see WATCHDOG). */
|
|
134
|
-
const gitNetwork = (cwd: string, args: string[], timeoutMs: number): GitRun => {
|
|
133
|
+
/** A network git call (push, fetch, ls-remote) under the process-group watchdog (see WATCHDOG). */
|
|
134
|
+
export const gitNetwork = (cwd: string, args: string[], timeoutMs: number): GitRun => {
|
|
135
135
|
const invocation = networkGitInvocation(cwd, args, timeoutMs);
|
|
136
136
|
const result = spawnSync(process.execPath, ["-e", WATCHDOG], {
|
|
137
137
|
cwd,
|
|
@@ -351,11 +351,25 @@ export function remoteTip(
|
|
|
351
351
|
branch: string,
|
|
352
352
|
options: { timeoutMs?: number } = {},
|
|
353
353
|
): RemoteTipResult {
|
|
354
|
-
if (!safeArg(
|
|
354
|
+
if (!safeArg(branch))
|
|
355
355
|
return { ok: false, code: "invalid_input", error: "remote and branch must not start with -" };
|
|
356
|
+
return remoteRefTip(cwd, remote, `refs/heads/${branch}`, options);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* The commit a full remote ref points at (`refs/heads/x`, `refs/tags/v1`); an
|
|
361
|
+
* annotated tag resolves to the commit it peels to. Same contract as remoteTip.
|
|
362
|
+
*/
|
|
363
|
+
export function remoteRefTip(
|
|
364
|
+
cwd: string,
|
|
365
|
+
remote: string,
|
|
366
|
+
ref: string,
|
|
367
|
+
options: { timeoutMs?: number } = {},
|
|
368
|
+
): RemoteTipResult {
|
|
369
|
+
if (!safeArg(remote) || !safeArg(ref))
|
|
370
|
+
return { ok: false, code: "invalid_input", error: "remote and ref must not start with -" };
|
|
356
371
|
const timeoutMs = options.timeoutMs ?? GIT_TIMEOUTS.network;
|
|
357
|
-
const
|
|
358
|
-
const listed = gitNetwork(cwd, ["ls-remote", remote, ref], timeoutMs);
|
|
372
|
+
const listed = gitNetwork(cwd, ["ls-remote", remote, ref, `${ref}^{}`], timeoutMs);
|
|
359
373
|
if (!listed.ok)
|
|
360
374
|
return {
|
|
361
375
|
ok: false,
|
|
@@ -364,11 +378,14 @@ export function remoteTip(
|
|
|
364
378
|
? `git ls-remote ${redactRemote(remote)} timed out after ${timeoutMs} ms`
|
|
365
379
|
: `git ls-remote ${redactRemote(remote)} failed (unreachable or not authorized)`,
|
|
366
380
|
};
|
|
381
|
+
let direct: string | null = null;
|
|
367
382
|
for (const row of listed.stdout.split(/\r?\n/u)) {
|
|
368
383
|
const [sha, name] = row.split(/\s+/u);
|
|
369
|
-
if (
|
|
384
|
+
if (!sha) continue;
|
|
385
|
+
if (name === `${ref}^{}`) return { ok: true, sha };
|
|
386
|
+
if (name === ref) direct = sha;
|
|
370
387
|
}
|
|
371
|
-
return { ok: true, sha:
|
|
388
|
+
return { ok: true, sha: direct };
|
|
372
389
|
}
|
|
373
390
|
|
|
374
391
|
export type FetchResult =
|