@muggleai/works 5.7.0 → 5.8.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/dist/{chunk-LTULYOOW.js → chunk-5PYK7DWB.js} +15 -0
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/.cursor-plugin/plugin.json +1 -1
- package/dist/plugin/hooks/README.md +11 -2
- package/dist/plugin/hooks/hooks.json +7 -1
- package/dist/plugin/scripts/gc-state.sh +66 -0
- package/dist/plugin/scripts/guard-run-job-object.ps1 +256 -0
- package/dist/plugin/scripts/guard-run.mjs +327 -0
- package/dist/plugin/scripts/guardrail-record-tests.sh +5 -4
- package/dist/plugin/scripts/guardrails.mjs +6 -3
- package/dist/plugin/scripts/pr-watch-guards.sh +46 -0
- package/dist/plugin/skills/_shared/pr-followup-helpers/loop-signature.md +3 -3
- package/dist/plugin/skills/_shared/vcs/CLAUDE.md +2 -1
- package/dist/plugin/skills/_shared/vcs/common/branch-standing.md +19 -0
- package/dist/plugin/skills/_shared/vcs/common/ci-rollup.md +16 -0
- package/dist/plugin/skills/_shared/vcs/common/push-to-branch.md +16 -0
- package/{plugin/skills/_shared/vcs/github → dist/plugin/skills/_shared/vcs/common}/verify-working-tree.md +4 -4
- package/dist/plugin/skills/_shared/vcs/github/pr-edit.md +2 -0
- package/dist/plugin/skills/_shared/vcs/github/reply-line-comment.md +2 -0
- package/dist/plugin/skills/_shared/vcs/github/signed-commits.md +28 -0
- package/dist/plugin/skills/_shared/vcs/github/top-level-comment.md +2 -0
- package/dist/plugin/skills/_shared/vcs/github/unresolved-threads.md +39 -39
- package/dist/plugin/skills/_shared/vcs/github.md +5 -2
- package/dist/plugin/skills/_shared/vcs/gitlab/loop-user-identity.md +1 -1
- package/dist/plugin/skills/_shared/vcs/gitlab/mr-create.md +1 -1
- package/dist/plugin/skills/_shared/vcs/gitlab/mr-discussions.md +1 -1
- package/dist/plugin/skills/_shared/vcs/gitlab/mr-metadata.md +2 -2
- package/dist/plugin/skills/_shared/vcs/gitlab/mr-pipeline.md +1 -1
- package/dist/plugin/skills/_shared/vcs/gitlab/reply-discussion.md +1 -1
- package/dist/plugin/skills/_shared/vcs/gitlab/resolve-discussion.md +1 -1
- package/dist/plugin/skills/_shared/vcs/gitlab/signed-commits.md +15 -0
- package/dist/plugin/skills/_shared/vcs/gitlab/unresolved-discussions.md +2 -2
- package/dist/plugin/skills/_shared/vcs/gitlab.md +6 -3
- package/dist/plugin/skills/_shared/vcs/post-signature.md +31 -0
- package/dist/plugin/skills/do/build.md +58 -56
- package/dist/plugin/skills/do/open-prs/forward.md +85 -84
- package/dist/plugin/skills/do/open-prs/update.md +3 -3
- package/dist/plugin/skills/do/per-comment-replies.md +2 -2
- package/dist/plugin/skills/do/resolve-conflicts.md +59 -58
- package/dist/plugin/skills/do/unit-tests.md +1 -1
- package/dist/plugin/skills/muggle-pr-followup/CLAUDE.md +1 -0
- package/dist/plugin/skills/muggle-pr-followup/SKILL.md +11 -4
- package/dist/plugin/skills/muggle-pr-followup/arm-watcher.md +29 -5
- package/dist/plugin/skills/muggle-pr-followup/bootstrap.md +3 -1
- package/dist/plugin/skills/muggle-pr-followup/contract.md +171 -158
- package/dist/plugin/skills/muggle-pr-followup/evals/evals.json +221 -101
- package/dist/plugin/skills/muggle-pr-followup/output-templates/inline-reply.md +2 -2
- package/dist/plugin/skills/muggle-pr-followup/output-templates/resolve-reminder.md +1 -1
- package/dist/plugin/skills/muggle-pr-followup/reconcile.md +9 -5
- package/dist/plugin/skills/muggle-pr-followup/state-schemas.md +191 -175
- package/dist/plugin/skills/muggle-pr-followup/stop.md +31 -0
- package/dist/plugin/skills/muggle-pr-visual-walkthrough/SKILL.md +14 -5
- package/dist/plugin/skills/muggle-test-prepare/steps/start-services.md +2 -2
- package/dist/release-manifest.json +4 -4
- package/package.json +6 -6
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.cursor-plugin/plugin.json +1 -1
- package/plugin/hooks/README.md +11 -2
- package/plugin/hooks/hooks.json +7 -1
- package/plugin/scripts/gc-state.sh +66 -0
- package/plugin/scripts/guard-run-job-object.ps1 +256 -0
- package/plugin/scripts/guard-run.mjs +327 -0
- package/plugin/scripts/guardrail-record-tests.sh +5 -4
- package/plugin/scripts/guardrails.mjs +6 -3
- package/plugin/scripts/pr-watch-guards.sh +46 -0
- package/plugin/skills/_shared/pr-followup-helpers/loop-signature.md +3 -3
- package/plugin/skills/_shared/vcs/CLAUDE.md +2 -1
- package/plugin/skills/_shared/vcs/common/branch-standing.md +19 -0
- package/plugin/skills/_shared/vcs/common/ci-rollup.md +16 -0
- package/plugin/skills/_shared/vcs/common/push-to-branch.md +16 -0
- package/{dist/plugin/skills/_shared/vcs/github → plugin/skills/_shared/vcs/common}/verify-working-tree.md +4 -4
- package/plugin/skills/_shared/vcs/github/pr-edit.md +2 -0
- package/plugin/skills/_shared/vcs/github/reply-line-comment.md +2 -0
- package/plugin/skills/_shared/vcs/github/signed-commits.md +28 -0
- package/plugin/skills/_shared/vcs/github/top-level-comment.md +2 -0
- package/plugin/skills/_shared/vcs/github/unresolved-threads.md +39 -39
- package/plugin/skills/_shared/vcs/github.md +5 -2
- package/plugin/skills/_shared/vcs/gitlab/loop-user-identity.md +1 -1
- package/plugin/skills/_shared/vcs/gitlab/mr-create.md +1 -1
- package/plugin/skills/_shared/vcs/gitlab/mr-discussions.md +1 -1
- package/plugin/skills/_shared/vcs/gitlab/mr-metadata.md +2 -2
- package/plugin/skills/_shared/vcs/gitlab/mr-pipeline.md +1 -1
- package/plugin/skills/_shared/vcs/gitlab/reply-discussion.md +1 -1
- package/plugin/skills/_shared/vcs/gitlab/resolve-discussion.md +1 -1
- package/plugin/skills/_shared/vcs/gitlab/signed-commits.md +15 -0
- package/plugin/skills/_shared/vcs/gitlab/unresolved-discussions.md +2 -2
- package/plugin/skills/_shared/vcs/gitlab.md +6 -3
- package/plugin/skills/_shared/vcs/post-signature.md +31 -0
- package/plugin/skills/do/build.md +58 -56
- package/plugin/skills/do/open-prs/forward.md +85 -84
- package/plugin/skills/do/open-prs/update.md +3 -3
- package/plugin/skills/do/per-comment-replies.md +2 -2
- package/plugin/skills/do/resolve-conflicts.md +59 -58
- package/plugin/skills/do/unit-tests.md +1 -1
- package/plugin/skills/muggle-pr-followup/CLAUDE.md +1 -0
- package/plugin/skills/muggle-pr-followup/SKILL.md +11 -4
- package/plugin/skills/muggle-pr-followup/arm-watcher.md +29 -5
- package/plugin/skills/muggle-pr-followup/bootstrap.md +3 -1
- package/plugin/skills/muggle-pr-followup/contract.md +171 -158
- package/plugin/skills/muggle-pr-followup/evals/evals.json +221 -101
- package/plugin/skills/muggle-pr-followup/output-templates/inline-reply.md +2 -2
- package/plugin/skills/muggle-pr-followup/output-templates/resolve-reminder.md +1 -1
- package/plugin/skills/muggle-pr-followup/reconcile.md +9 -5
- package/plugin/skills/muggle-pr-followup/state-schemas.md +191 -175
- package/plugin/skills/muggle-pr-followup/stop.md +31 -0
- package/plugin/skills/muggle-pr-visual-walkthrough/SKILL.md +14 -5
- package/plugin/skills/muggle-test-prepare/steps/start-services.md +2 -2
- package/dist/plugin/skills/_shared/vcs/github/push-to-branch.md +0 -10
- package/plugin/skills/_shared/vcs/github/push-to-branch.md +0 -10
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'child_process';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { extname, join, dirname } from 'path';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
|
|
6
|
+
// src/guard-run/cli.ts
|
|
7
|
+
|
|
8
|
+
// src/guard-run/buildLaunchPlan.ts
|
|
9
|
+
function quoteWindowsArgument(argument) {
|
|
10
|
+
if (argument !== "" && !/[ \t"]/.test(argument)) return argument;
|
|
11
|
+
let quoted = '"';
|
|
12
|
+
let backslashCount = 0;
|
|
13
|
+
for (const character of argument) {
|
|
14
|
+
if (character === "\\") {
|
|
15
|
+
backslashCount += 1;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (character === '"') {
|
|
19
|
+
quoted += "\\".repeat(backslashCount * 2 + 1) + '"';
|
|
20
|
+
backslashCount = 0;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
quoted += "\\".repeat(backslashCount) + character;
|
|
24
|
+
backslashCount = 0;
|
|
25
|
+
}
|
|
26
|
+
return quoted + "\\".repeat(backslashCount * 2) + '"';
|
|
27
|
+
}
|
|
28
|
+
function buildWindowsCommandLine(command) {
|
|
29
|
+
return command.map(quoteWindowsArgument).join(" ");
|
|
30
|
+
}
|
|
31
|
+
function buildWindowsJobObjectPlan(input) {
|
|
32
|
+
const bareCommandLine = buildWindowsCommandLine(input.options.command);
|
|
33
|
+
const commandLine = input.commandNeedsCmdShell ? `cmd.exe /d /s /c "${bareCommandLine}"` : bareCommandLine;
|
|
34
|
+
return {
|
|
35
|
+
executable: "powershell.exe",
|
|
36
|
+
args: [
|
|
37
|
+
"-NoProfile",
|
|
38
|
+
"-NonInteractive",
|
|
39
|
+
"-ExecutionPolicy",
|
|
40
|
+
"Bypass",
|
|
41
|
+
"-File",
|
|
42
|
+
input.jobObjectShimPath,
|
|
43
|
+
String(input.options.activeProcessLimit),
|
|
44
|
+
input.options.isServiceMode ? "persist" : "kill",
|
|
45
|
+
String(input.launcherPid),
|
|
46
|
+
// Base64 survives the node→powershell→CreateProcessW arg relays that
|
|
47
|
+
// would otherwise re-interpret quotes.
|
|
48
|
+
Buffer.from(commandLine, "utf8").toString("base64")
|
|
49
|
+
],
|
|
50
|
+
spawnDetached: false,
|
|
51
|
+
killsProcessGroupOnExit: false,
|
|
52
|
+
stopsScopeUnitOnExit: null
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function buildLinuxSystemdScopePlan(args) {
|
|
56
|
+
return {
|
|
57
|
+
// pids.max (cgroup v2): the excess fork fails with EAGAIN in the kernel.
|
|
58
|
+
executable: "systemd-run",
|
|
59
|
+
args: [
|
|
60
|
+
"--user",
|
|
61
|
+
"--scope",
|
|
62
|
+
"--quiet",
|
|
63
|
+
"--collect",
|
|
64
|
+
`--unit=${args.scopeUnitName}`,
|
|
65
|
+
"-p",
|
|
66
|
+
`TasksMax=${args.options.activeProcessLimit}`,
|
|
67
|
+
"--",
|
|
68
|
+
...args.options.command
|
|
69
|
+
],
|
|
70
|
+
spawnDetached: false,
|
|
71
|
+
killsProcessGroupOnExit: false,
|
|
72
|
+
// Catchable launcher death stops the scope; after an untrappable SIGKILL
|
|
73
|
+
// the scope lives on but stays TasksMax-bounded, so the cap always holds.
|
|
74
|
+
stopsScopeUnitOnExit: args.options.isServiceMode ? null : args.scopeUnitName
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function buildLinuxPrlimitProcessGroupPlan(args) {
|
|
78
|
+
return {
|
|
79
|
+
executable: "prlimit",
|
|
80
|
+
args: [`--nproc=${args.nprocCeiling}`, "--", ...args.options.command],
|
|
81
|
+
spawnDetached: true,
|
|
82
|
+
killsProcessGroupOnExit: !args.options.isServiceMode,
|
|
83
|
+
stopsScopeUnitOnExit: null
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function buildDarwinUlimitProcessGroupPlan(args) {
|
|
87
|
+
return {
|
|
88
|
+
executable: "/bin/sh",
|
|
89
|
+
args: ["-c", `ulimit -u ${args.nprocCeiling}; exec "$@"`, "guard-run", ...args.options.command],
|
|
90
|
+
spawnDetached: true,
|
|
91
|
+
killsProcessGroupOnExit: !args.options.isServiceMode,
|
|
92
|
+
stopsScopeUnitOnExit: null
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function buildBackendLaunchPlan(args) {
|
|
96
|
+
if (args.backend === "windows-job-object" /* WindowsJobObject */) {
|
|
97
|
+
return buildWindowsJobObjectPlan({
|
|
98
|
+
options: args.options,
|
|
99
|
+
jobObjectShimPath: args.jobObjectShimPath ?? "",
|
|
100
|
+
launcherPid: args.launcherPid ?? 0,
|
|
101
|
+
commandNeedsCmdShell: args.commandNeedsCmdShell ?? false
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if (args.backend === "linux-systemd-scope" /* LinuxSystemdScope */) {
|
|
105
|
+
return buildLinuxSystemdScopePlan({
|
|
106
|
+
options: args.options,
|
|
107
|
+
scopeUnitName: args.scopeUnitName ?? ""
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (args.backend === "linux-prlimit-process-group" /* LinuxPrlimitProcessGroup */) {
|
|
111
|
+
return buildLinuxPrlimitProcessGroupPlan({
|
|
112
|
+
options: args.options,
|
|
113
|
+
nprocCeiling: args.nprocCeiling ?? 0
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return buildDarwinUlimitProcessGroupPlan({
|
|
117
|
+
options: args.options,
|
|
118
|
+
nprocCeiling: args.nprocCeiling ?? 0
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/guard-run/headroom.ts
|
|
123
|
+
function countProcessListLines(psOutput) {
|
|
124
|
+
return psOutput.split("\n").filter((line) => line.trim() !== "").length;
|
|
125
|
+
}
|
|
126
|
+
function computeNprocCeiling(args) {
|
|
127
|
+
return args.currentUserProcessCount + args.activeProcessLimit;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// src/guard-run/constants.ts
|
|
131
|
+
var GUARD_RUN_DEFAULT_ACTIVE_PROCESS_LIMIT = 64;
|
|
132
|
+
var GUARD_RUN_USAGE = "usage: guard-run [--limit N] [--service] -- <command...>\n Runs <command> with its whole process tree kernel-capped at N processes\n (default 64). Default mode also ties the tree to the launcher: killing\n guard-run reaps every descendant. --service keeps the cap but lets the\n tree outlive the launcher (for dev servers).";
|
|
133
|
+
|
|
134
|
+
// src/guard-run/parseArguments.ts
|
|
135
|
+
function parseError(errorMessage) {
|
|
136
|
+
return { options: null, errorMessage: `${errorMessage}
|
|
137
|
+
${GUARD_RUN_USAGE}` };
|
|
138
|
+
}
|
|
139
|
+
function parsePositiveInteger(rawValue) {
|
|
140
|
+
if (rawValue === void 0 || !/^\d+$/.test(rawValue)) return null;
|
|
141
|
+
const parsed = Number(rawValue);
|
|
142
|
+
return parsed >= 1 ? parsed : null;
|
|
143
|
+
}
|
|
144
|
+
function parseGuardRunArguments(argv) {
|
|
145
|
+
let activeProcessLimit = GUARD_RUN_DEFAULT_ACTIVE_PROCESS_LIMIT;
|
|
146
|
+
let isServiceMode = false;
|
|
147
|
+
let index = 0;
|
|
148
|
+
while (index < argv.length) {
|
|
149
|
+
const token = argv[index];
|
|
150
|
+
if (token === "--") {
|
|
151
|
+
index += 1;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
if (token === "--service") {
|
|
155
|
+
isServiceMode = true;
|
|
156
|
+
index += 1;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (token === "--limit" || token.startsWith("--limit=")) {
|
|
160
|
+
const rawLimit = token === "--limit" ? argv[index + 1] : token.slice("--limit=".length);
|
|
161
|
+
const parsedLimit = parsePositiveInteger(rawLimit);
|
|
162
|
+
if (parsedLimit === null) return parseError("--limit requires a positive integer");
|
|
163
|
+
activeProcessLimit = parsedLimit;
|
|
164
|
+
index += token === "--limit" ? 2 : 1;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (token.startsWith("--")) return parseError(`unknown flag: ${token}`);
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
const command = argv.slice(index);
|
|
171
|
+
if (command.length === 0) return parseError("no command given");
|
|
172
|
+
return {
|
|
173
|
+
options: {
|
|
174
|
+
activeProcessLimit,
|
|
175
|
+
isServiceMode,
|
|
176
|
+
command
|
|
177
|
+
},
|
|
178
|
+
errorMessage: null
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/guard-run/selectBackend.ts
|
|
183
|
+
var SYSTEMD_USABLE_MANAGER_STATES = /* @__PURE__ */ new Set(["running", "degraded"]);
|
|
184
|
+
function selectGuardRunBackend(probe) {
|
|
185
|
+
if (probe.platform === "win32") {
|
|
186
|
+
return { backend: "windows-job-object" /* WindowsJobObject */, errorMessage: null };
|
|
187
|
+
}
|
|
188
|
+
if (probe.platform === "linux") {
|
|
189
|
+
const managerState = (probe.systemdUserManagerState ?? "").trim();
|
|
190
|
+
if (probe.hasSystemdRun && SYSTEMD_USABLE_MANAGER_STATES.has(managerState)) {
|
|
191
|
+
return { backend: "linux-systemd-scope" /* LinuxSystemdScope */, errorMessage: null };
|
|
192
|
+
}
|
|
193
|
+
if (probe.hasPrlimit) {
|
|
194
|
+
return { backend: "linux-prlimit-process-group" /* LinuxPrlimitProcessGroup */, errorMessage: null };
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
backend: null,
|
|
198
|
+
errorMessage: "no process-cap backend available (need a systemd user manager or prlimit); refusing to run the command unguarded"
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (probe.platform === "darwin") {
|
|
202
|
+
return { backend: "darwin-ulimit-process-group" /* DarwinUlimitProcessGroup */, errorMessage: null };
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
backend: null,
|
|
206
|
+
errorMessage: `unsupported platform "${probe.platform}"; refusing to run the command unguarded`
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// src/guard-run/cli.ts
|
|
211
|
+
var JOB_OBJECT_SHIM_FILENAME = "guard-run-job-object.ps1";
|
|
212
|
+
function fail(message) {
|
|
213
|
+
process.stderr.write(`guard-run: ${message}
|
|
214
|
+
`);
|
|
215
|
+
process.exit(2);
|
|
216
|
+
}
|
|
217
|
+
function commandSucceeds(executable, args) {
|
|
218
|
+
const probe = spawnSync(executable, args, { stdio: "ignore", timeout: 1e4 });
|
|
219
|
+
return probe.error === void 0 && probe.status === 0;
|
|
220
|
+
}
|
|
221
|
+
function probedSystemdUserManagerState() {
|
|
222
|
+
const probe = spawnSync("systemctl", ["--user", "is-system-running"], {
|
|
223
|
+
encoding: "utf-8",
|
|
224
|
+
timeout: 1e4
|
|
225
|
+
});
|
|
226
|
+
if (probe.error !== void 0 || typeof probe.stdout !== "string") return null;
|
|
227
|
+
return probe.stdout.trim();
|
|
228
|
+
}
|
|
229
|
+
function resolveJobObjectShimPath() {
|
|
230
|
+
const bundledSibling = join(dirname(fileURLToPath(import.meta.url)), JOB_OBJECT_SHIM_FILENAME);
|
|
231
|
+
if (existsSync(bundledSibling)) return bundledSibling;
|
|
232
|
+
const pluginTreePath = join(
|
|
233
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
234
|
+
"..",
|
|
235
|
+
"..",
|
|
236
|
+
"plugin",
|
|
237
|
+
"scripts",
|
|
238
|
+
JOB_OBJECT_SHIM_FILENAME
|
|
239
|
+
);
|
|
240
|
+
if (existsSync(pluginTreePath)) return pluginTreePath;
|
|
241
|
+
return fail(`cannot locate ${JOB_OBJECT_SHIM_FILENAME}; refusing to run the command unguarded`);
|
|
242
|
+
}
|
|
243
|
+
function windowsCommandNeedsCmdShell(firstCommandToken) {
|
|
244
|
+
const knownExtension = extname(firstCommandToken).toLowerCase();
|
|
245
|
+
if (knownExtension === ".cmd" || knownExtension === ".bat") return true;
|
|
246
|
+
if (knownExtension !== "") return false;
|
|
247
|
+
const lookup = spawnSync("where.exe", [firstCommandToken], {
|
|
248
|
+
encoding: "utf-8",
|
|
249
|
+
timeout: 1e4
|
|
250
|
+
});
|
|
251
|
+
if (lookup.error !== void 0 || lookup.status !== 0) return false;
|
|
252
|
+
const resolvedExtension = extname(lookup.stdout.split(/\r?\n/)[0] ?? "").toLowerCase();
|
|
253
|
+
return resolvedExtension === ".cmd" || resolvedExtension === ".bat";
|
|
254
|
+
}
|
|
255
|
+
function currentUserProcessCount() {
|
|
256
|
+
const uid = typeof process.getuid === "function" ? String(process.getuid()) : "";
|
|
257
|
+
const listing = spawnSync("ps", ["-u", uid, "-o", "pid="], {
|
|
258
|
+
encoding: "utf-8",
|
|
259
|
+
timeout: 1e4
|
|
260
|
+
});
|
|
261
|
+
if (listing.error !== void 0 || listing.status !== 0) {
|
|
262
|
+
return fail("cannot count current user processes for the nproc ceiling");
|
|
263
|
+
}
|
|
264
|
+
return countProcessListLines(listing.stdout);
|
|
265
|
+
}
|
|
266
|
+
function registerTreeCleanup(plan2, childPid) {
|
|
267
|
+
if (!plan2.killsProcessGroupOnExit && plan2.stopsScopeUnitOnExit === null) return;
|
|
268
|
+
let cleanedUp = false;
|
|
269
|
+
const cleanup = () => {
|
|
270
|
+
if (cleanedUp) return;
|
|
271
|
+
cleanedUp = true;
|
|
272
|
+
if (plan2.killsProcessGroupOnExit && childPid !== void 0) {
|
|
273
|
+
try {
|
|
274
|
+
process.kill(-childPid, "SIGKILL");
|
|
275
|
+
} catch {
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (plan2.stopsScopeUnitOnExit !== null) {
|
|
279
|
+
spawnSync("systemctl", ["--user", "stop", plan2.stopsScopeUnitOnExit], {
|
|
280
|
+
stdio: "ignore",
|
|
281
|
+
timeout: 1e4
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
process.on("exit", cleanup);
|
|
286
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
287
|
+
process.on(signal, () => {
|
|
288
|
+
cleanup();
|
|
289
|
+
process.exit(128 + (signal === "SIGINT" ? 2 : signal === "SIGTERM" ? 15 : 1));
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
var parseResult = parseGuardRunArguments(process.argv.slice(2));
|
|
294
|
+
if (parseResult.options === null) fail(parseResult.errorMessage ?? "invalid arguments");
|
|
295
|
+
var options = parseResult.options;
|
|
296
|
+
var isLinux = process.platform === "linux";
|
|
297
|
+
var selection = selectGuardRunBackend({
|
|
298
|
+
platform: process.platform,
|
|
299
|
+
hasSystemdRun: isLinux && commandSucceeds("systemd-run", ["--version"]),
|
|
300
|
+
systemdUserManagerState: isLinux ? probedSystemdUserManagerState() : null,
|
|
301
|
+
hasPrlimit: isLinux && commandSucceeds("prlimit", ["--version"])
|
|
302
|
+
});
|
|
303
|
+
if (selection.backend === null) fail(selection.errorMessage ?? "no backend");
|
|
304
|
+
var backend = selection.backend;
|
|
305
|
+
var needsNprocCeiling = backend === "linux-prlimit-process-group" /* LinuxPrlimitProcessGroup */ || backend === "darwin-ulimit-process-group" /* DarwinUlimitProcessGroup */;
|
|
306
|
+
var plan = buildBackendLaunchPlan({
|
|
307
|
+
backend,
|
|
308
|
+
options,
|
|
309
|
+
jobObjectShimPath: backend === "windows-job-object" /* WindowsJobObject */ ? resolveJobObjectShimPath() : void 0,
|
|
310
|
+
launcherPid: process.pid,
|
|
311
|
+
commandNeedsCmdShell: backend === "windows-job-object" /* WindowsJobObject */ ? windowsCommandNeedsCmdShell(options.command[0]) : false,
|
|
312
|
+
scopeUnitName: `muggle-guard-${process.pid}-${Date.now()}.scope`,
|
|
313
|
+
nprocCeiling: needsNprocCeiling ? computeNprocCeiling({
|
|
314
|
+
currentUserProcessCount: currentUserProcessCount(),
|
|
315
|
+
activeProcessLimit: options.activeProcessLimit
|
|
316
|
+
}) : void 0
|
|
317
|
+
});
|
|
318
|
+
var child = spawn(plan.executable, plan.args, {
|
|
319
|
+
stdio: "inherit",
|
|
320
|
+
detached: plan.spawnDetached,
|
|
321
|
+
windowsHide: true
|
|
322
|
+
});
|
|
323
|
+
child.on("error", (error) => fail(`failed to launch backend: ${String(error)}`));
|
|
324
|
+
registerTreeCleanup(plan, child.pid);
|
|
325
|
+
child.on("exit", (code, signal) => {
|
|
326
|
+
process.exitCode = code ?? (signal === "SIGKILL" ? 137 : signal === "SIGTERM" ? 143 : 1);
|
|
327
|
+
});
|
|
@@ -9,12 +9,13 @@ set -uo pipefail
|
|
|
9
9
|
# Fires after every Bash call and every muggle execute/replay, so a keyword
|
|
10
10
|
# pre-filter for test runners and the muggle E2E tool names keeps Node off the
|
|
11
11
|
# hot path. Only a `test` command (npm/pnpm/yarn/jest/vitest/pytest/go/cargo),
|
|
12
|
-
# a muggle execute/replay/test-generation event,
|
|
13
|
-
#
|
|
14
|
-
#
|
|
12
|
+
# a muggle execute/replay/test-generation event, a muggle-test skill telemetry
|
|
13
|
+
# emit (registers a clean-SKIP verdict as an E2E run), or an E2E skip marker
|
|
14
|
+
# reaches guardrails.mjs, which then inspects the output for pass/fail and
|
|
15
|
+
# updates state. Degrades to {}.
|
|
15
16
|
payload="$(cat)"
|
|
16
17
|
|
|
17
|
-
if ! grep -Eiq '(pnpm|npm|yarn)[[:space:]]+(run[[:space:]]+)?test|jest|vitest|pytest|go[[:space:]]+test|cargo[[:space:]]+test|muggle.*(execute|test-generation|replay)|MUGGLE_E2E_SKIP' <<<"$payload"; then
|
|
18
|
+
if ! grep -Eiq '(pnpm|npm|yarn)[[:space:]]+(run[[:space:]]+)?test|jest|vitest|pytest|go[[:space:]]+test|cargo[[:space:]]+test|muggle.*(execute|test-generation|replay)|muggle-local-telemetry-skill-emit|MUGGLE_E2E_SKIP' <<<"$payload"; then
|
|
18
19
|
printf '{}'
|
|
19
20
|
exit 0
|
|
20
21
|
fi
|
|
@@ -45,6 +45,8 @@ var GH_PR_MERGED_LINE = /\b(?:Merged|Squashed and merged|Rebased and merged) pul
|
|
|
45
45
|
var GH_PR_CLOSED_LINE = /\bClosed pull request [\w./-]*#(\d+)/;
|
|
46
46
|
var PR_MONITOR_TERMINAL_LINE = /\bTERMINAL pr=(\d+): (MERGED|CLOSED)\b/;
|
|
47
47
|
var MAX_PR_TERMINAL_BLOCKS = 3;
|
|
48
|
+
var MUGGLE_SKILL_EMIT_TOOL = /muggle-local-telemetry-skill-emit/i;
|
|
49
|
+
var MUGGLE_TEST_SKILL_NAME = "muggle-test";
|
|
48
50
|
|
|
49
51
|
// src/guardrails/prTerminal.ts
|
|
50
52
|
function detectPrTerminal(input2) {
|
|
@@ -113,7 +115,8 @@ ${input2.tool_response?.stderr ?? ""}`;
|
|
|
113
115
|
return !FAIL.test(out);
|
|
114
116
|
}
|
|
115
117
|
function isE2ERun(input2) {
|
|
116
|
-
|
|
118
|
+
if (E2E_TOOL.test(input2.tool_name ?? "")) return true;
|
|
119
|
+
return MUGGLE_SKILL_EMIT_TOOL.test(input2.tool_name ?? "") && input2.tool_input?.skillName === MUGGLE_TEST_SKILL_NAME;
|
|
117
120
|
}
|
|
118
121
|
|
|
119
122
|
// src/guardrails/shouldRunE2E.ts
|
|
@@ -124,10 +127,10 @@ function shouldRunE2E(state) {
|
|
|
124
127
|
function applyRecordedRun(state, run) {
|
|
125
128
|
let next = state;
|
|
126
129
|
if (run.unitTestPassed) {
|
|
127
|
-
next = { ...next, unitTestsGreen: true, e2eRun: false
|
|
130
|
+
next = { ...next, unitTestsGreen: true, e2eRun: false };
|
|
128
131
|
}
|
|
129
132
|
if (run.e2eRan) {
|
|
130
|
-
next = { ...next, e2eRun: true };
|
|
133
|
+
next = { ...next, e2eRun: true, e2eBlockCount: 0 };
|
|
131
134
|
}
|
|
132
135
|
if (run.e2eSkipped) {
|
|
133
136
|
next = { ...next, e2eSkipped: true };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
# Self-termination guards for the muggle-pr-followup watch loop. Sourced by the
|
|
4
|
+
# per-slot watch.sh that arm-watcher writes, and the single source of truth for
|
|
5
|
+
# the two conditions that stop a watcher from leaking across sessions.
|
|
6
|
+
#
|
|
7
|
+
# A bare `while true` monitor leaks on Windows: the OS does not stop a detached
|
|
8
|
+
# Git Bash loop when the Claude session that launched it ends, so orphaned
|
|
9
|
+
# watchers accumulate and each keeps spawning gh calls forever. Two guards bound
|
|
10
|
+
# that, and together with arm-watcher's pre-arm dedup keep at most one live
|
|
11
|
+
# watcher per PR:
|
|
12
|
+
#
|
|
13
|
+
# watcher_superseded — <slot>/watch.pid holds the PID of the watcher that
|
|
14
|
+
# owns the slot. A loop whose PID no longer matches has
|
|
15
|
+
# been replaced by a newer arm and must exit.
|
|
16
|
+
# watcher_lifetime_exceeded — a loop exits after MUGGLE_PR_WATCH_MAX_LIFETIME
|
|
17
|
+
# regardless, so an orphan nothing supersedes still dies
|
|
18
|
+
# on its own; reconcile re-arms an open PR inside a live
|
|
19
|
+
# session.
|
|
20
|
+
|
|
21
|
+
MUGGLE_PR_WATCH_MAX_LIFETIME="${MUGGLE_PR_WATCH_MAX_LIFETIME:-21600}"
|
|
22
|
+
MUGGLE_PR_WATCH_POLL_INTERVAL="${MUGGLE_PR_WATCH_POLL_INTERVAL:-60}"
|
|
23
|
+
|
|
24
|
+
# True when watch.pid exists and names a PID other than this loop's — a newer arm
|
|
25
|
+
# has taken ownership of the slot. Absent/empty watch.pid is not superseded: a
|
|
26
|
+
# loop that has not yet claimed the slot keeps running.
|
|
27
|
+
watcher_superseded() {
|
|
28
|
+
local slot="$1" mypid="$2" owner
|
|
29
|
+
[ -f "${slot}/watch.pid" ] || return 1
|
|
30
|
+
owner=$(cat "${slot}/watch.pid" 2>/dev/null)
|
|
31
|
+
[ -n "$owner" ] && [ "$owner" != "$mypid" ]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
watcher_lifetime_exceeded() {
|
|
35
|
+
local started="$1" now="$2" max="${3:-$MUGGLE_PR_WATCH_MAX_LIFETIME}"
|
|
36
|
+
[ $((now - started)) -ge "$max" ]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# True when pid names a running process. `kill -0` sends no signal; EPERM means
|
|
40
|
+
# the process exists but is foreign, which still counts as alive. Used by
|
|
41
|
+
# arm-watcher's pre-arm dedup to decide whether a watcher already owns the slot.
|
|
42
|
+
watcher_pid_alive() {
|
|
43
|
+
local pid="$1"
|
|
44
|
+
[ -n "$pid" ] || return 1
|
|
45
|
+
kill -0 "$pid" 2>/dev/null
|
|
46
|
+
}
|
|
@@ -8,11 +8,11 @@ Append these two lines as the end of every loop-posted comment body:
|
|
|
8
8
|
|
|
9
9
|
```
|
|
10
10
|
<!-- muggle-do:bot -->
|
|
11
|
-
🤖
|
|
11
|
+
🤖 _Posted by `/muggle-do` · [Muggle Works](https://github.com/multiplex-ai/muggle-ai-works)_
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
- `<!-- muggle-do:bot -->` — hidden HTML marker; GitHub renders it invisibly and humans never type it. This is the **detection token**.
|
|
15
|
-
- The visible line
|
|
14
|
+
- `<!-- muggle-do:bot -->` — hidden HTML marker; GitHub renders it invisibly and humans never type it. This is the **detection token**. It must stay exactly as written — echo-protection and addressed-by-loop classification read this literal string.
|
|
15
|
+
- The visible line is the shared Muggle Works signature ([`../vcs/post-signature.md`](../vcs/post-signature.md)) with `/muggle-do` as the command. It links a reader back to the tool and names the command that posted the comment.
|
|
16
16
|
|
|
17
17
|
## Detection
|
|
18
18
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# VCS provider recipes
|
|
2
2
|
|
|
3
|
-
Provider-agnostic seam for the watcher and dev-cycle skills. A caller resolves one provider token via [`detect-vcs.md`](detect-vcs.md) — `github` (`gh`) or `gitlab` (`glab`) — then loads the matching provider's recipe for each agnostic step. The two recipe sets mirror each other call-for-call; callers branch on the token, never fork procedure.
|
|
3
|
+
Provider-agnostic seam for the watcher and dev-cycle skills. A caller resolves one provider token via [`detect-vcs.md`](detect-vcs.md) — `github` (`gh`) or `gitlab` (`glab`) — then loads the matching provider's recipe for each agnostic step. The two recipe sets mirror each other call-for-call; callers branch on the token, never fork procedure. Provider-agnostic entry points live once in `common/`, indexed by both TOCs: a **dispatcher** owns the `github`-vs-`gitlab` branch so a caller links it alone instead of naming both recipes side by side (`ci-rollup`, `branch-standing`, `push-to-branch`), and a recipe with no provider-specific procedure lives there too (`verify-working-tree`).
|
|
4
4
|
|
|
5
5
|
## Index
|
|
6
6
|
|
|
7
7
|
- [`detect-vcs.md`](detect-vcs.md) — resolve `github` | `gitlab` from a URL argument or the origin remote.
|
|
8
8
|
- [`github.md`](github.md) — `gh` / `git` recipe TOC; per-recipe files in [`github/`](github/).
|
|
9
9
|
- [`gitlab.md`](gitlab.md) — `glab` / `git` recipe TOC; per-recipe files in [`gitlab/`](gitlab/).
|
|
10
|
+
- `common/` — provider-agnostic entry points (`ci-rollup`, `branch-standing`, `push-to-branch`, `verify-working-tree`), indexed by both provider TOCs.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Branch standing vs base (tool-agnostic)
|
|
2
|
+
|
|
3
|
+
The single instruction the rebase trigger references for **conflict** and **out-of-date** detection — sites link here and never name a provider's metadata recipe inline. Both signals read from the metadata snapshot the caller already fetched; this file only says which field each provider reads.
|
|
4
|
+
|
|
5
|
+
Resolve the provider per [`../detect-vcs.md`](../detect-vcs.md).
|
|
6
|
+
|
|
7
|
+
## Conflict with the base
|
|
8
|
+
|
|
9
|
+
- `github` → `mergeable == CONFLICTING`, corroborated by `mergeStateStatus == DIRTY` ([`../github/pr-metadata.md`](../github/pr-metadata.md)).
|
|
10
|
+
- `gitlab` → `detailed_merge_status` in `{broken_status, conflict}` ([`../gitlab/mr-metadata.md`](../gitlab/mr-metadata.md)).
|
|
11
|
+
|
|
12
|
+
A still-computing state — `github`'s `mergeable == UNKNOWN`, `gitlab`'s `checking` / `unchecked` — counts as **not conflicting** this tick.
|
|
13
|
+
|
|
14
|
+
## Out of date (behind the base)
|
|
15
|
+
|
|
16
|
+
Read from **commit ancestry**, never from the merge-state field: GitHub masks `BEHIND` behind `DIRTY` / `BLOCKED`, and GitLab reports `need_rebase` only under fast-forward-merge — both hide a stale branch. The compare is exact even while the conflict state is still computing.
|
|
17
|
+
|
|
18
|
+
- `github` → the compare call in [`../github/pr-metadata.md`](../github/pr-metadata.md#behind-by-out-of-date-detection); `behind_by > 0` ⇒ out of date.
|
|
19
|
+
- `gitlab` → the compare call in [`../gitlab/mr-metadata.md`](../gitlab/mr-metadata.md#behind-by-out-of-date-detection); any base commit the head lacks ⇒ out of date.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# CI rollup (tool-agnostic)
|
|
2
|
+
|
|
3
|
+
The single instruction every CI-poll site references — sites link here and never name a provider's rollup recipe inline. Fetch the CI state for a PR/MR head and fold it into one of three buckets: **red**, **pending**, **green**.
|
|
4
|
+
|
|
5
|
+
Resolve the provider per [`../detect-vcs.md`](../detect-vcs.md), then run its rollup recipe — the recipe owns the fetch command and the raw per-check/per-job states:
|
|
6
|
+
|
|
7
|
+
- `github` → [`../github/pr-checks.md`](../github/pr-checks.md) — the check-run rollup for the head SHA.
|
|
8
|
+
- `gitlab` → [`../gitlab/mr-pipeline.md`](../gitlab/mr-pipeline.md) — the pipeline-job rollup for the head.
|
|
9
|
+
|
|
10
|
+
Both recipes fold into the same buckets, so a caller branches on the token only to fetch, then reasons on the bucket alone:
|
|
11
|
+
|
|
12
|
+
- **red** — one or more entries in the `fail` bucket (a failed check-run; a `failed` job). Candidate for fix-ci; the dispatch carries the failing names.
|
|
13
|
+
- **pending** — nothing failed but an entry has not settled (`pending` bucket; a `running` / `pending` / `created` job). The result may yet go green.
|
|
14
|
+
- **green** — every entry passed, was skipped, or was cancelled, or there are none at all.
|
|
15
|
+
|
|
16
|
+
The rollup is **non-monotonic** — it flips red↔green and resets on every push. A caller that records a red result keys it on the **head SHA**, not a monotonic id, so the record re-arms on the next push.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Push to branch (tool-agnostic)
|
|
2
|
+
|
|
3
|
+
The single instruction every commit/push site references — sites link here and never embed provider commands inline.
|
|
4
|
+
|
|
5
|
+
## Signing gate — never push unsigned commits
|
|
6
|
+
|
|
7
|
+
Resolve the provider per [`../detect-vcs.md`](../detect-vcs.md), then run its signed-commits recipe — the recipe owns the preflight and the actual commands:
|
|
8
|
+
|
|
9
|
+
- `github` → [`../github/signed-commits.md`](../github/signed-commits.md) — local signing working: commit and push normally; broken: create the commits server-signed (`createCommitOnBranch`) and skip the push (the remote already has them). Rebase/force-push follows the same recipe's replay path.
|
|
10
|
+
- `gitlab` → [`../gitlab/signed-commits.md`](../gitlab/signed-commits.md) — local signing working: commit and push normally; broken: stop and escalate (no server-side signing).
|
|
11
|
+
|
|
12
|
+
Never disable signing to make a push go through.
|
|
13
|
+
|
|
14
|
+
## After the push
|
|
15
|
+
|
|
16
|
+
Capture the new head SHA — from the local branch, or from the mutation response on the server-signed path — and append it to `last_seen.pushed_shas[]` so resolve-reminder can recognize it.
|
|
@@ -8,10 +8,10 @@ git remote get-url origin # remote matches <owner>/<repo>
|
|
|
8
8
|
git rev-parse --abbrev-ref HEAD # current branch matches PR's headRefName
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
Accept any remote URL form for `<owner>/<repo
|
|
11
|
+
Accept any remote URL form for `<owner>/<repo>`, with or without trailing `.git`, where `<host>` is the provider host (`github.com`, or the GitLab instance host):
|
|
12
12
|
|
|
13
|
-
- `https
|
|
14
|
-
- `git
|
|
15
|
-
- `ssh://git
|
|
13
|
+
- `https://<host>/<owner>/<repo>`
|
|
14
|
+
- `git@<host>:<owner>/<repo>`
|
|
15
|
+
- `ssh://git@<host>/<owner>/<repo>`
|
|
16
16
|
|
|
17
17
|
Any mismatch → abort; the calling skill reports the wrong checkout to the user with its own message.
|
|
@@ -6,3 +6,5 @@ For `open-prs/update.md` when E2E state flips (passing↔failing) or validation
|
|
|
6
6
|
gh pr edit <pr-number> --repo <owner>/<repo> --title "<new-title>"
|
|
7
7
|
gh pr edit <pr-number> --repo <owner>/<repo> --body-file <file>
|
|
8
8
|
```
|
|
9
|
+
|
|
10
|
+
The `<file>` body must end with the Muggle Works signature. Because the description is re-posted on each refresh, strip the old signature (from the `<!-- muggle-works:signature -->` marker to the end) before re-appending it — see [`../post-signature.md`](../post-signature.md).
|
|
@@ -8,3 +8,5 @@ gh api --method POST \
|
|
|
8
8
|
repos/<owner>/<repo>/pulls/<n>/comments/<comment-id>/replies \
|
|
9
9
|
-f body="<reply-text>"
|
|
10
10
|
```
|
|
11
|
+
|
|
12
|
+
The `<reply-text>` must end with the loop signature block — the `<!-- muggle-do:bot -->` detection marker above the Muggle Works line. See [`../post-signature.md`](../post-signature.md).
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Signed commits without local signing
|
|
2
|
+
|
|
3
|
+
**Never push unsigned commits.** When the machine has no working signing setup, do not fall back to unsigned `git commit` + `git push` — create the commits **remotely** with GraphQL `createCommitOnBranch`, which GitHub signs server-side. And never "fix" a signing failure by disabling it: no `--no-gpg-sign`, no `-c commit.gpgsign=false` — a broken signing setup is fixed or routed around via the remote path, not turned off.
|
|
4
|
+
|
|
5
|
+
## Preflight
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
git -C <repo-path> log --format='%G?' origin/<branch>..HEAD # unpushed branch: <base>..HEAD
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Any `N` (no signature) among the commits about to leave the machine → the push is blocked; use the remote path below. `G`/`E`/`U` are signed commits (locally unverifiable is fine — GitHub-signed commits show `E` without GitHub's key in the local keyring). Signing configured and working → commit and push normally.
|
|
12
|
+
|
|
13
|
+
## Remote signed commit
|
|
14
|
+
|
|
15
|
+
1. The branch must exist on the remote; create it at the base if absent: `gh api repos/<owner>/<repo>/git/refs -f ref=refs/heads/<branch> -f sha=<base-sha>`.
|
|
16
|
+
2. Stage the change (`git add <files>`), then take each file's canonical bytes from the **index** — `git cat-file blob :<path>` — and base64 them. Index blobs, never working-copy reads: the index holds git's normalized content (line endings, filters), so the remote tree matches what a local commit would have produced.
|
|
17
|
+
3. One `createCommitOnBranch` mutation per commit: `branch {repositoryNameWithOwner, branchName}`, `message {headline, body}`, `expectedHeadOid` = the branch's current remote head (a concurrency lease — a concurrent push 409s instead of being clobbered), `fileChanges.additions` (path + base64 contents; removals via `fileChanges.deletions`).
|
|
18
|
+
4. Sync the local branch to the result: `git fetch origin <branch>`, verify `git diff --cached origin/<branch>` is **empty** (remote tree identical to what was staged), then `git reset --hard origin/<branch>`. A non-empty diff means the payload missed a file — fix and re-commit; never leave local and remote diverged.
|
|
19
|
+
|
|
20
|
+
## Rebase / force-push
|
|
21
|
+
|
|
22
|
+
A local rebase mints new local commits — unsigned on this machine — so the `--force-with-lease` path is equally blocked. Replay instead:
|
|
23
|
+
|
|
24
|
+
1. Create a temp ref at the new base tip.
|
|
25
|
+
2. Re-create each branch commit on it via `createCommitOnBranch` (same per-commit file snapshots from `git cat-file blob <commit>:<path>`; conflicted files carry the resolved content).
|
|
26
|
+
3. Move the branch ref with a lease: verify the branch's current remote head is still the expected pre-rebase SHA, then `gh api -X PATCH repos/<owner>/<repo>/git/refs/heads/<branch> -f sha=<new-head> -F force=true`. Delete the temp ref.
|
|
27
|
+
|
|
28
|
+
The replayed commits are GitHub-signed; the guarded ref move is the only force operation.
|