@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 +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 +5 -1
- package/src/git/ops.ts +360 -34
package/package.json
CHANGED
|
@@ -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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
+
};
|
package/src/core/branch.ts
CHANGED
|
@@ -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 },
|
package/src/core/config.ts
CHANGED
|
@@ -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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
package/src/core/workspaces.ts
CHANGED
|
@@ -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 = {
|
|
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 = {
|
|
@@ -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
|
|
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
|
-
? {
|
|
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
|
}
|