@exla/rudder 0.0.0-stage → 3.4.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/LICENSE +100 -0
- package/README.md +733 -2
- package/assets/rudder.icns +0 -0
- package/assets/sounds/NOTICE.md +8 -0
- package/assets/sounds/ping.mp3 +0 -0
- package/dist/agent-attention.d.ts +5 -0
- package/dist/agent-attention.js +54 -0
- package/dist/agent-attention.js.map +1 -0
- package/dist/analytics.d.ts +36 -0
- package/dist/analytics.js +178 -0
- package/dist/analytics.js.map +1 -0
- package/dist/attach-session.d.ts +91 -0
- package/dist/attach-session.js +290 -0
- package/dist/attach-session.js.map +1 -0
- package/dist/auth.d.ts +36 -0
- package/dist/auth.js +392 -0
- package/dist/auth.js.map +1 -0
- package/dist/backends.d.ts +3 -0
- package/dist/backends.js +638 -0
- package/dist/backends.js.map +1 -0
- package/dist/board/board.css +1 -0
- package/dist/board/board.js +2 -0
- package/dist/board/daemon.d.ts +39 -0
- package/dist/board/daemon.js +1510 -0
- package/dist/board/daemon.js.map +1 -0
- package/dist/brain.d.ts +24 -0
- package/dist/brain.js +244 -0
- package/dist/brain.js.map +1 -0
- package/dist/bus.d.ts +9 -0
- package/dist/bus.js +23 -0
- package/dist/bus.js.map +1 -0
- package/dist/checkout-sync.d.ts +35 -0
- package/dist/checkout-sync.js +247 -0
- package/dist/checkout-sync.js.map +1 -0
- package/dist/cloud.d.ts +48 -0
- package/dist/cloud.js +4596 -0
- package/dist/cloud.js.map +1 -0
- package/dist/codex-binary.d.ts +12 -0
- package/dist/codex-binary.js +184 -0
- package/dist/codex-binary.js.map +1 -0
- package/dist/context-audit.d.ts +21 -0
- package/dist/context-audit.js +109 -0
- package/dist/context-audit.js.map +1 -0
- package/dist/daemon.d.ts +27 -0
- package/dist/daemon.js +203 -0
- package/dist/daemon.js.map +1 -0
- package/dist/effort.d.ts +9 -0
- package/dist/effort.js +72 -0
- package/dist/effort.js.map +1 -0
- package/dist/feedback.d.ts +46 -0
- package/dist/feedback.js +173 -0
- package/dist/feedback.js.map +1 -0
- package/dist/gc.d.ts +3 -0
- package/dist/gc.js +262 -0
- package/dist/gc.js.map +1 -0
- package/dist/git.d.ts +31 -0
- package/dist/git.js +455 -0
- package/dist/git.js.map +1 -0
- package/dist/goal.d.ts +33 -0
- package/dist/goal.js +95 -0
- package/dist/goal.js.map +1 -0
- package/dist/graph.d.ts +93 -0
- package/dist/graph.js +372 -0
- package/dist/graph.js.map +1 -0
- package/dist/handoff.d.ts +110 -0
- package/dist/handoff.js +565 -0
- package/dist/handoff.js.map +1 -0
- package/dist/improve/advisor.d.ts +10 -0
- package/dist/improve/advisor.js +115 -0
- package/dist/improve/advisor.js.map +1 -0
- package/dist/improve/collect.d.ts +49 -0
- package/dist/improve/collect.js +274 -0
- package/dist/improve/collect.js.map +1 -0
- package/dist/improve/gate.d.ts +17 -0
- package/dist/improve/gate.js +60 -0
- package/dist/improve/gate.js.map +1 -0
- package/dist/improve/index.d.ts +9 -0
- package/dist/improve/index.js +376 -0
- package/dist/improve/index.js.map +1 -0
- package/dist/improve/judge.d.ts +32 -0
- package/dist/improve/judge.js +114 -0
- package/dist/improve/judge.js.map +1 -0
- package/dist/improve/mine.d.ts +31 -0
- package/dist/improve/mine.js +192 -0
- package/dist/improve/mine.js.map +1 -0
- package/dist/improve/propose.d.ts +51 -0
- package/dist/improve/propose.js +150 -0
- package/dist/improve/propose.js.map +1 -0
- package/dist/improve/rank.d.ts +16 -0
- package/dist/improve/rank.js +49 -0
- package/dist/improve/rank.js.map +1 -0
- package/dist/improve/schedule.d.ts +1 -0
- package/dist/improve/schedule.js +95 -0
- package/dist/improve/schedule.js.map +1 -0
- package/dist/improve/ship.d.ts +25 -0
- package/dist/improve/ship.js +82 -0
- package/dist/improve/ship.js.map +1 -0
- package/dist/improve/state.d.ts +157 -0
- package/dist/improve/state.js +264 -0
- package/dist/improve/state.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +88 -0
- package/dist/index.js.map +1 -0
- package/dist/jj-binary.d.ts +36 -0
- package/dist/jj-binary.js +143 -0
- package/dist/jj-binary.js.map +1 -0
- package/dist/jj.d.ts +163 -0
- package/dist/jj.js +926 -0
- package/dist/jj.js.map +1 -0
- package/dist/logger.d.ts +22 -0
- package/dist/logger.js +110 -0
- package/dist/logger.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.js +1366 -0
- package/dist/main.js.map +1 -0
- package/dist/migration.d.ts +82 -0
- package/dist/migration.js +198 -0
- package/dist/migration.js.map +1 -0
- package/dist/models.d.ts +9 -0
- package/dist/models.js +623 -0
- package/dist/models.js.map +1 -0
- package/dist/move-home.d.ts +82 -0
- package/dist/move-home.js +472 -0
- package/dist/move-home.js.map +1 -0
- package/dist/native/darwin-arm64/rudder-native +0 -0
- package/dist/native/darwin-x64/rudder-native +0 -0
- package/dist/native/linux-x64/rudder-native +0 -0
- package/dist/native-agents.d.ts +8 -0
- package/dist/native-agents.js +178 -0
- package/dist/native-agents.js.map +1 -0
- package/dist/native-binary.d.ts +4 -0
- package/dist/native-binary.js +140 -0
- package/dist/native-binary.js.map +1 -0
- package/dist/paste-bridge.d.ts +36 -0
- package/dist/paste-bridge.js +279 -0
- package/dist/paste-bridge.js.map +1 -0
- package/dist/plan-mode.d.ts +2 -0
- package/dist/plan-mode.js +22 -0
- package/dist/plan-mode.js.map +1 -0
- package/dist/planner.d.ts +27 -0
- package/dist/planner.js +587 -0
- package/dist/planner.js.map +1 -0
- package/dist/predict.d.ts +67 -0
- package/dist/predict.js +369 -0
- package/dist/predict.js.map +1 -0
- package/dist/report.d.ts +74 -0
- package/dist/report.js +412 -0
- package/dist/report.js.map +1 -0
- package/dist/rudder-md.d.ts +33 -0
- package/dist/rudder-md.js +142 -0
- package/dist/rudder-md.js.map +1 -0
- package/dist/run-manager.d.ts +62 -0
- package/dist/run-manager.js +1400 -0
- package/dist/run-manager.js.map +1 -0
- package/dist/scheduler.d.ts +128 -0
- package/dist/scheduler.js +955 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/state.d.ts +127 -0
- package/dist/state.js +704 -0
- package/dist/state.js.map +1 -0
- package/dist/surfaces.d.ts +91 -0
- package/dist/surfaces.js +460 -0
- package/dist/surfaces.js.map +1 -0
- package/dist/task-summary.d.ts +30 -0
- package/dist/task-summary.js +472 -0
- package/dist/task-summary.js.map +1 -0
- package/dist/types.d.ts +492 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/usage.d.ts +198 -0
- package/dist/usage.js +830 -0
- package/dist/usage.js.map +1 -0
- package/dist/util.d.ts +73 -0
- package/dist/util.js +399 -0
- package/dist/util.js.map +1 -0
- package/dist/version-check.d.ts +16 -0
- package/dist/version-check.js +269 -0
- package/dist/version-check.js.map +1 -0
- package/package.json +68 -4
- package/scripts/copy-native.mjs +89 -0
- package/scripts/postinstall.mjs +34 -0
package/dist/jj.js
ADDED
|
@@ -0,0 +1,926 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import fsp from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { commandExists, MissingToolError, pathExistsSync, runCommand, runCommandSync, shortHash, } from "./util.js";
|
|
5
|
+
import { ensureProjectRuntimeIgnored, saveRunRecord, workspacePath } from "./state.js";
|
|
6
|
+
import { DECISIONS_HEADER } from "./surfaces.js";
|
|
7
|
+
import { withIntegrationLock } from "./rudder-md.js";
|
|
8
|
+
import { managedJjInstallError } from "./jj-binary.js";
|
|
9
|
+
// Minimum jj version we are confident in. Older releases may lack flags we use
|
|
10
|
+
// (e.g. `jj workspace add -r`); we warn rather than throw so an older jj still
|
|
11
|
+
// works through the documented fallbacks.
|
|
12
|
+
const MIN_JJ = "0.21.0";
|
|
13
|
+
/**
|
|
14
|
+
* Hard requirement: jj must be installed. Throws MissingToolError otherwise.
|
|
15
|
+
*/
|
|
16
|
+
export function ensureJj() {
|
|
17
|
+
if (!commandExists("jj")) {
|
|
18
|
+
const reason = managedJjInstallError();
|
|
19
|
+
throw new MissingToolError("jj", reason ? `jj is not installed, and Rudder could not set it up (${reason}). ${TOOL_JJ_FALLBACK}` : undefined);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const TOOL_JJ_FALLBACK = "Install it yourself with `brew install jj` or `cargo install jj-cli` (https://jj-vcs.github.io), then run rudder again.";
|
|
23
|
+
/**
|
|
24
|
+
* Parse `jj --version`. Returns the version string (e.g. "0.40.0") or "".
|
|
25
|
+
* Warns (does not throw) when below MIN_JJ.
|
|
26
|
+
*/
|
|
27
|
+
export async function jjVersion() {
|
|
28
|
+
const result = await runCommand("jj", ["--version"], { allowFailure: true });
|
|
29
|
+
const match = result.stdout.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
30
|
+
const version = match ? match[0] : "";
|
|
31
|
+
if (version && compareVersions(version, MIN_JJ) < 0) {
|
|
32
|
+
console.warn(`[rudder] jj ${version} is older than the recommended ${MIN_JJ}; some workspace flags may fall back.`);
|
|
33
|
+
}
|
|
34
|
+
return version;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Idempotently colocate a jj repo with the git repo at repoRoot. If already a
|
|
38
|
+
* jj repo, no-op. Otherwise runs `jj git init --colocate`.
|
|
39
|
+
*/
|
|
40
|
+
export async function ensureColocated(repoRoot) {
|
|
41
|
+
// Every jj command snapshots the working copy by default, including `jj
|
|
42
|
+
// root`. Install the exclusions before even probing repository state.
|
|
43
|
+
await ensureProjectRuntimeIgnored(repoRoot);
|
|
44
|
+
ensureJj();
|
|
45
|
+
if (!isJjRepo(repoRoot)) {
|
|
46
|
+
await runCommand("jj", ["git", "init", "--colocate"], { cwd: repoRoot });
|
|
47
|
+
}
|
|
48
|
+
await untrackLegacyRudderState(repoRoot);
|
|
49
|
+
}
|
|
50
|
+
/** Remove runtime files snapshotted by older Rudder versions while preserving
|
|
51
|
+
* them on disk. Ignore rules alone do not untrack a path already present in @. */
|
|
52
|
+
async function untrackLegacyRudderState(repoRoot) {
|
|
53
|
+
const tracked = await runCommand("jj", ["file", "list", "--ignore-working-copy", "-r", "@", ".rudder"], { cwd: repoRoot, allowFailure: true });
|
|
54
|
+
if (tracked.code !== 0 || !tracked.stdout.trim())
|
|
55
|
+
return;
|
|
56
|
+
await runCommand("jj", ["file", "untrack", ".rudder"], { cwd: repoRoot });
|
|
57
|
+
}
|
|
58
|
+
export function isJjRepo(cwd) {
|
|
59
|
+
if (!commandExists("jj")) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
const result = runCommandSync("jj", ["root"], { cwd, allowFailure: true });
|
|
63
|
+
if (result.code === 0 && result.stdout.trim()) {
|
|
64
|
+
return samePath(result.stdout.trim(), cwd);
|
|
65
|
+
}
|
|
66
|
+
return hasLocalJjMarker(cwd);
|
|
67
|
+
}
|
|
68
|
+
export function findJjRoot(cwd) {
|
|
69
|
+
if (commandExists("jj")) {
|
|
70
|
+
const result = runCommandSync("jj", ["root"], { cwd, allowFailure: true });
|
|
71
|
+
if (result.code === 0 && result.stdout.trim()) {
|
|
72
|
+
return result.stdout.trim();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const marker = findJjMarker(cwd);
|
|
76
|
+
return marker ? path.dirname(marker) : path.resolve(cwd);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* jj refuses to operate on a STALE workspace — one whose working-copy commit was
|
|
80
|
+
* moved by a concurrent operation in another workspace (every command in any
|
|
81
|
+
* workspace advances the shared op log). The command exits non-zero with a
|
|
82
|
+
* "working copy is stale" message and a hint to run `jj workspace update-stale`.
|
|
83
|
+
* In a multi-worker run this happens routinely, so any jj read against a worker
|
|
84
|
+
* workspace must be able to recover instead of falsely reporting "no changes"
|
|
85
|
+
* (which marks a good run failed) or failing a merge.
|
|
86
|
+
*/
|
|
87
|
+
function isStaleWorkingCopy(text) {
|
|
88
|
+
return /working copy is stale|stale working copy/i.test(text);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Re-point a stale workspace at its recorded working-copy commit. jj snapshots
|
|
92
|
+
* the on-disk working copy as part of this, so a worker's changes are preserved.
|
|
93
|
+
* Returns true when the workspace is no longer stale afterwards.
|
|
94
|
+
*/
|
|
95
|
+
async function recoverStaleWorkspace(workspacePath) {
|
|
96
|
+
const result = await runCommand("jj", ["workspace", "update-stale"], {
|
|
97
|
+
cwd: workspacePath,
|
|
98
|
+
allowFailure: true,
|
|
99
|
+
});
|
|
100
|
+
return result.code === 0;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Run a read-only jj command in a workspace, transparently recovering once from a
|
|
104
|
+
* stale working copy. This keeps the verifier's change-detection and the merge's
|
|
105
|
+
* change-id reads honest while workers run concurrently in sibling workspaces.
|
|
106
|
+
*/
|
|
107
|
+
async function jjReadInWorkspace(workspacePath, args) {
|
|
108
|
+
let result = await runCommand("jj", args, { cwd: workspacePath, allowFailure: true });
|
|
109
|
+
if (result.code !== 0 && isStaleWorkingCopy(result.stderr)) {
|
|
110
|
+
await recoverStaleWorkspace(workspacePath);
|
|
111
|
+
result = await runCommand("jj", args, { cwd: workspacePath, allowFailure: true });
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The same recovery for a jj command that WRITES.
|
|
117
|
+
*
|
|
118
|
+
* Reads had this and writes did not, which left the main checkout able to wedge
|
|
119
|
+
* every launch in a repo: `jj workspace add` snapshots the workspace it is run
|
|
120
|
+
* FROM, so once a sibling worker's operation left the main checkout stale, every
|
|
121
|
+
* new agent — a typed task, a plan node, a `/resume`, review-all — died on
|
|
122
|
+
* "The working copy is stale" until the user ran `update-stale` by hand. The
|
|
123
|
+
* recovery is exactly what jj's own hint says to do, and it snapshots the
|
|
124
|
+
* working copy rather than discarding it, so nothing on disk is lost.
|
|
125
|
+
*/
|
|
126
|
+
export async function jjWriteWithStaleRecovery(cwd, args) {
|
|
127
|
+
let result = await runCommand("jj", args, { cwd, allowFailure: true });
|
|
128
|
+
// jj reports staleness on stderr, but check both: the message has moved
|
|
129
|
+
// between jj releases and a missed match reintroduces the wedge.
|
|
130
|
+
if (result.code !== 0 && isStaleWorkingCopy(`${result.stderr}\n${result.stdout}`)) {
|
|
131
|
+
if (await recoverStaleWorkspace(cwd)) {
|
|
132
|
+
result = await runCommand("jj", args, { cwd, allowFailure: true });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The change id of `@` (the working-copy commit) in the given workspace.
|
|
139
|
+
*/
|
|
140
|
+
export async function currentJjChangeId(workspacePath) {
|
|
141
|
+
const result = await jjReadInWorkspace(workspacePath, ["log", "--no-graph", "-r", "@", "-T", "change_id"]);
|
|
142
|
+
return result.code === 0 ? result.stdout.trim().split(/\s+/)[0] ?? "" : "";
|
|
143
|
+
}
|
|
144
|
+
/** The immutable commit id of a workspace's current revision. Prefer this when
|
|
145
|
+
* feeding a finished workspace into a merge: concurrent jj snapshots can make
|
|
146
|
+
* its stable change id divergent, while the workspace's @ commit remains an
|
|
147
|
+
* unambiguous revision. */
|
|
148
|
+
export async function currentJjCommitId(workspacePath) {
|
|
149
|
+
const result = await jjReadInWorkspace(workspacePath, ["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
|
|
150
|
+
return result.code === 0 ? result.stdout.trim().split(/\s+/)[0] ?? "" : "";
|
|
151
|
+
}
|
|
152
|
+
export async function jjStatus(workspacePath) {
|
|
153
|
+
const result = await jjReadInWorkspace(workspacePath, ["status"]);
|
|
154
|
+
return result.code === 0 ? parseJjStatus(result.stdout) : [];
|
|
155
|
+
}
|
|
156
|
+
export function parseJjStatus(stdout) {
|
|
157
|
+
const lines = [];
|
|
158
|
+
let inConflictSection = false;
|
|
159
|
+
for (const raw of stdout.split(/\r?\n/)) {
|
|
160
|
+
const line = raw.trim();
|
|
161
|
+
if (!line) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (/^working copy changes:?$/i.test(line)) {
|
|
165
|
+
inConflictSection = false;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (/^(conflicts|unresolved conflicts|there are unresolved conflicts)(:|$)/i.test(line)) {
|
|
169
|
+
inConflictSection = true;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (isJjStatusMetadata(line)) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const statusPath = statusPathFromSummaryLine(line);
|
|
176
|
+
if (statusPath) {
|
|
177
|
+
if (!isRudderMetadataPath(statusPath)) {
|
|
178
|
+
lines.push(line);
|
|
179
|
+
}
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (inConflictSection) {
|
|
183
|
+
const conflictPath = line.replace(/^[-*]\s+/, "").replace(/^conflict in\s+/i, "").trim();
|
|
184
|
+
if (conflictPath && !isRudderMetadataPath(conflictPath)) {
|
|
185
|
+
lines.push(`C ${conflictPath}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return lines;
|
|
190
|
+
}
|
|
191
|
+
export async function jjDiff(workspacePath) {
|
|
192
|
+
const status = await jjStatus(workspacePath);
|
|
193
|
+
// A sibling workspace can make this one stale mid-verification; an unrecovered
|
|
194
|
+
// read returns an empty diff and the verifier flags a healthy run.
|
|
195
|
+
const stat = await jjReadInWorkspace(workspacePath, ["diff", "--stat"]);
|
|
196
|
+
const patch = await jjReadInWorkspace(workspacePath, ["diff", "--git"]);
|
|
197
|
+
return [
|
|
198
|
+
status.length ? `status:\n${status.join("\n")}` : "",
|
|
199
|
+
stat.stdout.trim(),
|
|
200
|
+
patch.stdout.trim(),
|
|
201
|
+
]
|
|
202
|
+
.filter(Boolean)
|
|
203
|
+
.join("\n\n");
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Create a new empty change parented on the given changes. Reads the new change
|
|
207
|
+
* id back by querying a unique marker embedded in the description, never by
|
|
208
|
+
* scraping stdout. Returns the new change id.
|
|
209
|
+
*/
|
|
210
|
+
export async function createEmptyChange(params) {
|
|
211
|
+
ensureJj();
|
|
212
|
+
const marker = `rudder-node:${shortHash(`${params.description}:${Date.now()}:${Math.random()}`)}`;
|
|
213
|
+
const description = `${marker} ${params.description}`.trim();
|
|
214
|
+
const args = ["new", ...params.parents.filter(Boolean), "-m", description, "--no-edit"];
|
|
215
|
+
// Same stale exposure as `workspace add`: the scheduler creates a node's base
|
|
216
|
+
// change in the main checkout moments before its workspace, so one stale main
|
|
217
|
+
// checkout would fail the plan a step earlier instead.
|
|
218
|
+
const created = await jjWriteWithStaleRecovery(params.repoRoot, args);
|
|
219
|
+
if (created.code !== 0) {
|
|
220
|
+
throw new Error(created.stderr.trim() || created.stdout.trim() || "jj new failed.");
|
|
221
|
+
}
|
|
222
|
+
// jj normalizes descriptions to end with a trailing newline, and
|
|
223
|
+
// `description(exact:...)` matches the full stored description, so the revset
|
|
224
|
+
// literal must include the trailing "\n".
|
|
225
|
+
const exact = JSON.stringify(`${description}\n`);
|
|
226
|
+
const result = await runCommand("jj", ["log", "--no-graph", "-r", `description(exact:${exact})`, "-T", "change_id"], { cwd: params.repoRoot, allowFailure: true });
|
|
227
|
+
let changeId = result.stdout.trim().split(/\s+/)[0] ?? "";
|
|
228
|
+
if (!changeId) {
|
|
229
|
+
// Fallback: match on the unique marker as a substring.
|
|
230
|
+
const bySubstring = await runCommand("jj", ["log", "--no-graph", "-r", `description(substring:${JSON.stringify(marker)})`, "-T", "change_id"], { cwd: params.repoRoot, allowFailure: true });
|
|
231
|
+
changeId = bySubstring.stdout.trim().split(/\s+/)[0] ?? "";
|
|
232
|
+
}
|
|
233
|
+
if (!changeId) {
|
|
234
|
+
throw new Error(`Could not read back the change id for the new empty change (${marker}).`);
|
|
235
|
+
}
|
|
236
|
+
return changeId;
|
|
237
|
+
}
|
|
238
|
+
export async function describeChange(repoRoot, changeId, message) {
|
|
239
|
+
await runCommand("jj", ["describe", changeId, "-m", message], { cwd: repoRoot });
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Create a jj workspace for a node/run. Workspace name matches the original
|
|
243
|
+
* rudder regex `rudder-<id.slice(0,14)>-<shortHash(id).slice(0,6)>`. If
|
|
244
|
+
* `atChangeId` is given, parent the working-copy commit on it; falls back to
|
|
245
|
+
* `jj workspace add` + `jj edit <atChangeId>` if `-r` is unsupported.
|
|
246
|
+
*/
|
|
247
|
+
export async function createNodeWorkspace(params) {
|
|
248
|
+
ensureJj();
|
|
249
|
+
// jj snapshots the main checkout while adding a workspace. Runtime state
|
|
250
|
+
// must already be excluded at that exact point.
|
|
251
|
+
await ensureProjectRuntimeIgnored(params.repoRoot);
|
|
252
|
+
const id = params.nodeId ?? params.runId;
|
|
253
|
+
if (!id) {
|
|
254
|
+
throw new Error("createNodeWorkspace requires nodeId or runId.");
|
|
255
|
+
}
|
|
256
|
+
const workspaceName = workspaceNameFor(id);
|
|
257
|
+
const targetPath = workspacePath(params.repoRoot, params.runId ?? id, params.task);
|
|
258
|
+
await fsp.mkdir(path.dirname(targetPath), { recursive: true });
|
|
259
|
+
const baseArgs = ["workspace", "add", targetPath, "--name", workspaceName];
|
|
260
|
+
let added = await jjWriteWithStaleRecovery(params.repoRoot, params.atChangeId ? [...baseArgs, "-r", params.atChangeId] : baseArgs);
|
|
261
|
+
if (added.code !== 0 && params.atChangeId) {
|
|
262
|
+
// Older jj without `workspace add -r`: add, then edit onto the change.
|
|
263
|
+
added = await jjWriteWithStaleRecovery(params.repoRoot, baseArgs);
|
|
264
|
+
if (added.code === 0) {
|
|
265
|
+
await runCommand("jj", ["edit", params.atChangeId], { cwd: targetPath, allowFailure: true });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (added.code !== 0) {
|
|
269
|
+
throw new Error(added.stderr.trim() || added.stdout.trim() || `jj ${baseArgs.join(" ")} failed.`);
|
|
270
|
+
}
|
|
271
|
+
// Describe the workspace's OWN working-copy change (@) so `jj log` shows what
|
|
272
|
+
// this agent is doing instead of "(no description set)". Rudder agents read
|
|
273
|
+
// each other's jj descriptions to coordinate; RUDDER.md stays the aggregate
|
|
274
|
+
// human snapshot. Best-effort — a missing description must never fail a launch.
|
|
275
|
+
await runCommand("jj", ["describe", "-m", workspaceDescription(params, "running")], {
|
|
276
|
+
cwd: targetPath,
|
|
277
|
+
allowFailure: true,
|
|
278
|
+
});
|
|
279
|
+
return { workspaceName, path: targetPath };
|
|
280
|
+
}
|
|
281
|
+
/** jj working-copy description for a Rudder agent's workspace. */
|
|
282
|
+
function workspaceDescription(params, status) {
|
|
283
|
+
const label = params.nodeId ? `rudder[${params.nodeId}]` : "rudder";
|
|
284
|
+
const task = (params.task ?? "").trim().replace(/\s+/g, " ");
|
|
285
|
+
const summary = task.length > 72 ? `${task.slice(0, 71)}…` : task;
|
|
286
|
+
return summary ? `${label}: ${summary} [${status}]` : `${label} [${status}]`;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Phase-1 entry point used by run-manager. Delegates to createNodeWorkspace.
|
|
290
|
+
*/
|
|
291
|
+
export async function createRunJjWorkspace(params) {
|
|
292
|
+
return await createNodeWorkspace({
|
|
293
|
+
repoRoot: params.repoRoot,
|
|
294
|
+
runId: params.runId,
|
|
295
|
+
task: params.task,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
export function workspaceNameFor(id) {
|
|
299
|
+
return `rudder-${id.slice(0, 14)}-${shortHash(id).slice(0, 6)}`;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Merge a run's jj change into the current (main) workspace `@`. Captures the
|
|
303
|
+
* operation id before the merge and records it on merge.operationId. On
|
|
304
|
+
* conflict sets merge.status="conflict" + conflictedFiles + mergeChangeId.
|
|
305
|
+
*/
|
|
306
|
+
export async function mergeJjRunIntoCurrentWorkspace(run, allowDirty = false) {
|
|
307
|
+
// Integration is one-at-a-time ACROSS PROCESSES: a `rudder merge` CLI call
|
|
308
|
+
// and a daemon tick are separate processes sharing the same @, and a second
|
|
309
|
+
// merge starting mid-flight would stack a merge change on a moving target.
|
|
310
|
+
return withIntegrationLock(run.repoRoot, () => mergeJjRunIntoCurrentWorkspaceLocked(run, allowDirty));
|
|
311
|
+
}
|
|
312
|
+
async function mergeJjRunIntoCurrentWorkspaceLocked(run, allowDirty) {
|
|
313
|
+
if (!run.workspace.workspaceName) {
|
|
314
|
+
throw new Error("Run has no jj workspace name to merge.");
|
|
315
|
+
}
|
|
316
|
+
ensureJjRepo(run.repoRoot);
|
|
317
|
+
ensureJjRepo(run.workspace.path);
|
|
318
|
+
// A resolver edits the already-created merge change in place. Once its
|
|
319
|
+
// conflicts are gone, finalize that transaction instead of creating a second
|
|
320
|
+
// merge change on top of it.
|
|
321
|
+
if (run.merge?.status === "conflict" && run.merge.mergeChangeId) {
|
|
322
|
+
const remaining = await jjConflictedFiles(run.repoRoot);
|
|
323
|
+
if (remaining.length > 0) {
|
|
324
|
+
run.merge = { ...run.merge, status: "conflict", conflictedFiles: remaining };
|
|
325
|
+
await saveRunRecord(run);
|
|
326
|
+
return run;
|
|
327
|
+
}
|
|
328
|
+
return await finalizeJjIntegration(run, run.merge.mergeChangeId);
|
|
329
|
+
}
|
|
330
|
+
// Rudder writes its own files (.rudder/ is already filtered by jjStatus; RUDDER.md
|
|
331
|
+
// and DECISIONS.md are the live coordination surfaces) into the main workspace
|
|
332
|
+
// continuously, so they must not count as "dirty" and block a merge.
|
|
333
|
+
if (!allowDirty) {
|
|
334
|
+
const dirty = (await jjStatus(run.repoRoot)).filter((line) => !isMergeIgnorablePath(line));
|
|
335
|
+
if (dirty.length > 0) {
|
|
336
|
+
throw new Error("Target workspace is dirty. Squash/abandon changes or pass --allow-dirty.");
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// --allow-dirty (which the TUI always passes) skips the dirty gate above, but a
|
|
340
|
+
// merge parented on a still-conflicted @ (e.g. the user declined the resolver
|
|
341
|
+
// after a previous conflicted merge) nests conflicts, so this guard is
|
|
342
|
+
// unconditional.
|
|
343
|
+
// PRE-FLIGHT. A conflicted integration workspace refuses EVERY later merge, so
|
|
344
|
+
// one unresolved DECISIONS.md used to strand an entire plan: finished nodes sat
|
|
345
|
+
// in review and everything hard-depending on them stayed unlaunchable. Settle
|
|
346
|
+
// what needs no judgment first; only a real product conflict should ever stop
|
|
347
|
+
// integration.
|
|
348
|
+
const preexistingConflicts = await autoResolveMechanicalConflicts(run.repoRoot);
|
|
349
|
+
if (preexistingConflicts.length > 0) {
|
|
350
|
+
const error = `integration workspace already has unresolved conflicts (${preexistingConflicts.join(", ")}); resolve them before merging another run`;
|
|
351
|
+
await markMergeFailed(run, error);
|
|
352
|
+
throw new Error(error);
|
|
353
|
+
}
|
|
354
|
+
const opIdBefore = await currentOpId(run.repoRoot);
|
|
355
|
+
run.merge = {
|
|
356
|
+
status: "not-started",
|
|
357
|
+
attemptedAt: new Date().toISOString(),
|
|
358
|
+
targetBranch: (await currentJjChangeId(run.repoRoot)) || "@",
|
|
359
|
+
operationId: opIdBefore || undefined,
|
|
360
|
+
};
|
|
361
|
+
await saveRunRecord(run);
|
|
362
|
+
const sourceChangeId = await currentJjChangeId(run.workspace.path);
|
|
363
|
+
if (!sourceChangeId) {
|
|
364
|
+
await markMergeFailed(run, "Could not determine jj change id for run workspace.");
|
|
365
|
+
throw new Error("Could not determine jj change id for run workspace.");
|
|
366
|
+
}
|
|
367
|
+
const message = `rudder: ${run.task.slice(0, 72)}`;
|
|
368
|
+
const merge = await mergeNode({
|
|
369
|
+
repoRoot: run.repoRoot,
|
|
370
|
+
nodeChangeId: sourceChangeId,
|
|
371
|
+
intoChangeId: "@",
|
|
372
|
+
message,
|
|
373
|
+
});
|
|
374
|
+
if (!merge.mergeChangeId) {
|
|
375
|
+
const error = "jj merge failed.";
|
|
376
|
+
await markMergeFailed(run, error);
|
|
377
|
+
throw new Error(error);
|
|
378
|
+
}
|
|
379
|
+
if (merge.conflictedFiles.length === 0) {
|
|
380
|
+
// KEEP the pre-merge operationId captured above: it is the undo waypoint.
|
|
381
|
+
// Overwriting it with the POST-merge op id (as this used to) made
|
|
382
|
+
// `rudder undo <op>` restore to the state after the merge — a perfect
|
|
383
|
+
// no-op that reported success while changing nothing.
|
|
384
|
+
return await finalizeJjIntegration(run, merge.mergeChangeId);
|
|
385
|
+
}
|
|
386
|
+
run.status = "merge-conflict";
|
|
387
|
+
run.hadMergeConflict = true;
|
|
388
|
+
run.merge = {
|
|
389
|
+
...run.merge,
|
|
390
|
+
status: "conflict",
|
|
391
|
+
conflictedFiles: merge.conflictedFiles,
|
|
392
|
+
mergeChangeId: merge.mergeChangeId,
|
|
393
|
+
// Pre-merge op stays the undo waypoint (see the clean-merge path above).
|
|
394
|
+
error: "jj merge created conflicts.",
|
|
395
|
+
};
|
|
396
|
+
await saveRunRecord(run);
|
|
397
|
+
return run;
|
|
398
|
+
}
|
|
399
|
+
async function finalizeJjIntegration(run, mergeChangeId) {
|
|
400
|
+
const targetBookmark = await integrationBookmark(run.repoRoot, run.targetBranch);
|
|
401
|
+
await createBookmark(run.repoRoot, targetBookmark, mergeChangeId);
|
|
402
|
+
await exportToGit(run.repoRoot);
|
|
403
|
+
const localCommit = await gitRevision(run.repoRoot, targetBookmark);
|
|
404
|
+
const pushed = localCommit
|
|
405
|
+
? await remoteContainsCommit(run.repoRoot, targetBookmark, localCommit)
|
|
406
|
+
: false;
|
|
407
|
+
run.status = "merged";
|
|
408
|
+
run.merge = {
|
|
409
|
+
...run.merge,
|
|
410
|
+
status: "merged",
|
|
411
|
+
targetBranch: targetBookmark,
|
|
412
|
+
mergeChangeId,
|
|
413
|
+
localCommit: localCommit || undefined,
|
|
414
|
+
pushed,
|
|
415
|
+
pushedAt: pushed ? new Date().toISOString() : undefined,
|
|
416
|
+
conflictedFiles: undefined,
|
|
417
|
+
conflictKind: undefined,
|
|
418
|
+
error: undefined,
|
|
419
|
+
};
|
|
420
|
+
await saveRunRecord(run);
|
|
421
|
+
return run;
|
|
422
|
+
}
|
|
423
|
+
export async function closestLocalBookmark(repoRoot) {
|
|
424
|
+
const result = await runCommand("jj", ["log", "--no-graph", "-r", "heads(::@ & bookmarks())", "-T", "bookmarks ++ \"\\n\""], { cwd: repoRoot, allowFailure: true });
|
|
425
|
+
if (result.code !== 0)
|
|
426
|
+
return "";
|
|
427
|
+
const names = result.stdout
|
|
428
|
+
.split(/\s+/)
|
|
429
|
+
.map((name) => name.trim().replace(/\*$/, ""))
|
|
430
|
+
.filter((name) => name && !name.includes("@"));
|
|
431
|
+
return names.find((name) => name === "main")
|
|
432
|
+
?? names.find((name) => name === "master")
|
|
433
|
+
?? names[0]
|
|
434
|
+
?? "";
|
|
435
|
+
}
|
|
436
|
+
async function integrationBookmark(repoRoot, recordedTarget) {
|
|
437
|
+
const candidate = recordedTarget.trim();
|
|
438
|
+
if (candidate && candidate !== "HEAD" && !/^[a-z]{12,}$/i.test(candidate) && !/^[0-9a-f]{12,}$/i.test(candidate)) {
|
|
439
|
+
return candidate;
|
|
440
|
+
}
|
|
441
|
+
return (await closestLocalBookmark(repoRoot)) || "main";
|
|
442
|
+
}
|
|
443
|
+
async function gitRevision(repoRoot, ref) {
|
|
444
|
+
const result = await runCommand("git", ["rev-parse", ref], { cwd: repoRoot, allowFailure: true });
|
|
445
|
+
return result.code === 0 ? result.stdout.trim() : "";
|
|
446
|
+
}
|
|
447
|
+
async function remoteContainsCommit(repoRoot, bookmark, commit) {
|
|
448
|
+
const remote = `origin/${bookmark}`;
|
|
449
|
+
const exists = await runCommand("git", ["rev-parse", "--verify", remote], {
|
|
450
|
+
cwd: repoRoot,
|
|
451
|
+
allowFailure: true,
|
|
452
|
+
});
|
|
453
|
+
if (exists.code !== 0)
|
|
454
|
+
return false;
|
|
455
|
+
const contains = await runCommand("git", ["merge-base", "--is-ancestor", commit, remote], {
|
|
456
|
+
cwd: repoRoot,
|
|
457
|
+
allowFailure: true,
|
|
458
|
+
});
|
|
459
|
+
return contains.code === 0;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Merge two changes into a new change. Captures the op id (after the merge),
|
|
463
|
+
* returns the new merge change id and any conflicted files. jj merges never
|
|
464
|
+
* fail on conflict; the conflict is recorded in the merge change.
|
|
465
|
+
*/
|
|
466
|
+
export async function mergeNode(params) {
|
|
467
|
+
ensureJjRepo(params.repoRoot);
|
|
468
|
+
const merge = await runCommand("jj", ["new", params.intoChangeId, params.nodeChangeId, "-m", params.message], { cwd: params.repoRoot, allowFailure: true });
|
|
469
|
+
if (merge.code !== 0) {
|
|
470
|
+
throw new Error(merge.stderr.trim() || merge.stdout.trim() || "jj merge failed.");
|
|
471
|
+
}
|
|
472
|
+
const mergeChangeId = await currentJjChangeId(params.repoRoot);
|
|
473
|
+
const opId = await currentOpId(params.repoRoot);
|
|
474
|
+
const conflictedFiles = await jjConflictedFiles(params.repoRoot);
|
|
475
|
+
return { mergeChangeId, opId, conflictedFiles };
|
|
476
|
+
}
|
|
477
|
+
export async function jjConflictedFiles(workspacePath) {
|
|
478
|
+
// Stale-recovering read: a stale failure here would falsely report "no
|
|
479
|
+
// conflicts" to the merge/sync paths.
|
|
480
|
+
const result = await jjReadInWorkspace(workspacePath, ["resolve", "--list"]);
|
|
481
|
+
return result.code === 0 ? parseJjConflictedFiles(result.stdout) : [];
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Alias matching the plan's API name.
|
|
485
|
+
*/
|
|
486
|
+
export async function listConflicts(workspacePath) {
|
|
487
|
+
return await jjConflictedFiles(workspacePath);
|
|
488
|
+
}
|
|
489
|
+
export function parseJjConflictedFiles(stdout) {
|
|
490
|
+
return stdout
|
|
491
|
+
.split(/\r?\n/)
|
|
492
|
+
.map((line) => line.trim())
|
|
493
|
+
.filter(Boolean)
|
|
494
|
+
.map((line) => line.replace(/^[-*]\s+/, "").replace(/^conflict in\s+/i, "").trim())
|
|
495
|
+
// `jj resolve --list` prints "path/to/file 2-sided conflict" (and
|
|
496
|
+
// "... including 1 deletion"). Everything downstream treats these as PATHS:
|
|
497
|
+
// they are joined into merge errors, stored on run.merge.conflictedFiles, and
|
|
498
|
+
// fed to the mechanical resolver, which keys on the file NAME. Leaving the
|
|
499
|
+
// descriptor attached makes every one of those miss. Split on the run of
|
|
500
|
+
// whitespace jj uses as the separator so paths containing single spaces
|
|
501
|
+
// survive intact.
|
|
502
|
+
.map((line) => line.replace(/\s{2,}\d+-sided conflict.*$/, "").trim())
|
|
503
|
+
.filter((line) => line && !isRudderMetadataPath(line));
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* The id of the latest operation in the op log. Recovers once from a stale
|
|
507
|
+
* workspace; if the id still cannot be read, warns and returns "" instead of
|
|
508
|
+
* throwing — merges must proceed, they just lose their undo waypoint.
|
|
509
|
+
*/
|
|
510
|
+
export async function currentOpId(repoRoot) {
|
|
511
|
+
const result = await jjReadInWorkspace(repoRoot, ["op", "log", "--no-graph", "-n", "1", "-T", "id.short()"]);
|
|
512
|
+
const opId = result.code === 0 ? result.stdout.trim().split(/\s+/)[0] ?? "" : "";
|
|
513
|
+
if (!opId) {
|
|
514
|
+
console.warn("rudder: could not capture jj op id; this merge will not be undoable via rudder undo");
|
|
515
|
+
}
|
|
516
|
+
return opId;
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Restore the repo to an earlier operation. One call rewinds all workspaces and
|
|
520
|
+
* refs atomically (op restore is global).
|
|
521
|
+
*/
|
|
522
|
+
export async function undoToOp(repoRoot, opId) {
|
|
523
|
+
await runCommand("jj", ["op", "restore", opId], { cwd: repoRoot });
|
|
524
|
+
}
|
|
525
|
+
export async function undoLast(repoRoot) {
|
|
526
|
+
await runCommand("jj", ["undo"], { cwd: repoRoot });
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Export jj changes to the colocated git repo. Call after each merge, before
|
|
530
|
+
* opening a diff review, and before a PR.
|
|
531
|
+
*/
|
|
532
|
+
export async function exportToGit(repoRoot) {
|
|
533
|
+
await runCommand("jj", ["git", "export"], { cwd: repoRoot });
|
|
534
|
+
}
|
|
535
|
+
export async function createBookmark(repoRoot, name, atChangeId) {
|
|
536
|
+
const create = await runCommand("jj", ["bookmark", "create", name, "-r", atChangeId], {
|
|
537
|
+
cwd: repoRoot,
|
|
538
|
+
allowFailure: true,
|
|
539
|
+
});
|
|
540
|
+
if (create.code !== 0) {
|
|
541
|
+
// Bookmark may already exist; move it to the target change.
|
|
542
|
+
await runCommand("jj", ["bookmark", "set", name, "-r", atChangeId, "--allow-backwards"], {
|
|
543
|
+
cwd: repoRoot,
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
export async function pushBookmark(repoRoot, name) {
|
|
548
|
+
const push = await runCommand("jj", ["git", "push", "--bookmark", name, "--allow-new"], {
|
|
549
|
+
cwd: repoRoot,
|
|
550
|
+
allowFailure: true,
|
|
551
|
+
});
|
|
552
|
+
if (push.code !== 0) {
|
|
553
|
+
// jj 0.40 drops `--allow-new`; a plain `--bookmark` push handles new bookmarks.
|
|
554
|
+
await runCommand("jj", ["git", "push", "--bookmark", name], { cwd: repoRoot });
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Forget a jj workspace and remove its directory.
|
|
559
|
+
*/
|
|
560
|
+
export async function forgetWorkspace(params) {
|
|
561
|
+
if (commandExists("jj")) {
|
|
562
|
+
await runCommand("jj", ["workspace", "forget", params.workspaceName], {
|
|
563
|
+
cwd: params.repoRoot,
|
|
564
|
+
allowFailure: true,
|
|
565
|
+
}).catch(() => undefined);
|
|
566
|
+
}
|
|
567
|
+
await fsp.rm(params.workspacePath, { recursive: true, force: true });
|
|
568
|
+
}
|
|
569
|
+
export async function removeRunWorkspace(run) {
|
|
570
|
+
if (!run.workspace.enabled) {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (!run.workspace.workspaceName) {
|
|
574
|
+
throw new Error("Run has no jj workspace name to remove.");
|
|
575
|
+
}
|
|
576
|
+
await forgetWorkspace({
|
|
577
|
+
repoRoot: run.repoRoot,
|
|
578
|
+
workspaceName: run.workspace.workspaceName,
|
|
579
|
+
workspacePath: run.workspace.path,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Rebase a run's node change onto the trunk. jj records conflicts in place and
|
|
584
|
+
* never blocks, so there is no rebase-in-progress dance.
|
|
585
|
+
*/
|
|
586
|
+
export async function syncRunWorkspace(run, baseBranch) {
|
|
587
|
+
if (!run.workspace.workspaceName) {
|
|
588
|
+
throw new Error("Run has no jj workspace to sync.");
|
|
589
|
+
}
|
|
590
|
+
ensureJjRepo(run.repoRoot);
|
|
591
|
+
const nodeChange = run.workspace.jjChangeId || (await currentJjChangeId(run.workspace.path));
|
|
592
|
+
if (!nodeChange) {
|
|
593
|
+
run.sync = {
|
|
594
|
+
...run.sync,
|
|
595
|
+
status: "failed",
|
|
596
|
+
attemptedAt: new Date().toISOString(),
|
|
597
|
+
baseBranch,
|
|
598
|
+
error: "Could not determine jj change id for run workspace.",
|
|
599
|
+
};
|
|
600
|
+
await saveRunRecord(run);
|
|
601
|
+
return run;
|
|
602
|
+
}
|
|
603
|
+
const trunk = baseBranch && baseBranch !== "HEAD" ? baseBranch : run.targetBranch || "@";
|
|
604
|
+
run.sync = {
|
|
605
|
+
status: "not-started",
|
|
606
|
+
attemptedAt: new Date().toISOString(),
|
|
607
|
+
baseBranch: trunk,
|
|
608
|
+
};
|
|
609
|
+
await saveRunRecord(run);
|
|
610
|
+
const rebase = await runCommand("jj", ["rebase", "-s", nodeChange, "-d", trunk], {
|
|
611
|
+
cwd: run.repoRoot,
|
|
612
|
+
allowFailure: true,
|
|
613
|
+
});
|
|
614
|
+
if (rebase.code !== 0) {
|
|
615
|
+
// jj 0.40 replaced `-d`/`--destination` with `-o`/`--onto`.
|
|
616
|
+
const onto = await runCommand("jj", ["rebase", "-s", nodeChange, "--onto", trunk], {
|
|
617
|
+
cwd: run.repoRoot,
|
|
618
|
+
allowFailure: true,
|
|
619
|
+
});
|
|
620
|
+
if (onto.code !== 0) {
|
|
621
|
+
run.sync = {
|
|
622
|
+
...run.sync,
|
|
623
|
+
status: "failed",
|
|
624
|
+
error: onto.stderr.trim() || onto.stdout.trim() || `Rebase onto ${trunk} failed.`,
|
|
625
|
+
};
|
|
626
|
+
await saveRunRecord(run);
|
|
627
|
+
return run;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
const conflicted = await jjConflictedFiles(run.workspace.path);
|
|
631
|
+
if (conflicted.length) {
|
|
632
|
+
run.hadMergeConflict = true;
|
|
633
|
+
}
|
|
634
|
+
run.sync = {
|
|
635
|
+
...run.sync,
|
|
636
|
+
status: conflicted.length ? "conflict" : "synced",
|
|
637
|
+
conflictedFiles: conflicted,
|
|
638
|
+
};
|
|
639
|
+
await saveRunRecord(run);
|
|
640
|
+
return run;
|
|
641
|
+
}
|
|
642
|
+
function ensureJjRepo(cwd) {
|
|
643
|
+
if (!commandExists("jj")) {
|
|
644
|
+
throw new MissingToolError("jj");
|
|
645
|
+
}
|
|
646
|
+
const result = runCommandSync("jj", ["root"], { cwd, allowFailure: true });
|
|
647
|
+
if (result.code !== 0 || !result.stdout.trim()) {
|
|
648
|
+
throw new Error(`Expected ${cwd} to be inside a jj repository.`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
function hasLocalJjMarker(cwd) {
|
|
652
|
+
const marker = path.join(path.resolve(cwd), ".jj");
|
|
653
|
+
try {
|
|
654
|
+
return fs.statSync(marker).isDirectory();
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
return false;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
function findJjMarker(cwd) {
|
|
661
|
+
let current = path.resolve(cwd);
|
|
662
|
+
while (true) {
|
|
663
|
+
const marker = path.join(current, ".jj");
|
|
664
|
+
if (pathExistsSync(marker)) {
|
|
665
|
+
try {
|
|
666
|
+
if (fs.statSync(marker).isDirectory()) {
|
|
667
|
+
return marker;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
catch {
|
|
671
|
+
// Keep walking if the marker cannot be inspected.
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
const parent = path.dirname(current);
|
|
675
|
+
if (parent === current) {
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
current = parent;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
function samePath(left, right) {
|
|
682
|
+
const normalize = (value) => {
|
|
683
|
+
const resolved = path.resolve(value);
|
|
684
|
+
try {
|
|
685
|
+
return fs.realpathSync(resolved);
|
|
686
|
+
}
|
|
687
|
+
catch {
|
|
688
|
+
return resolved;
|
|
689
|
+
}
|
|
690
|
+
};
|
|
691
|
+
return normalize(left) === normalize(right);
|
|
692
|
+
}
|
|
693
|
+
function isJjStatusMetadata(line) {
|
|
694
|
+
return (/^the working copy has no changes\.?$/i.test(line) ||
|
|
695
|
+
/^the working copy is clean\.?$/i.test(line) ||
|
|
696
|
+
/^no changes\.?$/i.test(line) ||
|
|
697
|
+
/^working copy\s*:/i.test(line) ||
|
|
698
|
+
/^parent commit\s*:/i.test(line) ||
|
|
699
|
+
/^current operation\s*:/i.test(line) ||
|
|
700
|
+
/^no conflicts found\.?$/i.test(line));
|
|
701
|
+
}
|
|
702
|
+
function statusPathFromSummaryLine(line) {
|
|
703
|
+
const match = line.match(/^[A-Z?]{1,2}\s+(.+)$/);
|
|
704
|
+
return match?.[1]?.trim() || null;
|
|
705
|
+
}
|
|
706
|
+
function isRudderMetadataPath(filePath) {
|
|
707
|
+
const normalized = filePath.replace(/\\/g, "/").replace(/^["']|["']$/g, "");
|
|
708
|
+
return normalized === ".rudder" || normalized.startsWith(".rudder/");
|
|
709
|
+
}
|
|
710
|
+
/// A jj-status line that names a Rudder-managed file (a status path or a `C path`
|
|
711
|
+
/// conflict line), which should never count toward the target-dirty merge gate.
|
|
712
|
+
function isMergeIgnorablePath(line) {
|
|
713
|
+
const path = line.replace(/^[ACMDR?]\s+/, "").replace(/^C\s+/, "").trim();
|
|
714
|
+
return (isRudderMetadataPath(path) ||
|
|
715
|
+
path === "RUDDER.md" ||
|
|
716
|
+
path === "DECISIONS.md" ||
|
|
717
|
+
path.endsWith("/RUDDER.md") ||
|
|
718
|
+
path.endsWith("/DECISIONS.md"));
|
|
719
|
+
}
|
|
720
|
+
async function markMergeFailed(run, error) {
|
|
721
|
+
run.merge = {
|
|
722
|
+
...run.merge,
|
|
723
|
+
status: "failed",
|
|
724
|
+
error,
|
|
725
|
+
};
|
|
726
|
+
await saveRunRecord(run);
|
|
727
|
+
}
|
|
728
|
+
function compareVersions(a, b) {
|
|
729
|
+
const pa = a.split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
730
|
+
const pb = b.split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
731
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
|
|
732
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
733
|
+
if (diff !== 0) {
|
|
734
|
+
return diff < 0 ? -1 : 1;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
return 0;
|
|
738
|
+
}
|
|
739
|
+
// ---------------------------------------------------------------------------
|
|
740
|
+
// Mechanical conflict resolution.
|
|
741
|
+
//
|
|
742
|
+
// A conflict in one of Rudder's own coordination files is never a real product
|
|
743
|
+
// disagreement, and neither is a lockfile: they are the dominant cause of the
|
|
744
|
+
// N-sided pileups that used to wedge integration. The native side pre-flights
|
|
745
|
+
// the same classes before every interactive merge; this is the same rule for
|
|
746
|
+
// the scheduler/daemon path, which had none and so could still freeze a whole
|
|
747
|
+
// plan behind one DECISIONS.md conflict.
|
|
748
|
+
// ---------------------------------------------------------------------------
|
|
749
|
+
const CONFLICT_MARKER = /^(<{7}|={7}|>{7}|%{7}|\+{7})/;
|
|
750
|
+
export function containsConflictMarkers(content) {
|
|
751
|
+
return content.split(/\r?\n/).some((line) => CONFLICT_MARKER.test(line));
|
|
752
|
+
}
|
|
753
|
+
/** The parent revisions of `@`; a merge has two or more. */
|
|
754
|
+
export async function jjParentRevs(repoRoot) {
|
|
755
|
+
const result = await jjReadInWorkspace(repoRoot, [
|
|
756
|
+
"log",
|
|
757
|
+
"--no-graph",
|
|
758
|
+
"--no-pager",
|
|
759
|
+
"-r",
|
|
760
|
+
"@-",
|
|
761
|
+
"-T",
|
|
762
|
+
'change_id.short() ++ "\\n"',
|
|
763
|
+
]);
|
|
764
|
+
if (result.code !== 0) {
|
|
765
|
+
return [];
|
|
766
|
+
}
|
|
767
|
+
return result.stdout
|
|
768
|
+
.split(/\r?\n/)
|
|
769
|
+
.map((line) => line.trim())
|
|
770
|
+
.filter(Boolean);
|
|
771
|
+
}
|
|
772
|
+
async function jjFileShow(repoRoot, rev, filePath) {
|
|
773
|
+
const result = await runCommand("jj", ["file", "show", "-r", rev, filePath], {
|
|
774
|
+
cwd: repoRoot,
|
|
775
|
+
allowFailure: true,
|
|
776
|
+
});
|
|
777
|
+
return result.code === 0 ? result.stdout : "";
|
|
778
|
+
}
|
|
779
|
+
/** Union of `- ` bullet entries across every side, order-preserving and deduped. */
|
|
780
|
+
export function mergeDecisionsSides(sides) {
|
|
781
|
+
const seen = new Set();
|
|
782
|
+
const entries = [];
|
|
783
|
+
for (const side of sides) {
|
|
784
|
+
for (const entry of side.split(/\n(?=- )/)) {
|
|
785
|
+
const normalized = entry.trim();
|
|
786
|
+
if (!normalized.startsWith("- ") || containsConflictMarkers(normalized)) {
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
if (!seen.has(normalized)) {
|
|
790
|
+
seen.add(normalized);
|
|
791
|
+
entries.push(normalized);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
if (!entries.length) {
|
|
796
|
+
return longestCleanSide(sides);
|
|
797
|
+
}
|
|
798
|
+
return `${DECISIONS_HEADER}\n\n${entries.join("\n\n")}\n`;
|
|
799
|
+
}
|
|
800
|
+
function longestCleanSide(sides) {
|
|
801
|
+
return sides
|
|
802
|
+
.filter((side) => !containsConflictMarkers(side))
|
|
803
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
804
|
+
}
|
|
805
|
+
/** Deep-union of dependency/script maps; scalar ties keep the earlier side. */
|
|
806
|
+
export function mergePackageJsonSides(sides) {
|
|
807
|
+
const parsed = [];
|
|
808
|
+
for (const side of sides) {
|
|
809
|
+
if (!side.trim()) {
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
try {
|
|
813
|
+
const value = JSON.parse(side);
|
|
814
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
815
|
+
return undefined;
|
|
816
|
+
}
|
|
817
|
+
parsed.push(value);
|
|
818
|
+
}
|
|
819
|
+
catch {
|
|
820
|
+
// A side that is not valid JSON means this is not a mechanical conflict.
|
|
821
|
+
return undefined;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
if (!parsed.length) {
|
|
825
|
+
return undefined;
|
|
826
|
+
}
|
|
827
|
+
const merged = parsed.reduce((acc, value) => deepMergePreferringFirst(acc, value));
|
|
828
|
+
return `${JSON.stringify(merged, null, 2)}\n`;
|
|
829
|
+
}
|
|
830
|
+
function deepMergePreferringFirst(left, right) {
|
|
831
|
+
const out = { ...left };
|
|
832
|
+
for (const [key, value] of Object.entries(right)) {
|
|
833
|
+
const existing = out[key];
|
|
834
|
+
if (existing === undefined) {
|
|
835
|
+
out[key] = value;
|
|
836
|
+
}
|
|
837
|
+
else if (isPlainObject(existing) && isPlainObject(value)) {
|
|
838
|
+
out[key] = deepMergePreferringFirst(existing, value);
|
|
839
|
+
}
|
|
840
|
+
// Scalar/array collision: keep the earlier side, matching the native rule.
|
|
841
|
+
}
|
|
842
|
+
return out;
|
|
843
|
+
}
|
|
844
|
+
function isPlainObject(value) {
|
|
845
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
846
|
+
}
|
|
847
|
+
/** Union of non-empty lines, order-preserving. */
|
|
848
|
+
function unionLines(sides) {
|
|
849
|
+
const seen = new Set();
|
|
850
|
+
const lines = [];
|
|
851
|
+
for (const side of sides) {
|
|
852
|
+
for (const line of side.split(/\r?\n/)) {
|
|
853
|
+
if (containsConflictMarkers(line)) {
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
const key = line.trim();
|
|
857
|
+
if (key && seen.has(key)) {
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
if (key) {
|
|
861
|
+
seen.add(key);
|
|
862
|
+
}
|
|
863
|
+
lines.push(line);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
return `${lines.join("\n").replace(/\n+$/, "")}\n`;
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* The mechanical resolution for one conflicted path, or undefined to leave it to
|
|
870
|
+
* the LLM resolver. Keyed on file NAME so it works at any depth.
|
|
871
|
+
*/
|
|
872
|
+
export function mechanicalMergeFor(fileName, sides) {
|
|
873
|
+
switch (fileName) {
|
|
874
|
+
case "DECISIONS.md":
|
|
875
|
+
return mergeDecisionsSides(sides);
|
|
876
|
+
case "RUDDER.md":
|
|
877
|
+
case "RUDDER_SHARED.md":
|
|
878
|
+
// Regenerated by the orchestrator right after the merge.
|
|
879
|
+
return longestCleanSide(sides);
|
|
880
|
+
case ".gitignore":
|
|
881
|
+
return unionLines(sides);
|
|
882
|
+
case "package.json":
|
|
883
|
+
return mergePackageJsonSides(sides);
|
|
884
|
+
case "package-lock.json":
|
|
885
|
+
case "pnpm-lock.yaml":
|
|
886
|
+
case "yarn.lock":
|
|
887
|
+
// Regenerable: take the most complete side, install rebuilds it.
|
|
888
|
+
return longestCleanSide(sides);
|
|
889
|
+
default:
|
|
890
|
+
return undefined;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Resolve every conflict Rudder can settle without judgment, and return what is
|
|
895
|
+
* left. Never writes content that still carries conflict markers.
|
|
896
|
+
*/
|
|
897
|
+
export async function autoResolveMechanicalConflicts(repoRoot) {
|
|
898
|
+
const conflicted = await jjConflictedFiles(repoRoot);
|
|
899
|
+
if (!conflicted.length) {
|
|
900
|
+
return [];
|
|
901
|
+
}
|
|
902
|
+
const parents = await jjParentRevs(repoRoot);
|
|
903
|
+
if (parents.length < 2) {
|
|
904
|
+
return conflicted;
|
|
905
|
+
}
|
|
906
|
+
let resolvedAny = false;
|
|
907
|
+
for (const filePath of conflicted) {
|
|
908
|
+
const fileName = path.basename(filePath);
|
|
909
|
+
const sides = await Promise.all(parents.map((rev) => jjFileShow(repoRoot, rev, filePath)));
|
|
910
|
+
const merged = mechanicalMergeFor(fileName, sides);
|
|
911
|
+
// Never snapshot a "resolution" that still carries markers: it would commit
|
|
912
|
+
// literal <<<<<<< lines into history for every agent to read.
|
|
913
|
+
if (merged === undefined || containsConflictMarkers(merged)) {
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
await fsp.writeFile(path.join(repoRoot, filePath), merged, "utf8").then(() => {
|
|
917
|
+
resolvedAny = true;
|
|
918
|
+
}, () => undefined);
|
|
919
|
+
}
|
|
920
|
+
if (!resolvedAny) {
|
|
921
|
+
return conflicted;
|
|
922
|
+
}
|
|
923
|
+
// jj is the source of truth for what is still conflicted after the writes.
|
|
924
|
+
return await jjConflictedFiles(repoRoot);
|
|
925
|
+
}
|
|
926
|
+
//# sourceMappingURL=jj.js.map
|