sidecarsync 2.0.1 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/dist/cli.js +1863 -533
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -196,6 +196,9 @@ function currentUser() {
|
|
|
196
196
|
function currentHost() {
|
|
197
197
|
return os.hostname().split(".", 1)[0] || "unknown";
|
|
198
198
|
}
|
|
199
|
+
function currentMachine() {
|
|
200
|
+
return `${currentUser()}@${currentHost()}`;
|
|
201
|
+
}
|
|
199
202
|
function parseDuration(value) {
|
|
200
203
|
if (typeof value === "number")
|
|
201
204
|
return Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
@@ -213,12 +216,15 @@ function utcTimestamp() {
|
|
|
213
216
|
function nowIso() {
|
|
214
217
|
return new Date().toISOString();
|
|
215
218
|
}
|
|
216
|
-
var SidecarError;
|
|
219
|
+
var DIRTY_CHECKOUT_EXIT = 75, SidecarError;
|
|
217
220
|
var init_util = __esm(() => {
|
|
218
221
|
SidecarError = class SidecarError extends Error {
|
|
219
|
-
|
|
222
|
+
reason;
|
|
223
|
+
stage;
|
|
224
|
+
constructor(message, options = {}) {
|
|
220
225
|
super(message);
|
|
221
226
|
this.name = "SidecarError";
|
|
227
|
+
this.reason = options.reason;
|
|
222
228
|
}
|
|
223
229
|
};
|
|
224
230
|
});
|
|
@@ -229,6 +235,10 @@ import os2 from "node:os";
|
|
|
229
235
|
import path2 from "node:path";
|
|
230
236
|
import { spawnSync } from "node:child_process";
|
|
231
237
|
import { fileURLToPath } from "node:url";
|
|
238
|
+
function sidecarCliPath() {
|
|
239
|
+
const self = fileURLToPath(import.meta.url);
|
|
240
|
+
return self.endsWith(".ts") ? path2.join(path2.dirname(self), "..", "dist", "cli.js") : self;
|
|
241
|
+
}
|
|
232
242
|
function packageVersion() {
|
|
233
243
|
let current = path2.dirname(fileURLToPath(import.meta.url));
|
|
234
244
|
while (true) {
|
|
@@ -324,147 +334,6 @@ var init_install = __esm(() => {
|
|
|
324
334
|
INSTALL_SOURCES = new Set(["npm", "bun", "curl"]);
|
|
325
335
|
});
|
|
326
336
|
|
|
327
|
-
// src/git.ts
|
|
328
|
-
import fs3 from "node:fs";
|
|
329
|
-
import path3 from "node:path";
|
|
330
|
-
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
331
|
-
function git(repo, args, options = {}) {
|
|
332
|
-
return gitRaw(["-C", repo, ...args], options);
|
|
333
|
-
}
|
|
334
|
-
function gitBytes(repo, args, options = {}) {
|
|
335
|
-
const check = options.check ?? true;
|
|
336
|
-
const result = spawnSync2("git", ["-C", repo, ...args], {
|
|
337
|
-
encoding: "buffer",
|
|
338
|
-
maxBuffer: 100 * 1024 * 1024
|
|
339
|
-
});
|
|
340
|
-
const status = result.status ?? 1;
|
|
341
|
-
const stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? "");
|
|
342
|
-
const stderr = Buffer.isBuffer(result.stderr) ? result.stderr : Buffer.from(result.stderr ?? "");
|
|
343
|
-
if (check && status !== 0) {
|
|
344
|
-
throw new SidecarError(stderr.toString("utf8").trim() || stdout.toString("utf8").trim());
|
|
345
|
-
}
|
|
346
|
-
return { status, stdout, stderr };
|
|
347
|
-
}
|
|
348
|
-
function gitRaw(args, options = {}) {
|
|
349
|
-
const check = options.check ?? true;
|
|
350
|
-
const result = spawnSync2("git", args, {
|
|
351
|
-
encoding: "utf8",
|
|
352
|
-
input: options.input,
|
|
353
|
-
maxBuffer: 100 * 1024 * 1024
|
|
354
|
-
});
|
|
355
|
-
const status = result.status ?? 1;
|
|
356
|
-
const stdout = result.stdout ?? "";
|
|
357
|
-
const stderr = result.stderr ?? "";
|
|
358
|
-
if (check && status !== 0) {
|
|
359
|
-
throw new SidecarError(stderr.trim() || stdout.trim());
|
|
360
|
-
}
|
|
361
|
-
return { status, stdout, stderr };
|
|
362
|
-
}
|
|
363
|
-
function fetch(repo, quiet, check = true) {
|
|
364
|
-
const args = ["fetch", "--prune", "origin", "+refs/heads/*:refs/remotes/origin/*"];
|
|
365
|
-
if (quiet)
|
|
366
|
-
args.splice(1, 0, "--quiet");
|
|
367
|
-
git(repo, args, { check });
|
|
368
|
-
}
|
|
369
|
-
function hasAnyCommit(repo) {
|
|
370
|
-
return git(repo, ["rev-parse", "--verify", "HEAD"], { check: false }).status === 0;
|
|
371
|
-
}
|
|
372
|
-
function branchExists(repo, branch) {
|
|
373
|
-
return git(repo, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { check: false }).status === 0;
|
|
374
|
-
}
|
|
375
|
-
function remoteRefExists(repo, branch) {
|
|
376
|
-
return git(repo, ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branch}`], {
|
|
377
|
-
check: false
|
|
378
|
-
}).status === 0;
|
|
379
|
-
}
|
|
380
|
-
function isAncestor(repo, maybeAncestor, descendant) {
|
|
381
|
-
return git(repo, ["merge-base", "--is-ancestor", maybeAncestor, descendant], { check: false }).status === 0;
|
|
382
|
-
}
|
|
383
|
-
function gitToplevel(cwd) {
|
|
384
|
-
const root = gitToplevelOptional(cwd);
|
|
385
|
-
if (!root)
|
|
386
|
-
throw new SidecarError("not inside a Git repository");
|
|
387
|
-
return root;
|
|
388
|
-
}
|
|
389
|
-
function gitToplevelOptional(cwd) {
|
|
390
|
-
const result = gitRaw(["-C", cwd, "rev-parse", "--show-toplevel"], { check: false });
|
|
391
|
-
if (result.status !== 0)
|
|
392
|
-
return;
|
|
393
|
-
return result.stdout.trim();
|
|
394
|
-
}
|
|
395
|
-
function gitCommonDir(root) {
|
|
396
|
-
const commonDir = gitCommonDirOptional(root);
|
|
397
|
-
if (!commonDir)
|
|
398
|
-
throw new SidecarError("not inside a Git repository");
|
|
399
|
-
return commonDir;
|
|
400
|
-
}
|
|
401
|
-
function gitExcludePath(root) {
|
|
402
|
-
const commonDir = gitCommonDirOptional(root);
|
|
403
|
-
return commonDir ? path3.join(commonDir, "info", "exclude") : undefined;
|
|
404
|
-
}
|
|
405
|
-
function isGitIgnored(root, relativePath) {
|
|
406
|
-
return git(root, ["check-ignore", "-q", "--", relativePath], { check: false }).status === 0;
|
|
407
|
-
}
|
|
408
|
-
function isGitTracked(root, relativePath) {
|
|
409
|
-
return git(root, ["ls-files", "--error-unmatch", "--", relativePath], { check: false }).status === 0;
|
|
410
|
-
}
|
|
411
|
-
function gitCommonDirOptional(root) {
|
|
412
|
-
const result = gitRaw(["-C", root, "rev-parse", "--git-common-dir"], { check: false });
|
|
413
|
-
if (result.status !== 0)
|
|
414
|
-
return;
|
|
415
|
-
return path3.resolve(root, result.stdout.trim());
|
|
416
|
-
}
|
|
417
|
-
function gitDir(repo) {
|
|
418
|
-
const result = git(repo, ["rev-parse", "--git-dir"]).stdout.trim();
|
|
419
|
-
return path3.isAbsolute(result) ? result : path3.resolve(repo, result);
|
|
420
|
-
}
|
|
421
|
-
function hasGitMetadata(repo) {
|
|
422
|
-
return fs3.existsSync(path3.join(repo, ".git"));
|
|
423
|
-
}
|
|
424
|
-
function isDirty(repo) {
|
|
425
|
-
return Boolean(git(repo, ["status", "--porcelain"]).stdout.trim());
|
|
426
|
-
}
|
|
427
|
-
function ensureClean(repo) {
|
|
428
|
-
if (isDirty(repo))
|
|
429
|
-
throw new SidecarError("sidecar checkout has uncommitted changes");
|
|
430
|
-
}
|
|
431
|
-
function ensureCommitIdentity(repo) {
|
|
432
|
-
if (git(repo, ["config", "user.name"], { check: false }).status !== 0) {
|
|
433
|
-
git(repo, ["config", "user.name", currentUser()]);
|
|
434
|
-
}
|
|
435
|
-
if (git(repo, ["config", "user.email"], { check: false }).status !== 0) {
|
|
436
|
-
git(repo, ["config", "user.email", `${slug(currentUser())}@${slug(currentHost())}.local`]);
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
function familyPrimaryRoot(root) {
|
|
440
|
-
const primary = jjDefaultWorkspace(root) ?? gitMainWorktree(root);
|
|
441
|
-
if (!primary)
|
|
442
|
-
return;
|
|
443
|
-
return realpathOr(primary) === realpathOr(root) ? undefined : primary;
|
|
444
|
-
}
|
|
445
|
-
function jjDefaultWorkspace(root) {
|
|
446
|
-
const pointer = path3.join(root, ".jj", "repo");
|
|
447
|
-
try {
|
|
448
|
-
if (!fs3.statSync(pointer).isFile())
|
|
449
|
-
return;
|
|
450
|
-
const repo = path3.resolve(path3.dirname(pointer), fs3.readFileSync(pointer, "utf8").trim());
|
|
451
|
-
const workspace = path3.dirname(path3.dirname(repo));
|
|
452
|
-
return fs3.existsSync(path3.join(workspace, ".jj")) ? workspace : undefined;
|
|
453
|
-
} catch {
|
|
454
|
-
return;
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
function gitMainWorktree(root) {
|
|
458
|
-
const result = git(root, ["worktree", "list", "--porcelain"], { check: false });
|
|
459
|
-
if (result.status !== 0)
|
|
460
|
-
return;
|
|
461
|
-
const entry = result.stdout.split(/\r?\n/).find((line) => line.startsWith("worktree "));
|
|
462
|
-
return entry?.slice("worktree ".length).trim() || undefined;
|
|
463
|
-
}
|
|
464
|
-
var init_git = __esm(() => {
|
|
465
|
-
init_util();
|
|
466
|
-
});
|
|
467
|
-
|
|
468
337
|
// ../../node_modules/.bun/smol-toml@1.7.0/node_modules/smol-toml/dist/date.js
|
|
469
338
|
var DATE_TIME_RE, TomlDate;
|
|
470
339
|
var init_date = __esm(() => {
|
|
@@ -1428,6 +1297,157 @@ var init_dist = __esm(() => {
|
|
|
1428
1297
|
*/
|
|
1429
1298
|
});
|
|
1430
1299
|
|
|
1300
|
+
// src/git.ts
|
|
1301
|
+
import fs3 from "node:fs";
|
|
1302
|
+
import path3 from "node:path";
|
|
1303
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
1304
|
+
function git(repo, args, options = {}) {
|
|
1305
|
+
return gitRaw(["-C", repo, ...args], options);
|
|
1306
|
+
}
|
|
1307
|
+
function gitBytes(repo, args, options = {}) {
|
|
1308
|
+
const check = options.check ?? true;
|
|
1309
|
+
const result = spawnSync2("git", ["-C", repo, ...args], {
|
|
1310
|
+
encoding: "buffer",
|
|
1311
|
+
maxBuffer: 100 * 1024 * 1024
|
|
1312
|
+
});
|
|
1313
|
+
const status = result.status ?? 1;
|
|
1314
|
+
const stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? "");
|
|
1315
|
+
const stderr = Buffer.isBuffer(result.stderr) ? result.stderr : Buffer.from(result.stderr ?? "");
|
|
1316
|
+
if (check && status !== 0) {
|
|
1317
|
+
throw new SidecarError(stderr.toString("utf8").trim() || stdout.toString("utf8").trim());
|
|
1318
|
+
}
|
|
1319
|
+
return { status, stdout, stderr };
|
|
1320
|
+
}
|
|
1321
|
+
function gitRaw(args, options = {}) {
|
|
1322
|
+
const check = options.check ?? true;
|
|
1323
|
+
const result = spawnSync2("git", args, {
|
|
1324
|
+
encoding: "utf8",
|
|
1325
|
+
input: options.input,
|
|
1326
|
+
maxBuffer: 100 * 1024 * 1024
|
|
1327
|
+
});
|
|
1328
|
+
const status = result.status ?? 1;
|
|
1329
|
+
const stdout = result.stdout ?? "";
|
|
1330
|
+
const stderr = result.stderr ?? "";
|
|
1331
|
+
if (check && status !== 0) {
|
|
1332
|
+
throw new SidecarError(stderr.trim() || stdout.trim());
|
|
1333
|
+
}
|
|
1334
|
+
return { status, stdout, stderr };
|
|
1335
|
+
}
|
|
1336
|
+
function fetch(repo, quiet, check = true) {
|
|
1337
|
+
const args = ["fetch", "--prune", "origin", "+refs/heads/*:refs/remotes/origin/*"];
|
|
1338
|
+
if (quiet)
|
|
1339
|
+
args.splice(1, 0, "--quiet");
|
|
1340
|
+
git(repo, args, { check });
|
|
1341
|
+
}
|
|
1342
|
+
function hasAnyCommit(repo) {
|
|
1343
|
+
return git(repo, ["rev-parse", "--verify", "HEAD"], { check: false }).status === 0;
|
|
1344
|
+
}
|
|
1345
|
+
function branchExists(repo, branch) {
|
|
1346
|
+
return git(repo, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { check: false }).status === 0;
|
|
1347
|
+
}
|
|
1348
|
+
function remoteRefExists(repo, branch) {
|
|
1349
|
+
return git(repo, ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branch}`], {
|
|
1350
|
+
check: false
|
|
1351
|
+
}).status === 0;
|
|
1352
|
+
}
|
|
1353
|
+
function isAncestor(repo, maybeAncestor, descendant) {
|
|
1354
|
+
return git(repo, ["merge-base", "--is-ancestor", maybeAncestor, descendant], { check: false }).status === 0;
|
|
1355
|
+
}
|
|
1356
|
+
function treeOf(repo, ref) {
|
|
1357
|
+
return git(repo, ["rev-parse", `${ref}^{tree}`]).stdout.trim();
|
|
1358
|
+
}
|
|
1359
|
+
function emptyTree(repo) {
|
|
1360
|
+
return git(repo, ["mktree"], { input: "" }).stdout.trim();
|
|
1361
|
+
}
|
|
1362
|
+
function gitToplevel(cwd) {
|
|
1363
|
+
const root = gitToplevelOptional(cwd);
|
|
1364
|
+
if (!root)
|
|
1365
|
+
throw new SidecarError("not inside a Git repository");
|
|
1366
|
+
return root;
|
|
1367
|
+
}
|
|
1368
|
+
function gitToplevelOptional(cwd) {
|
|
1369
|
+
const result = gitRaw(["-C", cwd, "rev-parse", "--show-toplevel"], { check: false });
|
|
1370
|
+
if (result.status !== 0)
|
|
1371
|
+
return;
|
|
1372
|
+
return result.stdout.trim();
|
|
1373
|
+
}
|
|
1374
|
+
function gitCommonDir(root) {
|
|
1375
|
+
const commonDir = gitCommonDirOptional(root);
|
|
1376
|
+
if (!commonDir)
|
|
1377
|
+
throw new SidecarError("not inside a Git repository");
|
|
1378
|
+
return commonDir;
|
|
1379
|
+
}
|
|
1380
|
+
function gitExcludePath(root) {
|
|
1381
|
+
const commonDir = gitCommonDirOptional(root);
|
|
1382
|
+
return commonDir ? path3.join(commonDir, "info", "exclude") : undefined;
|
|
1383
|
+
}
|
|
1384
|
+
function isGitIgnored(root, relativePath) {
|
|
1385
|
+
return git(root, ["check-ignore", "-q", "--", relativePath], { check: false }).status === 0;
|
|
1386
|
+
}
|
|
1387
|
+
function isGitTracked(root, relativePath) {
|
|
1388
|
+
return git(root, ["ls-files", "--error-unmatch", "--", relativePath], { check: false }).status === 0;
|
|
1389
|
+
}
|
|
1390
|
+
function gitCommonDirOptional(root) {
|
|
1391
|
+
const result = gitRaw(["-C", root, "rev-parse", "--git-common-dir"], { check: false });
|
|
1392
|
+
if (result.status !== 0)
|
|
1393
|
+
return;
|
|
1394
|
+
return path3.resolve(root, result.stdout.trim());
|
|
1395
|
+
}
|
|
1396
|
+
function gitDir(repo) {
|
|
1397
|
+
const result = git(repo, ["rev-parse", "--git-dir"]).stdout.trim();
|
|
1398
|
+
return path3.isAbsolute(result) ? result : path3.resolve(repo, result);
|
|
1399
|
+
}
|
|
1400
|
+
function hasGitMetadata(repo) {
|
|
1401
|
+
return fs3.existsSync(path3.join(repo, ".git"));
|
|
1402
|
+
}
|
|
1403
|
+
function isDirty(repo) {
|
|
1404
|
+
return Boolean(git(repo, ["status", "--porcelain"]).stdout.trim());
|
|
1405
|
+
}
|
|
1406
|
+
function ensureClean(repo) {
|
|
1407
|
+
if (isDirty(repo))
|
|
1408
|
+
throw new SidecarError("sidecar checkout has uncommitted changes", { reason: "dirty-checkout" });
|
|
1409
|
+
}
|
|
1410
|
+
function sidecarIdentity() {
|
|
1411
|
+
return { name: currentUser(), email: `${slug(currentUser())}@${slug(currentHost())}.local` };
|
|
1412
|
+
}
|
|
1413
|
+
function ensureCommitIdentity(repo) {
|
|
1414
|
+
const { name, email } = sidecarIdentity();
|
|
1415
|
+
if (git(repo, ["config", "user.name"], { check: false }).status !== 0) {
|
|
1416
|
+
git(repo, ["config", "user.name", name]);
|
|
1417
|
+
}
|
|
1418
|
+
if (git(repo, ["config", "user.email"], { check: false }).status !== 0) {
|
|
1419
|
+
git(repo, ["config", "user.email", email]);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
function familyPrimaryRoot(root) {
|
|
1423
|
+
const primary = jjDefaultWorkspace(root) ?? gitMainWorktree(root);
|
|
1424
|
+
if (!primary)
|
|
1425
|
+
return;
|
|
1426
|
+
return realpathOr(primary) === realpathOr(root) ? undefined : primary;
|
|
1427
|
+
}
|
|
1428
|
+
function jjDefaultWorkspace(root) {
|
|
1429
|
+
const pointer = path3.join(root, ".jj", "repo");
|
|
1430
|
+
try {
|
|
1431
|
+
if (!fs3.statSync(pointer).isFile())
|
|
1432
|
+
return;
|
|
1433
|
+
const repo = path3.resolve(path3.dirname(pointer), fs3.readFileSync(pointer, "utf8").trim());
|
|
1434
|
+
const workspace = path3.dirname(path3.dirname(repo));
|
|
1435
|
+
return fs3.existsSync(path3.join(workspace, ".jj")) ? workspace : undefined;
|
|
1436
|
+
} catch {
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
function gitMainWorktree(root) {
|
|
1441
|
+
const result = git(root, ["worktree", "list", "--porcelain"], { check: false });
|
|
1442
|
+
if (result.status !== 0)
|
|
1443
|
+
return;
|
|
1444
|
+
const entry = result.stdout.split(/\r?\n/).find((line) => line.startsWith("worktree "));
|
|
1445
|
+
return entry?.slice("worktree ".length).trim() || undefined;
|
|
1446
|
+
}
|
|
1447
|
+
var init_git = __esm(() => {
|
|
1448
|
+
init_util();
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1431
1451
|
// src/redaction.ts
|
|
1432
1452
|
function redactText(input, mode = DEFAULT_REDACTION_MODE) {
|
|
1433
1453
|
if (mode === "none")
|
|
@@ -1582,7 +1602,8 @@ function parseHealthRecord(text) {
|
|
|
1582
1602
|
lastFailureAt: typeof record.lastFailureAt === "string" ? record.lastFailureAt : undefined,
|
|
1583
1603
|
consecutiveFailures: typeof record.consecutiveFailures === "number" && Number.isFinite(record.consecutiveFailures) ? record.consecutiveFailures : 0,
|
|
1584
1604
|
stage: typeof record.stage === "string" ? record.stage : undefined,
|
|
1585
|
-
message: typeof record.message === "string" ? record.message : undefined
|
|
1605
|
+
message: typeof record.message === "string" ? record.message : undefined,
|
|
1606
|
+
review: record.review === "pending" || record.review === "attention" ? record.review : undefined
|
|
1586
1607
|
};
|
|
1587
1608
|
}
|
|
1588
1609
|
function serializeHealthRecord(record) {
|
|
@@ -1595,24 +1616,27 @@ function nextHealthRecord(previous, identity, outcome, now) {
|
|
|
1595
1616
|
...identity,
|
|
1596
1617
|
status: outcome.status,
|
|
1597
1618
|
updatedAt: now,
|
|
1598
|
-
lastSuccessAt: previous?.lastSuccessAt,
|
|
1599
|
-
lastFailureAt: previous?.lastFailureAt,
|
|
1619
|
+
lastSuccessAt: isoTimestamp(previous?.lastSuccessAt),
|
|
1620
|
+
lastFailureAt: isoTimestamp(previous?.lastFailureAt),
|
|
1600
1621
|
consecutiveFailures: 0
|
|
1601
1622
|
};
|
|
1602
1623
|
if (outcome.status === "ok")
|
|
1603
|
-
return { ...base, lastSuccessAt: now };
|
|
1624
|
+
return { ...base, lastSuccessAt: now, ...outcome.review ? { review: outcome.review } : {} };
|
|
1604
1625
|
return {
|
|
1605
1626
|
...base,
|
|
1606
1627
|
lastFailureAt: now,
|
|
1607
|
-
consecutiveFailures: (previous?.consecutiveFailures ?? 0) + 1,
|
|
1628
|
+
consecutiveFailures: Math.max(0, Math.floor(previous?.consecutiveFailures ?? 0)) + 1,
|
|
1608
1629
|
stage: outcome.stage,
|
|
1609
1630
|
message: redactText(outcome.message).trim().slice(-MESSAGE_LIMIT)
|
|
1610
1631
|
};
|
|
1611
1632
|
}
|
|
1633
|
+
function isoTimestamp(value) {
|
|
1634
|
+
return value && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/.test(value) ? value : undefined;
|
|
1635
|
+
}
|
|
1612
1636
|
function shouldPublishHealth(previous, next, now = Date.now()) {
|
|
1613
1637
|
if (!previous)
|
|
1614
1638
|
return true;
|
|
1615
|
-
if (next.status === "failed" || previous.status !== next.status)
|
|
1639
|
+
if (next.status === "failed" || previous.status !== next.status || previous.review !== next.review)
|
|
1616
1640
|
return true;
|
|
1617
1641
|
const last = Date.parse(previous.updatedAt);
|
|
1618
1642
|
if (!Number.isFinite(last))
|
|
@@ -3538,6 +3562,22 @@ function ensureDistinctCheckouts(peers) {
|
|
|
3538
3562
|
function sameRemoteKey(remote) {
|
|
3539
3563
|
return remote.trim().replace(/\/+$/, "").replace(/\.git$/, "");
|
|
3540
3564
|
}
|
|
3565
|
+
function reviewRequired(config) {
|
|
3566
|
+
return config.review === "required";
|
|
3567
|
+
}
|
|
3568
|
+
function privateFailures() {
|
|
3569
|
+
return failuresArePrivate;
|
|
3570
|
+
}
|
|
3571
|
+
function keepFailuresPrivate() {
|
|
3572
|
+
failuresArePrivate = true;
|
|
3573
|
+
}
|
|
3574
|
+
function notePeerPolicy(config) {
|
|
3575
|
+
if (reviewRequired(config))
|
|
3576
|
+
keepFailuresPrivate();
|
|
3577
|
+
}
|
|
3578
|
+
function reviewTimeoutSeconds(config) {
|
|
3579
|
+
return config.reviewTimeout ?? DEFAULT_REVIEW_TIMEOUT_SECONDS;
|
|
3580
|
+
}
|
|
3541
3581
|
function findConfigRoot(start) {
|
|
3542
3582
|
const root = findConfigRootOptional(start);
|
|
3543
3583
|
if (root)
|
|
@@ -3556,7 +3596,11 @@ function findConfigRootOptional(start) {
|
|
|
3556
3596
|
}
|
|
3557
3597
|
}
|
|
3558
3598
|
function writeConfig(configPath, config) {
|
|
3559
|
-
|
|
3599
|
+
notePeerPolicy(config);
|
|
3600
|
+
fs5.writeFileSync(configPath, configText(config), "utf8");
|
|
3601
|
+
}
|
|
3602
|
+
function configText(config) {
|
|
3603
|
+
return [
|
|
3560
3604
|
`version = ${config.version}`,
|
|
3561
3605
|
`remote = ${JSON.stringify(config.remote)}`,
|
|
3562
3606
|
`path = ${JSON.stringify(config.path)}`,
|
|
@@ -3566,10 +3610,11 @@ function writeConfig(configPath, config) {
|
|
|
3566
3610
|
`resolve = ${JSON.stringify(config.resolve ?? DEFAULT_RESOLVE)}`,
|
|
3567
3611
|
...config.debounce === undefined ? [] : [`debounce = ${config.debounce}`],
|
|
3568
3612
|
...config.interval === undefined ? [] : [`interval = ${config.interval}`],
|
|
3613
|
+
...reviewRequired(config) ? [`review = "required"`] : [],
|
|
3614
|
+
...config.reviewTimeout === undefined ? [] : [`review-timeout = ${config.reviewTimeout}`],
|
|
3569
3615
|
""
|
|
3570
3616
|
].join(`
|
|
3571
3617
|
`);
|
|
3572
|
-
fs5.writeFileSync(configPath, text, "utf8");
|
|
3573
3618
|
}
|
|
3574
3619
|
function readConfig(configPath, options = {}) {
|
|
3575
3620
|
let values;
|
|
@@ -3580,10 +3625,22 @@ function readConfig(configPath, options = {}) {
|
|
|
3580
3625
|
}
|
|
3581
3626
|
values = parsed;
|
|
3582
3627
|
} catch (error) {
|
|
3628
|
+
if (error.code !== "ENOENT")
|
|
3629
|
+
keepFailuresPrivate();
|
|
3583
3630
|
if (error instanceof SidecarError)
|
|
3584
3631
|
throw error;
|
|
3585
3632
|
throw new SidecarError(`${configPath} is not valid TOML: ${error instanceof Error ? error.message : String(error)}`);
|
|
3586
3633
|
}
|
|
3634
|
+
if (values.review !== undefined && values.review !== "off")
|
|
3635
|
+
keepFailuresPrivate();
|
|
3636
|
+
const unknown = Object.keys(values).filter((key) => !CONFIG_KEYS.has(key));
|
|
3637
|
+
if (unknown.length) {
|
|
3638
|
+
throw new SidecarError(`${configPath}: unknown key${unknown.length > 1 ? "s" : ""} ${unknown.map((key) => JSON.stringify(key)).join(", ")}; this sidecar may be older than the config — update it with \`sidecar update\``);
|
|
3639
|
+
}
|
|
3640
|
+
const version = numberConfigValue(configPath, values, "version", CONFIG_VERSION);
|
|
3641
|
+
if (version !== CONFIG_VERSION) {
|
|
3642
|
+
throw new SidecarError(`${configPath}: config version ${version} is not supported by this sidecar (expected ${CONFIG_VERSION}); update it with \`sidecar update\``);
|
|
3643
|
+
}
|
|
3587
3644
|
const remote = optionalStringConfigValue(configPath, values, "remote");
|
|
3588
3645
|
if (!remote)
|
|
3589
3646
|
throw new SidecarError(`${configPath} is missing remote`);
|
|
@@ -3592,7 +3649,7 @@ function readConfig(configPath, options = {}) {
|
|
|
3592
3649
|
const config = {
|
|
3593
3650
|
peer,
|
|
3594
3651
|
remote,
|
|
3595
|
-
version
|
|
3652
|
+
version,
|
|
3596
3653
|
path: stringConfigValue(configPath, values, "path", DEFAULT_PATH),
|
|
3597
3654
|
branch: stringConfigValue(configPath, values, "branch", DEFAULT_BRANCH),
|
|
3598
3655
|
inbox: stringConfigValue(configPath, values, "inbox", DEFAULT_INBOX),
|
|
@@ -3600,6 +3657,8 @@ function readConfig(configPath, options = {}) {
|
|
|
3600
3657
|
resolve: resolveModeConfigValue(stringConfigValue(configPath, values, "resolve", DEFAULT_RESOLVE), configPath),
|
|
3601
3658
|
debounce: durationConfigValue(values.debounce, `${configPath} debounce`),
|
|
3602
3659
|
interval: durationConfigValue(values.interval, `${configPath} interval`),
|
|
3660
|
+
review: reviewModeConfigValue(stringConfigValue(configPath, values, "review", "off"), configPath),
|
|
3661
|
+
reviewTimeout: durationConfigValue(values["review-timeout"], `${configPath} review-timeout`),
|
|
3603
3662
|
rules: options.loadRules === false ? undefined : readRules(rulesPath),
|
|
3604
3663
|
rulesPath
|
|
3605
3664
|
};
|
|
@@ -3616,6 +3675,11 @@ function redactionModeConfigValue(value, source) {
|
|
|
3616
3675
|
return value;
|
|
3617
3676
|
throw new SidecarError(`${source}: invalid redaction mode ${JSON.stringify(value)}; expected one of ${REDACTION_MODES.join(", ")}`);
|
|
3618
3677
|
}
|
|
3678
|
+
function reviewModeConfigValue(value, source) {
|
|
3679
|
+
if (REVIEW_MODES.includes(value))
|
|
3680
|
+
return value;
|
|
3681
|
+
throw new SidecarError(`${source}: invalid review mode ${JSON.stringify(value)}; expected one of ${REVIEW_MODES.join(", ")}`);
|
|
3682
|
+
}
|
|
3619
3683
|
function durationConfigValue(value, source) {
|
|
3620
3684
|
if (value === undefined)
|
|
3621
3685
|
return;
|
|
@@ -3736,6 +3800,8 @@ function inboxPrefix(config) {
|
|
|
3736
3800
|
return inboxBranchPrefix(config.inbox);
|
|
3737
3801
|
}
|
|
3738
3802
|
function remoteBranchName(remoteBranch) {
|
|
3803
|
+
if (remoteBranch.startsWith(PUBLIC_REFS))
|
|
3804
|
+
return remoteBranch.slice(PUBLIC_REFS.length);
|
|
3739
3805
|
return remoteBranch.startsWith("origin/") ? remoteBranch.slice("origin/".length) : remoteBranch;
|
|
3740
3806
|
}
|
|
3741
3807
|
function matchesInboxPrefix(prefix, branch) {
|
|
@@ -3749,7 +3815,7 @@ function inboxBranchPrefix(template) {
|
|
|
3749
3815
|
const slashIndex = staticPrefix.lastIndexOf("/");
|
|
3750
3816
|
return slashIndex === -1 ? staticPrefix : staticPrefix.slice(0, slashIndex + 1);
|
|
3751
3817
|
}
|
|
3752
|
-
var DEFAULT_PATH = "sidecar", DEFAULT_BRANCH = "main", DEFAULT_INBOX = "sidecar-inbox/{user}/{random}", branchValidity, DEFAULT_PEER = "default", PEER_ENV = "SIDECAR_PEER", PEER_NAME, RESERVED_PEER_SUFFIXES, RESOLVE_MODES, DEFAULT_RESOLVE = "fork";
|
|
3818
|
+
var DEFAULT_PATH = "sidecar", DEFAULT_BRANCH = "main", DEFAULT_INBOX = "sidecar-inbox/{user}/{random}", branchValidity, DEFAULT_PEER = "default", PEER_ENV = "SIDECAR_PEER", SOFT_SYNC_ENV = "SIDECAR_SYNC_SOFT", LOCAL_SYNC_ENV = "SIDECAR_SYNC_LOCAL", DEFAULT_DEBOUNCE_SECONDS = 60, PEER_NAME, RESERVED_PEER_SUFFIXES, RESOLVE_MODES, DEFAULT_RESOLVE = "fork", REVIEW_MODES, DEFAULT_REVIEW_TIMEOUT_SECONDS, CONFIG_VERSION = 1, CONFIG_KEYS, failuresArePrivate = false, PUBLIC_REFS = "refs/sidecar-public/";
|
|
3753
3819
|
var init_config = __esm(() => {
|
|
3754
3820
|
init_dist();
|
|
3755
3821
|
init_util();
|
|
@@ -3761,6 +3827,21 @@ var init_config = __esm(() => {
|
|
|
3761
3827
|
PEER_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
3762
3828
|
RESERVED_PEER_SUFFIXES = new Set(["swp", "swo", "swx", "bak", "orig", "rej", "tmp", "old", "example", "sample", "lock"]);
|
|
3763
3829
|
RESOLVE_MODES = ["fork", "lww"];
|
|
3830
|
+
REVIEW_MODES = ["off", "required"];
|
|
3831
|
+
DEFAULT_REVIEW_TIMEOUT_SECONDS = 60 * 60;
|
|
3832
|
+
CONFIG_KEYS = new Set([
|
|
3833
|
+
"version",
|
|
3834
|
+
"remote",
|
|
3835
|
+
"path",
|
|
3836
|
+
"branch",
|
|
3837
|
+
"inbox",
|
|
3838
|
+
"redaction",
|
|
3839
|
+
"resolve",
|
|
3840
|
+
"debounce",
|
|
3841
|
+
"interval",
|
|
3842
|
+
"review",
|
|
3843
|
+
"review-timeout"
|
|
3844
|
+
]);
|
|
3764
3845
|
});
|
|
3765
3846
|
|
|
3766
3847
|
// src/state.ts
|
|
@@ -3803,7 +3884,8 @@ function readSettings() {
|
|
|
3803
3884
|
daemonEnabled: typeof record.daemonEnabled === "boolean" ? record.daemonEnabled : true,
|
|
3804
3885
|
autoUpdate: typeof record.autoUpdate === "boolean" ? record.autoUpdate : true,
|
|
3805
3886
|
lastUpdateCheckAt: typeof record.lastUpdateCheckAt === "string" ? record.lastUpdateCheckAt : undefined,
|
|
3806
|
-
installSource: INSTALL_SOURCES.has(record.installSource) ? record.installSource : undefined
|
|
3887
|
+
installSource: INSTALL_SOURCES.has(record.installSource) ? record.installSource : undefined,
|
|
3888
|
+
reviewer: typeof record.reviewer === "string" && record.reviewer.trim() ? record.reviewer : undefined
|
|
3807
3889
|
};
|
|
3808
3890
|
} catch (error) {
|
|
3809
3891
|
logSidecarEvent("failure", {
|
|
@@ -3823,6 +3905,8 @@ function writeSettings(settings) {
|
|
|
3823
3905
|
record.lastUpdateCheckAt = settings.lastUpdateCheckAt;
|
|
3824
3906
|
if (settings.installSource)
|
|
3825
3907
|
record.installSource = settings.installSource;
|
|
3908
|
+
if (settings.reviewer)
|
|
3909
|
+
record.reviewer = settings.reviewer;
|
|
3826
3910
|
fs6.writeFileSync(settingsPath(), `${JSON.stringify(record, null, 2)}
|
|
3827
3911
|
`, "utf8");
|
|
3828
3912
|
}
|
|
@@ -4073,8 +4157,21 @@ function acquireSyncLockOrThrow(root, peer) {
|
|
|
4073
4157
|
return release;
|
|
4074
4158
|
throw new SidecarError("another sidecar sync is already running; try again once it finishes");
|
|
4075
4159
|
}
|
|
4160
|
+
function acquireSyncLockWaiting(root, peer) {
|
|
4161
|
+
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
4162
|
+
const deadline = Date.now() + SYNC_LOCK_WAIT_MS;
|
|
4163
|
+
while (true) {
|
|
4164
|
+
const release = acquireSyncLock(root, peer);
|
|
4165
|
+
if (release)
|
|
4166
|
+
return release;
|
|
4167
|
+
if (Date.now() >= deadline) {
|
|
4168
|
+
throw new SidecarError("another sidecar sync is still running; try again once it finishes");
|
|
4169
|
+
}
|
|
4170
|
+
Atomics.wait(sleeper, 0, 0, 250);
|
|
4171
|
+
}
|
|
4172
|
+
}
|
|
4076
4173
|
function withSyncLock(root, peer, onBusy, fn) {
|
|
4077
|
-
const releaseLock = onBusy === "skip" ? acquireSyncLock(root, peer) : acquireSyncLockOrThrow(root, peer);
|
|
4174
|
+
const releaseLock = onBusy === "skip" ? acquireSyncLock(root, peer) : onBusy === "wait" ? acquireSyncLockWaiting(root, peer) : acquireSyncLockOrThrow(root, peer);
|
|
4078
4175
|
if (!releaseLock) {
|
|
4079
4176
|
console.log("another sidecar sync is already running; skipping this soft sync");
|
|
4080
4177
|
return false;
|
|
@@ -4106,7 +4203,38 @@ function syncLockIsStale(lockDir) {
|
|
|
4106
4203
|
return error.code !== "EPERM";
|
|
4107
4204
|
}
|
|
4108
4205
|
}
|
|
4109
|
-
|
|
4206
|
+
function reviewDueDir() {
|
|
4207
|
+
return path6.join(sidecarStateDir(), "review-due");
|
|
4208
|
+
}
|
|
4209
|
+
function reviewDuePath(configPath) {
|
|
4210
|
+
const key = `${realpathOr(path6.dirname(configPath))}\x00${path6.basename(configPath)}`;
|
|
4211
|
+
return path6.join(reviewDueDir(), `${crypto3.createHash("sha256").update(key).digest("hex").slice(0, 16)}.json`);
|
|
4212
|
+
}
|
|
4213
|
+
function writeReviewDue(configPath, dueAt) {
|
|
4214
|
+
const filePath = reviewDuePath(configPath);
|
|
4215
|
+
fs6.mkdirSync(path6.dirname(filePath), { recursive: true });
|
|
4216
|
+
const temporary = `${filePath}.${crypto3.randomUUID()}.tmp`;
|
|
4217
|
+
try {
|
|
4218
|
+
fs6.writeFileSync(temporary, `${JSON.stringify({ configPath, dueAt: new Date(dueAt).toISOString() })}
|
|
4219
|
+
`, "utf8");
|
|
4220
|
+
fs6.renameSync(temporary, filePath);
|
|
4221
|
+
} finally {
|
|
4222
|
+
fs6.rmSync(temporary, { force: true });
|
|
4223
|
+
}
|
|
4224
|
+
}
|
|
4225
|
+
function clearReviewDue(configPath) {
|
|
4226
|
+
fs6.rmSync(reviewDuePath(configPath), { force: true });
|
|
4227
|
+
}
|
|
4228
|
+
function readReviewDue(configPath) {
|
|
4229
|
+
try {
|
|
4230
|
+
const { dueAt } = JSON.parse(fs6.readFileSync(reviewDuePath(configPath), "utf8"));
|
|
4231
|
+
const time = typeof dueAt === "string" ? Date.parse(dueAt) : Number.NaN;
|
|
4232
|
+
return Number.isFinite(time) ? time : undefined;
|
|
4233
|
+
} catch {
|
|
4234
|
+
return;
|
|
4235
|
+
}
|
|
4236
|
+
}
|
|
4237
|
+
var STATE_DIR_ENV = "SIDECAR_STATE_DIR", DEFAULT_SETTINGS, LOG_ROTATE_BYTES, SYNC_LOCK_WAIT_MS;
|
|
4110
4238
|
var init_state = __esm(() => {
|
|
4111
4239
|
init_util();
|
|
4112
4240
|
init_git();
|
|
@@ -4115,6 +4243,7 @@ var init_state = __esm(() => {
|
|
|
4115
4243
|
init_redaction();
|
|
4116
4244
|
DEFAULT_SETTINGS = { daemonEnabled: true, autoUpdate: true };
|
|
4117
4245
|
LOG_ROTATE_BYTES = 5 * 1024 * 1024;
|
|
4246
|
+
SYNC_LOCK_WAIT_MS = 10 * 60 * 1000;
|
|
4118
4247
|
});
|
|
4119
4248
|
|
|
4120
4249
|
// src/service.ts
|
|
@@ -4415,12 +4544,395 @@ var init_service = __esm(() => {
|
|
|
4415
4544
|
init_state();
|
|
4416
4545
|
});
|
|
4417
4546
|
|
|
4418
|
-
// src/
|
|
4547
|
+
// src/review.ts
|
|
4419
4548
|
import crypto4 from "node:crypto";
|
|
4420
4549
|
import fs8 from "node:fs";
|
|
4421
|
-
import os5 from "node:os";
|
|
4422
4550
|
import path8 from "node:path";
|
|
4423
|
-
import {
|
|
4551
|
+
import { spawn as spawn2, spawnSync as spawnSync4 } from "node:child_process";
|
|
4552
|
+
function newId() {
|
|
4553
|
+
return crypto4.randomBytes(4).toString("hex");
|
|
4554
|
+
}
|
|
4555
|
+
function reviewsDir() {
|
|
4556
|
+
return path8.join(sidecarStateDir(), "reviews");
|
|
4557
|
+
}
|
|
4558
|
+
function reviewDir(id) {
|
|
4559
|
+
return path8.join(reviewsDir(), id);
|
|
4560
|
+
}
|
|
4561
|
+
function writePrivateFile(filePath, text) {
|
|
4562
|
+
const temporary = `${filePath}.${crypto4.randomUUID()}.tmp`;
|
|
4563
|
+
try {
|
|
4564
|
+
fs8.writeFileSync(temporary, text, { encoding: "utf8", mode: 384 });
|
|
4565
|
+
fs8.renameSync(temporary, filePath);
|
|
4566
|
+
} finally {
|
|
4567
|
+
fs8.rmSync(temporary, { force: true });
|
|
4568
|
+
}
|
|
4569
|
+
}
|
|
4570
|
+
function writePrivateJson(filePath, value) {
|
|
4571
|
+
writePrivateFile(filePath, `${JSON.stringify(value, null, 2)}
|
|
4572
|
+
`);
|
|
4573
|
+
}
|
|
4574
|
+
function readJson(filePath) {
|
|
4575
|
+
try {
|
|
4576
|
+
return JSON.parse(fs8.readFileSync(filePath, "utf8"));
|
|
4577
|
+
} catch {
|
|
4578
|
+
return;
|
|
4579
|
+
}
|
|
4580
|
+
}
|
|
4581
|
+
function readReview(id) {
|
|
4582
|
+
keepFailuresPrivate();
|
|
4583
|
+
if (!ID_PATTERN.test(id))
|
|
4584
|
+
return;
|
|
4585
|
+
const dir = reviewDir(id);
|
|
4586
|
+
const record = readJson(path8.join(dir, "review.json"));
|
|
4587
|
+
if (!record || record.id !== id)
|
|
4588
|
+
return;
|
|
4589
|
+
const verdict = readJson(path8.join(dir, "verdict.json"));
|
|
4590
|
+
const run = readJson(runPath(dir));
|
|
4591
|
+
return { record, verdict, run, state: reviewState(record, verdict, run), dir };
|
|
4592
|
+
}
|
|
4593
|
+
function runPath(dir) {
|
|
4594
|
+
return path8.join(dir, "run.json");
|
|
4595
|
+
}
|
|
4596
|
+
function reviewFamily(sidecarPath) {
|
|
4597
|
+
return realpathOr(gitCommonDir(sidecarPath));
|
|
4598
|
+
}
|
|
4599
|
+
function listReviews(sidecarPath) {
|
|
4600
|
+
let ids;
|
|
4601
|
+
try {
|
|
4602
|
+
ids = fs8.readdirSync(reviewsDir());
|
|
4603
|
+
} catch {
|
|
4604
|
+
return [];
|
|
4605
|
+
}
|
|
4606
|
+
const family = sidecarPath === undefined ? undefined : reviewFamily(sidecarPath);
|
|
4607
|
+
return ids.map(readReview).filter((review) => review !== undefined).filter((review) => family === undefined || review.record.family === family).sort((left, right) => right.record.createdAt.localeCompare(left.record.createdAt) || right.record.id.localeCompare(left.record.id));
|
|
4608
|
+
}
|
|
4609
|
+
function findReview(idOrPrefix) {
|
|
4610
|
+
const exact = readReview(idOrPrefix);
|
|
4611
|
+
if (exact)
|
|
4612
|
+
return exact;
|
|
4613
|
+
const matches = listReviews().filter((review) => review.record.id.startsWith(idOrPrefix));
|
|
4614
|
+
if (matches.length === 1)
|
|
4615
|
+
return matches[0];
|
|
4616
|
+
if (matches.length > 1)
|
|
4617
|
+
throw new SidecarError(`review id ${JSON.stringify(idOrPrefix)} is ambiguous; use more of it`);
|
|
4618
|
+
throw new SidecarError(`no review ${JSON.stringify(idOrPrefix)} on this machine`);
|
|
4619
|
+
}
|
|
4620
|
+
function isOpen(review) {
|
|
4621
|
+
return review.state !== "published" && review.state !== "superseded";
|
|
4622
|
+
}
|
|
4623
|
+
function assertOpen(review) {
|
|
4624
|
+
if (!isOpen(review))
|
|
4625
|
+
throw new SidecarError(`review ${review.record.id} is already ${review.state}`);
|
|
4626
|
+
}
|
|
4627
|
+
function openReview(sidecarPath) {
|
|
4628
|
+
return listReviews(sidecarPath).find(isOpen);
|
|
4629
|
+
}
|
|
4630
|
+
function reviewState(record, verdict, run) {
|
|
4631
|
+
if (record.publishedAt)
|
|
4632
|
+
return "published";
|
|
4633
|
+
if (record.supersededAt)
|
|
4634
|
+
return "superseded";
|
|
4635
|
+
if (verdict)
|
|
4636
|
+
return verdict.verdict;
|
|
4637
|
+
if (run?.finishedAt && run.exitCode !== 0)
|
|
4638
|
+
return "failed";
|
|
4639
|
+
if (run && !run.finishedAt && !pidAlive(run.pid))
|
|
4640
|
+
return "failed";
|
|
4641
|
+
if (!run && record.launchedAt && (Date.now() - Date.parse(record.launchedAt)) / 1000 > LAUNCH_GRACE_SECONDS)
|
|
4642
|
+
return "failed";
|
|
4643
|
+
return "pending";
|
|
4644
|
+
}
|
|
4645
|
+
function pidAlive(pid) {
|
|
4646
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
4647
|
+
return false;
|
|
4648
|
+
try {
|
|
4649
|
+
process.kill(pid, 0);
|
|
4650
|
+
return true;
|
|
4651
|
+
} catch (error) {
|
|
4652
|
+
return error.code === "EPERM";
|
|
4653
|
+
}
|
|
4654
|
+
}
|
|
4655
|
+
function reviewAttention(review, now = Date.now()) {
|
|
4656
|
+
if (review.state === "rejected")
|
|
4657
|
+
return "rejected";
|
|
4658
|
+
if (review.state === "failed") {
|
|
4659
|
+
const run = review.run;
|
|
4660
|
+
if (!run)
|
|
4661
|
+
return "reviewer never started";
|
|
4662
|
+
if (run.finishedAt) {
|
|
4663
|
+
return run.signal ? `reviewer stopped by ${run.signal} without a verdict` : `reviewer exited ${run.exitCode} without a verdict`;
|
|
4664
|
+
}
|
|
4665
|
+
return "reviewer stopped without a verdict";
|
|
4666
|
+
}
|
|
4667
|
+
if (review.state !== "pending")
|
|
4668
|
+
return;
|
|
4669
|
+
if (!review.record.launchedAt && !readSettings().reviewer)
|
|
4670
|
+
return "no reviewer is configured on this machine";
|
|
4671
|
+
const age = (now - Date.parse(review.record.createdAt)) / 1000;
|
|
4672
|
+
if (age > review.record.timeoutSeconds)
|
|
4673
|
+
return `undecided for over ${formatDuration(review.record.timeoutSeconds)}`;
|
|
4674
|
+
return;
|
|
4675
|
+
}
|
|
4676
|
+
function formatDuration(seconds) {
|
|
4677
|
+
if (seconds >= 3600 && seconds % 3600 === 0)
|
|
4678
|
+
return `${seconds / 3600}h`;
|
|
4679
|
+
if (seconds >= 60 && seconds % 60 === 0)
|
|
4680
|
+
return `${seconds / 60}m`;
|
|
4681
|
+
return `${Math.round(seconds)}s`;
|
|
4682
|
+
}
|
|
4683
|
+
function peerReviewGuidelinesFileName(name) {
|
|
4684
|
+
return name === DEFAULT_PEER ? ".sidecar-review" : `.sidecar-review.${name}`;
|
|
4685
|
+
}
|
|
4686
|
+
function peerReviewGuidelinesPath(root, name) {
|
|
4687
|
+
return path8.join(root, peerReviewGuidelinesFileName(name));
|
|
4688
|
+
}
|
|
4689
|
+
function readGuidelines(root, peer) {
|
|
4690
|
+
try {
|
|
4691
|
+
const text = fs8.readFileSync(peerReviewGuidelinesPath(root, peer), "utf8").trim();
|
|
4692
|
+
return text || undefined;
|
|
4693
|
+
} catch (error) {
|
|
4694
|
+
if (error.code === "ENOENT")
|
|
4695
|
+
return;
|
|
4696
|
+
throw new SidecarError(`could not read ${peerReviewGuidelinesPath(root, peer)}: ${error instanceof Error ? error.message : String(error)}`);
|
|
4697
|
+
}
|
|
4698
|
+
}
|
|
4699
|
+
function createReview(options) {
|
|
4700
|
+
const { root, config, sidecarPath, base, head } = options;
|
|
4701
|
+
const guidelines = readGuidelines(root, config.peer);
|
|
4702
|
+
const id = newId();
|
|
4703
|
+
const dir = reviewDir(id);
|
|
4704
|
+
fs8.mkdirSync(reviewsDir(), { recursive: true, mode: 448 });
|
|
4705
|
+
fs8.mkdirSync(dir, { mode: 448 });
|
|
4706
|
+
const fields = git(sidecarPath, ["diff-tree", ...PACKET_DIFF_OPTIONS, "--name-status", "-z", base, head]).stdout.split("\x00").filter(Boolean);
|
|
4707
|
+
const files = [];
|
|
4708
|
+
for (let index = 0;index + 1 < fields.length; index += 2)
|
|
4709
|
+
files.push({ status: fields[index], path: fields[index + 1] });
|
|
4710
|
+
writeOpaqueArtifacts(sidecarPath, dir, base, head, files);
|
|
4711
|
+
const diff = git(sidecarPath, ["diff-tree", ...PACKET_DIFF_OPTIONS, "-p", "--no-color", "--no-ext-diff", "--no-textconv", base, head]).stdout;
|
|
4712
|
+
const record = {
|
|
4713
|
+
schema: REVIEW_SCHEMA,
|
|
4714
|
+
id,
|
|
4715
|
+
family: reviewFamily(sidecarPath),
|
|
4716
|
+
root,
|
|
4717
|
+
peer: config.peer,
|
|
4718
|
+
base,
|
|
4719
|
+
head,
|
|
4720
|
+
tree: treeOf(sidecarPath, head),
|
|
4721
|
+
files,
|
|
4722
|
+
createdAt: nowIso(),
|
|
4723
|
+
timeoutSeconds: reviewTimeoutSeconds(config)
|
|
4724
|
+
};
|
|
4725
|
+
writePrivateFile(path8.join(dir, "diff.patch"), diff);
|
|
4726
|
+
if (guidelines)
|
|
4727
|
+
writePrivateFile(path8.join(dir, "guidelines.md"), `${guidelines}
|
|
4728
|
+
`);
|
|
4729
|
+
writePrivateFile(path8.join(dir, "instructions.md"), reviewInstructions(record, config, guidelines));
|
|
4730
|
+
writePrivateJson(path8.join(dir, "review.json"), record);
|
|
4731
|
+
logSidecarEvent("review-open", { id, root, peer: config.peer, files: files.length });
|
|
4732
|
+
pruneClosedReviews(sidecarPath);
|
|
4733
|
+
return { record, state: "pending", dir };
|
|
4734
|
+
}
|
|
4735
|
+
function writeOpaqueArtifacts(repo, dir, from, head, files) {
|
|
4736
|
+
const binary = new Set(git(repo, ["diff-tree", ...PACKET_DIFF_OPTIONS, "--numstat", "-z", from, head]).stdout.split("\x00").filter((entry) => entry.startsWith("-\t-\t")).map((entry) => entry.slice("-\t-\t".length)));
|
|
4737
|
+
const blobs = new Set(git(repo, ["ls-tree", "-r", "-z", "--full-tree", head]).stdout.split("\x00").filter((entry) => entry.split(" ")[1] === "blob").map((entry) => entry.slice(entry.indexOf("\t") + 1)));
|
|
4738
|
+
const utf8 = new TextDecoder("utf-8", { fatal: true });
|
|
4739
|
+
const isText = (blob) => {
|
|
4740
|
+
try {
|
|
4741
|
+
utf8.decode(blob);
|
|
4742
|
+
return true;
|
|
4743
|
+
} catch {
|
|
4744
|
+
return false;
|
|
4745
|
+
}
|
|
4746
|
+
};
|
|
4747
|
+
let count = 0;
|
|
4748
|
+
for (const file of files) {
|
|
4749
|
+
if (file.status === "D" || !blobs.has(file.path))
|
|
4750
|
+
continue;
|
|
4751
|
+
const blob = gitBytes(repo, ["cat-file", "blob", `${head}:${file.path}`]).stdout;
|
|
4752
|
+
if (!binary.has(file.path) && isText(blob))
|
|
4753
|
+
continue;
|
|
4754
|
+
count += 1;
|
|
4755
|
+
file.artifact = path8.join("opaque", String(count).padStart(4, "0"));
|
|
4756
|
+
fs8.mkdirSync(path8.join(dir, "opaque"), { recursive: true, mode: 448 });
|
|
4757
|
+
fs8.writeFileSync(path8.join(dir, file.artifact), blob, { mode: 384 });
|
|
4758
|
+
}
|
|
4759
|
+
}
|
|
4760
|
+
function updateRecord(review, change) {
|
|
4761
|
+
const record = { ...review.record, ...change };
|
|
4762
|
+
writePrivateJson(path8.join(review.dir, "review.json"), record);
|
|
4763
|
+
review.record = record;
|
|
4764
|
+
}
|
|
4765
|
+
function markPublishing(review, inbox, commit) {
|
|
4766
|
+
updateRecord(review, { publishing: { inbox, commit } });
|
|
4767
|
+
}
|
|
4768
|
+
function markPublished(review, commit) {
|
|
4769
|
+
updateRecord(review, { publishedAt: nowIso(), publishedCommit: commit });
|
|
4770
|
+
logSidecarEvent("review-published", { id: review.record.id, commit });
|
|
4771
|
+
}
|
|
4772
|
+
function markSuperseded(review) {
|
|
4773
|
+
updateRecord(review, { supersededAt: nowIso() });
|
|
4774
|
+
logSidecarEvent("review-superseded", { id: review.record.id });
|
|
4775
|
+
}
|
|
4776
|
+
function pruneClosedReviews(sidecarPath) {
|
|
4777
|
+
const closed = listReviews(sidecarPath).filter((review) => !isOpen(review));
|
|
4778
|
+
for (const review of closed.slice(KEEP_CLOSED_REVIEWS)) {
|
|
4779
|
+
fs8.rmSync(review.dir, { recursive: true, force: true });
|
|
4780
|
+
}
|
|
4781
|
+
}
|
|
4782
|
+
function pruneOrphanedReviews() {
|
|
4783
|
+
for (const review of listReviews()) {
|
|
4784
|
+
if (!fs8.existsSync(review.record.family))
|
|
4785
|
+
fs8.rmSync(review.dir, { recursive: true, force: true });
|
|
4786
|
+
}
|
|
4787
|
+
}
|
|
4788
|
+
function opaqueSection(record) {
|
|
4789
|
+
const opaque = record.files.filter((file) => file.artifact);
|
|
4790
|
+
if (!opaque.length)
|
|
4791
|
+
return "";
|
|
4792
|
+
return `
|
|
4793
|
+
These files' content does not appear faithfully in \`diff.patch\` (binary, not
|
|
4794
|
+
UTF-8, or diffs turned off). Their complete new content, as it would be
|
|
4795
|
+
published, is in the packet.
|
|
4796
|
+
Inspect each one; if you cannot, do not approve:
|
|
4797
|
+
|
|
4798
|
+
${opaque.map((file) => `- \`${file.artifact}\` — ${file.path}`).join(`
|
|
4799
|
+
`)}
|
|
4800
|
+
`;
|
|
4801
|
+
}
|
|
4802
|
+
function reviewInstructions(record, config, guidelines) {
|
|
4803
|
+
return `# Sidecar publish review ${record.id}
|
|
4804
|
+
|
|
4805
|
+
A sidecar is a scratchpad repository that syncs automatically to a shared Git
|
|
4806
|
+
remote (${redactText(config.remote)}). This machine wants to publish the change in
|
|
4807
|
+
\`diff.patch\`. Everyone with access to that remote can read what you approve,
|
|
4808
|
+
and it stays in the remote's history.
|
|
4809
|
+
|
|
4810
|
+
Decide whether anything in the change should not leave this machine. This is
|
|
4811
|
+
not a code review: ignore quality, style, and correctness.
|
|
4812
|
+
|
|
4813
|
+
Secret redaction has already run. Values it caught appear as placeholders such
|
|
4814
|
+
as \`<API_KEY>\`, \`<TOKEN>\`, or \`<SECRET>\`; they are not leaks. Look for what
|
|
4815
|
+
pattern matching misses:
|
|
4816
|
+
|
|
4817
|
+
- credentials in unusual formats, or ones split across lines
|
|
4818
|
+
- personal data about real people: contact details, health, finances, private conversations
|
|
4819
|
+
- confidential material: customer names and data, unreleased plans, internal-only details
|
|
4820
|
+
- anything the repository's guidelines below rule out
|
|
4821
|
+
|
|
4822
|
+
## Files
|
|
4823
|
+
|
|
4824
|
+
All in this directory (\`$SIDECAR_REVIEW_DIR\`):
|
|
4825
|
+
|
|
4826
|
+
- \`diff.patch\` — the change exactly as it would be published
|
|
4827
|
+
- \`review.json\` — the repository, peer, and changed paths
|
|
4828
|
+
${guidelines ? "- `guidelines.md` — this repository's guidelines, repeated below\n" : ""}${opaqueSection(record)}
|
|
4829
|
+
## Guidelines
|
|
4830
|
+
|
|
4831
|
+
${guidelines ?? "This repository has no review guidelines (`.sidecar-review` beside its `.sidecar`)."}
|
|
4832
|
+
|
|
4833
|
+
## Decide
|
|
4834
|
+
|
|
4835
|
+
Approve, and the change is published on the next sync:
|
|
4836
|
+
|
|
4837
|
+
sidecar review approve ${record.id}
|
|
4838
|
+
|
|
4839
|
+
Reject, saying which file and what kind of content. Do not repeat the
|
|
4840
|
+
sensitive value itself:
|
|
4841
|
+
|
|
4842
|
+
sidecar review reject ${record.id} --reason "<file: what kind of content>"
|
|
4843
|
+
|
|
4844
|
+
If you are unsure, or a person should weigh in, do neither: leave the review
|
|
4845
|
+
open and raise it with them. Nothing is published until someone approves, and
|
|
4846
|
+
they decide with the same two commands.
|
|
4847
|
+
`;
|
|
4848
|
+
}
|
|
4849
|
+
function configuredReviewer() {
|
|
4850
|
+
const command = readSettings().reviewer;
|
|
4851
|
+
if (!command)
|
|
4852
|
+
throw new SidecarError("no reviewer is configured; set one with `sidecar review reviewer <command>`");
|
|
4853
|
+
return command;
|
|
4854
|
+
}
|
|
4855
|
+
function launchReviewer(review) {
|
|
4856
|
+
updateRecord(review, { launchedAt: nowIso() });
|
|
4857
|
+
const child = spawn2(process.execPath, [sidecarCliPath(), "review", "run", review.record.id], {
|
|
4858
|
+
cwd: review.dir,
|
|
4859
|
+
detached: true,
|
|
4860
|
+
stdio: "ignore",
|
|
4861
|
+
env: { ...process.env, [SKIP_LOCAL_EXEC_ENV]: "1" }
|
|
4862
|
+
});
|
|
4863
|
+
child.unref();
|
|
4864
|
+
logSidecarEvent("review-launch", { id: review.record.id });
|
|
4865
|
+
}
|
|
4866
|
+
function runReviewer(idOrPrefix) {
|
|
4867
|
+
const review = findReview(idOrPrefix);
|
|
4868
|
+
const { id } = review.record;
|
|
4869
|
+
assertOpen(review);
|
|
4870
|
+
if (review.run)
|
|
4871
|
+
throw new SidecarError(`review ${id}'s reviewer already ran; \`sidecar review retry ${id}\` opens a new review of the same change`);
|
|
4872
|
+
const command = configuredReviewer();
|
|
4873
|
+
const file = runPath(review.dir);
|
|
4874
|
+
const run = { pid: process.pid, command, startedAt: nowIso() };
|
|
4875
|
+
writePrivateJson(file, run);
|
|
4876
|
+
const log = fs8.openSync(path8.join(review.dir, "reviewer.log"), "a", 384);
|
|
4877
|
+
let result;
|
|
4878
|
+
try {
|
|
4879
|
+
result = spawnSync4("/bin/sh", ["-c", command], {
|
|
4880
|
+
cwd: review.dir,
|
|
4881
|
+
stdio: ["ignore", log, log],
|
|
4882
|
+
env: reviewerEnv(review)
|
|
4883
|
+
});
|
|
4884
|
+
} finally {
|
|
4885
|
+
fs8.closeSync(log);
|
|
4886
|
+
}
|
|
4887
|
+
const finished = { ...run, finishedAt: nowIso(), exitCode: result.status, signal: result.signal };
|
|
4888
|
+
writePrivateJson(file, finished);
|
|
4889
|
+
logSidecarEvent("review-run", { id, exitCode: result.status, signal: result.signal });
|
|
4890
|
+
return 0;
|
|
4891
|
+
}
|
|
4892
|
+
function reviewerEnv(review) {
|
|
4893
|
+
const env = { ...process.env };
|
|
4894
|
+
for (const name of [PEER_ENV, SOFT_SYNC_ENV, LOCAL_SYNC_ENV, SKIP_LOCAL_EXEC_ENV, GLOBAL_EXEC_ENV])
|
|
4895
|
+
delete env[name];
|
|
4896
|
+
const { record } = review;
|
|
4897
|
+
return {
|
|
4898
|
+
...env,
|
|
4899
|
+
SIDECAR_REVIEW_ID: record.id,
|
|
4900
|
+
SIDECAR_REVIEW_DIR: review.dir,
|
|
4901
|
+
SIDECAR_REVIEW_PROMPT: path8.join(review.dir, "instructions.md"),
|
|
4902
|
+
SIDECAR_REVIEW_DIFF: path8.join(review.dir, "diff.patch"),
|
|
4903
|
+
SIDECAR_REVIEW_ROOT: record.root,
|
|
4904
|
+
SIDECAR_REVIEW_PEER: record.peer,
|
|
4905
|
+
SIDECAR_REVIEW_CONFIG: peerConfigPath(record.root, record.peer)
|
|
4906
|
+
};
|
|
4907
|
+
}
|
|
4908
|
+
function decideReview(review, verdict, reason) {
|
|
4909
|
+
assertOpen(review);
|
|
4910
|
+
if (review.record.publishing && verdict === "rejected") {
|
|
4911
|
+
throw new SidecarError(`review ${review.record.id} is already being published; it can no longer be rejected`);
|
|
4912
|
+
}
|
|
4913
|
+
const record = { verdict, ...reason ? { reason } : {}, decidedAt: nowIso() };
|
|
4914
|
+
writePrivateJson(path8.join(review.dir, "verdict.json"), record);
|
|
4915
|
+
review.verdict = record;
|
|
4916
|
+
review.state = verdict;
|
|
4917
|
+
logSidecarEvent("review-decision", { id: review.record.id, verdict });
|
|
4918
|
+
}
|
|
4919
|
+
var REVIEW_SCHEMA = 1, KEEP_CLOSED_REVIEWS = 10, ID_PATTERN, LAUNCH_GRACE_SECONDS = 60, PACKET_DIFF_OPTIONS;
|
|
4920
|
+
var init_review = __esm(() => {
|
|
4921
|
+
init_util();
|
|
4922
|
+
init_git();
|
|
4923
|
+
init_install();
|
|
4924
|
+
init_config();
|
|
4925
|
+
init_state();
|
|
4926
|
+
init_redaction();
|
|
4927
|
+
ID_PATTERN = /^[0-9a-f]+$/;
|
|
4928
|
+
PACKET_DIFF_OPTIONS = ["-r", "--no-renames", "--ignore-submodules=none"];
|
|
4929
|
+
});
|
|
4930
|
+
|
|
4931
|
+
// src/sync.ts
|
|
4932
|
+
import crypto5 from "node:crypto";
|
|
4933
|
+
import fs9 from "node:fs";
|
|
4934
|
+
import os5 from "node:os";
|
|
4935
|
+
import path9 from "node:path";
|
|
4424
4936
|
function syncProject(root, config, options) {
|
|
4425
4937
|
const stage = (name) => options.onStage?.(name);
|
|
4426
4938
|
stage("checkout");
|
|
@@ -4428,6 +4940,7 @@ function syncProject(root, config, options) {
|
|
|
4428
4940
|
if (!options.remote && !hasAnyCommit(sidecarPath)) {
|
|
4429
4941
|
throw new SidecarError(`local sync requires an initialized sidecar checkout at ${sidecarPath}; run \`sidecar sync\` first`);
|
|
4430
4942
|
}
|
|
4943
|
+
bindReviewPolicy(sidecarPath, config);
|
|
4431
4944
|
const inbox = expandInbox(config, sidecarPath);
|
|
4432
4945
|
ensureCommitIdentity(sidecarPath);
|
|
4433
4946
|
ensureRedactionFilter(sidecarPath, config.redaction, config);
|
|
@@ -4436,22 +4949,335 @@ function syncProject(root, config, options) {
|
|
|
4436
4949
|
if (options.snapshot) {
|
|
4437
4950
|
snapshot(sidecarPath, root, inbox, options.message, config.redaction, config);
|
|
4438
4951
|
}
|
|
4439
|
-
const siblings = siblingCheckouts(sidecarPath, config);
|
|
4440
|
-
if (siblings.length || !options.remote) {
|
|
4441
|
-
stage("merge-local");
|
|
4442
|
-
mergeInboxBranches(sidecarPath, config, { forkFiles: true, push: false, remote: false });
|
|
4443
|
-
stage("settle");
|
|
4444
|
-
settleCheckouts(sidecarPath, config, inbox, siblings);
|
|
4952
|
+
const siblings = siblingCheckouts(sidecarPath, config);
|
|
4953
|
+
if (siblings.length || !options.remote) {
|
|
4954
|
+
stage("merge-local");
|
|
4955
|
+
mergeInboxBranches(sidecarPath, config, { forkFiles: true, push: false, remote: false });
|
|
4956
|
+
stage("settle");
|
|
4957
|
+
settleCheckouts(sidecarPath, config, inbox, siblings);
|
|
4958
|
+
}
|
|
4959
|
+
if (!options.remote)
|
|
4960
|
+
return {};
|
|
4961
|
+
if (reviewRequired(config)) {
|
|
4962
|
+
return publishReviewed(root, sidecarPath, config, inbox, siblings, stage, options.reviewFloor ?? false);
|
|
4963
|
+
}
|
|
4964
|
+
stage("push-inbox");
|
|
4965
|
+
syncBranchBeforePush(sidecarPath, inbox, config);
|
|
4966
|
+
pushBranch(sidecarPath, inbox);
|
|
4967
|
+
stage("merge");
|
|
4968
|
+
mergeInboxBranches(sidecarPath, config, { forkFiles: true, push: true, remote: true });
|
|
4969
|
+
stage("settle-remote");
|
|
4970
|
+
settleCheckouts(sidecarPath, config, inbox, siblings);
|
|
4971
|
+
return {};
|
|
4972
|
+
}
|
|
4973
|
+
function withPublicGit(repo, fn) {
|
|
4974
|
+
assertRedactionFilterIntact(repo);
|
|
4975
|
+
const { name, email } = sidecarIdentity();
|
|
4976
|
+
const overrides = [
|
|
4977
|
+
["core.hooksPath", os5.devNull],
|
|
4978
|
+
["core.attributesFile", os5.devNull],
|
|
4979
|
+
["core.autocrlf", "false"],
|
|
4980
|
+
["commit.gpgSign", "false"],
|
|
4981
|
+
["tag.gpgSign", "false"],
|
|
4982
|
+
["i18n.commitEncoding", "UTF-8"],
|
|
4983
|
+
["rerere.enabled", "false"],
|
|
4984
|
+
["rerere.autoUpdate", "false"],
|
|
4985
|
+
["merge.renormalize", "false"],
|
|
4986
|
+
["merge.default", "text"],
|
|
4987
|
+
["push.pushOption", ""],
|
|
4988
|
+
["push.followTags", "false"],
|
|
4989
|
+
...neutralizedDrivers(repo)
|
|
4990
|
+
];
|
|
4991
|
+
const env = {
|
|
4992
|
+
GIT_AUTHOR_NAME: name,
|
|
4993
|
+
GIT_AUTHOR_EMAIL: email,
|
|
4994
|
+
GIT_COMMITTER_NAME: name,
|
|
4995
|
+
GIT_COMMITTER_EMAIL: email,
|
|
4996
|
+
GIT_NO_REPLACE_OBJECTS: "1",
|
|
4997
|
+
GIT_ATTR_SOURCE: emptyTree(repo),
|
|
4998
|
+
GIT_CONFIG_PARAMETERS: undefined,
|
|
4999
|
+
GIT_CONFIG_COUNT: String(overrides.length)
|
|
5000
|
+
};
|
|
5001
|
+
overrides.forEach(([key, value], index) => {
|
|
5002
|
+
env[`GIT_CONFIG_KEY_${index}`] = key;
|
|
5003
|
+
env[`GIT_CONFIG_VALUE_${index}`] = value;
|
|
5004
|
+
});
|
|
5005
|
+
const saved = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]]));
|
|
5006
|
+
const apply = (values) => {
|
|
5007
|
+
for (const [key, value] of Object.entries(values)) {
|
|
5008
|
+
if (value === undefined)
|
|
5009
|
+
delete process.env[key];
|
|
5010
|
+
else
|
|
5011
|
+
process.env[key] = value;
|
|
5012
|
+
}
|
|
5013
|
+
};
|
|
5014
|
+
apply(env);
|
|
5015
|
+
try {
|
|
5016
|
+
return fn();
|
|
5017
|
+
} finally {
|
|
5018
|
+
apply(saved);
|
|
5019
|
+
}
|
|
5020
|
+
}
|
|
5021
|
+
function neutralizedDrivers(repo) {
|
|
5022
|
+
const keys = git(repo, ["config", "--name-only", "--get-regexp", "^(merge|filter)\\..+\\.(driver|clean|smudge|process)$"], { check: false }).stdout.split(`
|
|
5023
|
+
`).filter(Boolean);
|
|
5024
|
+
return keys.flatMap((key) => {
|
|
5025
|
+
const [section, ...rest] = key.split(".");
|
|
5026
|
+
const driver = rest.slice(0, -1).join(".");
|
|
5027
|
+
if (section === "merge")
|
|
5028
|
+
return [[`merge.${driver}.driver`, "git merge-file --marker-size=%L %A %O %B"]];
|
|
5029
|
+
if (driver === REDACTION_FILTER_NAME)
|
|
5030
|
+
return [];
|
|
5031
|
+
return [[`filter.${driver}.clean`, "cat"], [`filter.${driver}.smudge`, "cat"], [`filter.${driver}.process`, ""], [`filter.${driver}.required`, "false"]];
|
|
5032
|
+
});
|
|
5033
|
+
}
|
|
5034
|
+
function assertRedactionFilterIntact(repo) {
|
|
5035
|
+
if (git(repo, ["config", "--get-all", `filter.${REDACTION_FILTER_NAME}.process`], { check: false }).status === 0) {
|
|
5036
|
+
throw new SidecarError(`filter.${REDACTION_FILTER_NAME}.process is configured; remove it — a review-gated peer publishes only through sidecar's own redaction filter`);
|
|
5037
|
+
}
|
|
5038
|
+
}
|
|
5039
|
+
function assertPublishDestination(repo) {
|
|
5040
|
+
const pushUrl = git(repo, ["config", "--get-all", "remote.origin.pushurl"], { check: false }).stdout.trim();
|
|
5041
|
+
if (!pushUrl)
|
|
5042
|
+
return;
|
|
5043
|
+
const url = git(repo, ["config", "--get", "remote.origin.url"], { check: false }).stdout.trim();
|
|
5044
|
+
if (pushUrl !== url) {
|
|
5045
|
+
throw new SidecarError("remote.origin.pushurl sends this sidecar's pushes somewhere other than its remote; a review-gated peer publishes only to its remote — unset it");
|
|
5046
|
+
}
|
|
5047
|
+
}
|
|
5048
|
+
function fetchPublic(repo) {
|
|
5049
|
+
git(repo, ["fetch", "--quiet", "--prune", "--no-tags", "origin", "+refs/heads/*:refs/remotes/origin/*", `+refs/heads/*:${PUBLIC_REFS}*`]);
|
|
5050
|
+
}
|
|
5051
|
+
function publicRef(branch) {
|
|
5052
|
+
return `${PUBLIC_REFS}${branch}`;
|
|
5053
|
+
}
|
|
5054
|
+
function refreshPublicRefs(repo) {
|
|
5055
|
+
withPublicGit(repo, () => fetchPublic(repo));
|
|
5056
|
+
}
|
|
5057
|
+
function publicTip(repo, branch) {
|
|
5058
|
+
return git(repo, ["rev-parse", "--verify", "--quiet", publicRef(branch)], { check: false }).stdout.trim() || undefined;
|
|
5059
|
+
}
|
|
5060
|
+
function isPublic(repo, commit) {
|
|
5061
|
+
return Boolean(git(repo, ["for-each-ref", "--contains", commit, "--format=%(refname)", PUBLIC_REFS]).stdout.trim());
|
|
5062
|
+
}
|
|
5063
|
+
function publishReviewed(root, sidecarPath, config, inbox, siblings, stage, reviewFloor) {
|
|
5064
|
+
stage("fetch");
|
|
5065
|
+
withPublicGit(sidecarPath, () => {
|
|
5066
|
+
fetchPublic(sidecarPath);
|
|
5067
|
+
if (!publicTip(sidecarPath, config.branch))
|
|
5068
|
+
bootstrapReviewedMain(sidecarPath, config);
|
|
5069
|
+
stage("publish");
|
|
5070
|
+
const open = openReview(sidecarPath);
|
|
5071
|
+
if (open?.state === "approved")
|
|
5072
|
+
publishApprovedReview(sidecarPath, config, inbox, open);
|
|
5073
|
+
});
|
|
5074
|
+
stage("merge");
|
|
5075
|
+
withPublicGit(sidecarPath, () => mergeFetchedInboxes(sidecarPath, config, { forkFiles: true }));
|
|
5076
|
+
stage("merge-local");
|
|
5077
|
+
mergeInboxBranches(sidecarPath, config, { forkFiles: true, push: false, remote: false });
|
|
5078
|
+
stage("settle-remote");
|
|
5079
|
+
settleCheckouts(sidecarPath, config, inbox, siblings);
|
|
5080
|
+
stage("review");
|
|
5081
|
+
const gate = withPublicGit(sidecarPath, () => gateUnpublished(root, sidecarPath, config, reviewFloor));
|
|
5082
|
+
if (!gate)
|
|
5083
|
+
return {};
|
|
5084
|
+
if ("due" in gate)
|
|
5085
|
+
return { reviewDue: gate.due };
|
|
5086
|
+
if (gate.launch)
|
|
5087
|
+
launchReviewer(gate.review);
|
|
5088
|
+
return { held: { review: gate.review, attention: reviewAttention(gate.review) } };
|
|
5089
|
+
}
|
|
5090
|
+
function gateUnpublished(root, sidecarPath, config, floor) {
|
|
5091
|
+
const configPath = peerConfigPath(root, config.peer);
|
|
5092
|
+
const result = gateReview(root, sidecarPath, config, floor);
|
|
5093
|
+
if (result && "due" in result)
|
|
5094
|
+
writeReviewDue(configPath, result.due);
|
|
5095
|
+
else
|
|
5096
|
+
clearReviewDue(configPath);
|
|
5097
|
+
return result;
|
|
5098
|
+
}
|
|
5099
|
+
function gateReview(root, sidecarPath, config, floor) {
|
|
5100
|
+
const head = git(sidecarPath, ["rev-parse", `refs/heads/${config.branch}`]).stdout.trim();
|
|
5101
|
+
const headTree = treeOf(sidecarPath, head);
|
|
5102
|
+
const publicMain = publicTip(sidecarPath, config.branch);
|
|
5103
|
+
const base = publicMain && git(sidecarPath, ["merge-base", head, publicMain], { check: false }).stdout.trim();
|
|
5104
|
+
if (!base)
|
|
5105
|
+
throw new SidecarError(`local ${config.branch} shares no history with the remote's; nothing is reviewed or published until it does`);
|
|
5106
|
+
const open = openReview(sidecarPath);
|
|
5107
|
+
const hasReviewer = Boolean(readSettings().reviewer);
|
|
5108
|
+
if (treeOf(sidecarPath, base) === headTree) {
|
|
5109
|
+
if (open && open.state !== "approved")
|
|
5110
|
+
markSuperseded(open);
|
|
5111
|
+
return;
|
|
5112
|
+
}
|
|
5113
|
+
if (open?.state === "pending" || open?.state === "approved") {
|
|
5114
|
+
return { review: open, launch: open.state === "pending" && !open.record.launchedAt && hasReviewer };
|
|
5115
|
+
}
|
|
5116
|
+
if (open) {
|
|
5117
|
+
if (open.record.tree === headTree)
|
|
5118
|
+
return { review: open, launch: false };
|
|
5119
|
+
markSuperseded(open);
|
|
5120
|
+
}
|
|
5121
|
+
if (floor) {
|
|
5122
|
+
const due = reviewFloorEndsAt(sidecarPath, config);
|
|
5123
|
+
if (due > Date.now()) {
|
|
5124
|
+
console.log(`the next review opens in ${Math.ceil((due - Date.now()) / 1000)}s; reviews open at most once per debounce`);
|
|
5125
|
+
return { due };
|
|
5126
|
+
}
|
|
5127
|
+
}
|
|
5128
|
+
const review = createReview({ root, config, sidecarPath, base, head });
|
|
5129
|
+
console.log(`opened review ${paint("brand", review.record.id)} for ${review.record.files.length} changed file(s)`);
|
|
5130
|
+
return { review, launch: hasReviewer };
|
|
5131
|
+
}
|
|
5132
|
+
function reviewFloorEndsAt(sidecarPath, config) {
|
|
5133
|
+
const previous = listReviews(sidecarPath)[0];
|
|
5134
|
+
if (!previous)
|
|
5135
|
+
return 0;
|
|
5136
|
+
const { createdAt, launchedAt } = previous.record;
|
|
5137
|
+
const started = Math.max(Date.parse(createdAt), launchedAt ? Date.parse(launchedAt) : 0);
|
|
5138
|
+
return started + (config.debounce ?? DEFAULT_DEBOUNCE_SECONDS) * 1000;
|
|
5139
|
+
}
|
|
5140
|
+
function publishApprovedReview(sidecarPath, config, inbox, review) {
|
|
5141
|
+
const { record } = review;
|
|
5142
|
+
const target = record.publishing ?? { inbox, commit: cutPublishCommit(sidecarPath, config, inbox, record) };
|
|
5143
|
+
const protectRef = publishingRef(record.id);
|
|
5144
|
+
if (!isPublic(sidecarPath, target.commit)) {
|
|
5145
|
+
const parents = git(sidecarPath, ["rev-list", "--parents", "-n", "1", target.commit]).stdout.trim().split(" ").slice(1);
|
|
5146
|
+
if (!parents.every((parent) => isPublic(sidecarPath, parent))) {
|
|
5147
|
+
markSuperseded(review);
|
|
5148
|
+
git(sidecarPath, ["update-ref", "-d", protectRef], { check: false });
|
|
5149
|
+
console.log(`review ${paint("brand", record.id)} was measured from a commit the remote does not have; reviewing again`);
|
|
5150
|
+
return;
|
|
5151
|
+
}
|
|
5152
|
+
assertPublishDestination(sidecarPath);
|
|
5153
|
+
if (!record.publishing) {
|
|
5154
|
+
git(sidecarPath, ["update-ref", protectRef, target.commit]);
|
|
5155
|
+
markPublishing(review, target.inbox, target.commit);
|
|
5156
|
+
}
|
|
5157
|
+
const push = git(sidecarPath, ["push", "--no-follow-tags", "origin", `${target.commit}:refs/heads/${target.inbox}`], { check: false });
|
|
5158
|
+
if (push.status !== 0) {
|
|
5159
|
+
throw new SidecarError(push.stderr.trim() || `could not publish review ${record.id} to ${target.inbox}`);
|
|
5160
|
+
}
|
|
5161
|
+
fetchPublic(sidecarPath);
|
|
5162
|
+
console.log(`published review ${paint("brand", record.id)} to ${paint("brand", target.inbox)}`);
|
|
5163
|
+
}
|
|
5164
|
+
linkPublished(sidecarPath, config, review, target.commit);
|
|
5165
|
+
markPublished(review, target.commit);
|
|
5166
|
+
git(sidecarPath, ["update-ref", "-d", protectRef], { check: false });
|
|
5167
|
+
}
|
|
5168
|
+
function publishingRef(id) {
|
|
5169
|
+
return `refs/sidecar-publishing/${id}`;
|
|
5170
|
+
}
|
|
5171
|
+
function retryReview(review) {
|
|
5172
|
+
const { record } = review;
|
|
5173
|
+
if (!isOpen(review))
|
|
5174
|
+
throw new SidecarError(`review ${record.id} is already ${review.state}`);
|
|
5175
|
+
if (record.publishing)
|
|
5176
|
+
throw new SidecarError(`review ${record.id} is already being published`);
|
|
5177
|
+
if (review.state === "approved")
|
|
5178
|
+
throw new SidecarError(`review ${record.id} is approved and publishes on the next sync`);
|
|
5179
|
+
const config = loadPeer(record.root, record.peer).config;
|
|
5180
|
+
const sidecarPath = requireSidecarCheckout(record.root, config);
|
|
5181
|
+
markSuperseded(review);
|
|
5182
|
+
const fresh = withPublicGit(sidecarPath, () => createReview({ root: record.root, config, sidecarPath, base: record.base, head: record.head }));
|
|
5183
|
+
launchReviewer(fresh);
|
|
5184
|
+
return fresh;
|
|
5185
|
+
}
|
|
5186
|
+
function cutPublishCommit(sidecarPath, config, inbox, record) {
|
|
5187
|
+
const previous = publicTip(sidecarPath, inbox);
|
|
5188
|
+
const parents = [record.base, ...previous && !isAncestor(sidecarPath, previous, record.base) ? [previous] : []];
|
|
5189
|
+
const body = [
|
|
5190
|
+
"sidecar publish",
|
|
5191
|
+
"",
|
|
5192
|
+
`source: ${currentMachine()}`,
|
|
5193
|
+
`inbox: ${inbox}`,
|
|
5194
|
+
`review: ${record.id}`,
|
|
5195
|
+
...publishedWriteTrailers(sidecarPath, config, record)
|
|
5196
|
+
];
|
|
5197
|
+
return git(sidecarPath, [
|
|
5198
|
+
"commit-tree",
|
|
5199
|
+
record.tree,
|
|
5200
|
+
...parents.flatMap((parent) => ["-p", parent]),
|
|
5201
|
+
"-m",
|
|
5202
|
+
body.join(`
|
|
5203
|
+
`)
|
|
5204
|
+
]).stdout.trim();
|
|
5205
|
+
}
|
|
5206
|
+
function publishedWriteTrailers(repo, config, record) {
|
|
5207
|
+
const defaults = { resolve: config.resolve, redaction: config.redaction };
|
|
5208
|
+
const paths = record.files.map((file) => file.path).filter((filePath) => resolveFileRules(config.rules, filePath, defaults).resolve === "lww");
|
|
5209
|
+
if (paths.length > WRITTEN_TRAILER_LIMIT)
|
|
5210
|
+
return [];
|
|
5211
|
+
const now = Math.floor(Date.now() / 1000);
|
|
5212
|
+
return paths.map((filePath) => {
|
|
5213
|
+
const { time } = lastWriteEvent(repo, record.head, filePath);
|
|
5214
|
+
return lwwWrittenTrailer(filePath, { time: Math.min(Math.max(0, time), now), source: `review-${record.id}` });
|
|
5215
|
+
});
|
|
5216
|
+
}
|
|
5217
|
+
function linkPublished(sidecarPath, config, review, published) {
|
|
5218
|
+
const main = `refs/heads/${config.branch}`;
|
|
5219
|
+
if (isAncestor(sidecarPath, published, main))
|
|
5220
|
+
return;
|
|
5221
|
+
if (!isAncestor(sidecarPath, review.record.head, main)) {
|
|
5222
|
+
logSidecarEvent("review-link-skip", { sidecarPath, id: review.record.id });
|
|
5223
|
+
return;
|
|
5224
|
+
}
|
|
5225
|
+
withScratchWorktree(sidecarPath, config, "HEAD", (worktree) => {
|
|
5226
|
+
git(worktree, ["switch", config.branch]);
|
|
5227
|
+
git(worktree, ["merge", "--no-ff", "-s", "ours", "-m", `sidecar: link published review ${review.record.id}`, published]);
|
|
5228
|
+
});
|
|
5229
|
+
}
|
|
5230
|
+
function mergePublishedInboxes(sidecarPath, config, options) {
|
|
5231
|
+
return withPublicGit(sidecarPath, () => {
|
|
5232
|
+
fetchPublic(sidecarPath);
|
|
5233
|
+
return mergeFetchedInboxes(sidecarPath, config, options);
|
|
5234
|
+
});
|
|
5235
|
+
}
|
|
5236
|
+
function mergeFetchedInboxes(sidecarPath, config, options) {
|
|
5237
|
+
if (!publicTip(sidecarPath, config.branch))
|
|
5238
|
+
return 0;
|
|
5239
|
+
const publicMain = publicRef(config.branch);
|
|
5240
|
+
if (publishedInboxBranches(sidecarPath, config).every((ref) => isAncestor(sidecarPath, ref, publicMain))) {
|
|
5241
|
+
console.log("no published inbox branches to merge");
|
|
5242
|
+
return 0;
|
|
4445
5243
|
}
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
5244
|
+
assertPublishDestination(sidecarPath);
|
|
5245
|
+
return withScratchWorktree(sidecarPath, config, publicMain, (worktree) => {
|
|
5246
|
+
assertRedactionFilterIntact(worktree);
|
|
5247
|
+
const maxAttempts = 3;
|
|
5248
|
+
for (let attempt = 1;; attempt += 1) {
|
|
5249
|
+
if (attempt > 1) {
|
|
5250
|
+
fetchPublic(sidecarPath);
|
|
5251
|
+
git(worktree, ["reset", "--hard", publicMain]);
|
|
5252
|
+
}
|
|
5253
|
+
const pending = publishedInboxBranches(worktree, config).filter((ref) => !isAncestor(worktree, ref, "HEAD"));
|
|
5254
|
+
if (!pending.length) {
|
|
5255
|
+
console.log("no published inbox branches to merge");
|
|
5256
|
+
return 0;
|
|
5257
|
+
}
|
|
5258
|
+
const merged = [];
|
|
5259
|
+
for (const ref of pending) {
|
|
5260
|
+
console.log(`merging ${paint("brand", remoteBranchName(ref))}`);
|
|
5261
|
+
if (mergeInboxBranch(worktree, config, ref, options))
|
|
5262
|
+
merged.push(ref);
|
|
5263
|
+
}
|
|
5264
|
+
const push = git(worktree, ["push", "--no-follow-tags", "origin", `HEAD:refs/heads/${config.branch}`], { check: false });
|
|
5265
|
+
if (push.status !== 0) {
|
|
5266
|
+
if (attempt >= maxAttempts)
|
|
5267
|
+
throw new SidecarError(push.stderr.trim() || `could not push ${config.branch}`);
|
|
5268
|
+
console.log(`push of ${config.branch} was rejected; refetching and retrying`);
|
|
5269
|
+
continue;
|
|
5270
|
+
}
|
|
5271
|
+
fetchPublic(sidecarPath);
|
|
5272
|
+
console.log(`pushed ${config.branch}`);
|
|
5273
|
+
console.log(`merged ${merged.length} inbox branch(es)`);
|
|
5274
|
+
return merged.length;
|
|
5275
|
+
}
|
|
5276
|
+
});
|
|
5277
|
+
}
|
|
5278
|
+
function publishedInboxBranches(repo, config) {
|
|
5279
|
+
const prefix = inboxPrefix(config);
|
|
5280
|
+
return git(repo, ["for-each-ref", "--format=%(refname)", PUBLIC_REFS]).stdout.split(/\r?\n/).map((ref) => ref.trim()).filter((ref) => ref && matchesInboxPrefix(prefix, ref.slice(PUBLIC_REFS.length))).sort();
|
|
4455
5281
|
}
|
|
4456
5282
|
function settleCheckouts(sidecarPath, config, inbox, siblings) {
|
|
4457
5283
|
refreshInboxFromMain(sidecarPath, config, inbox);
|
|
@@ -4465,7 +5291,7 @@ function settleCheckouts(sidecarPath, config, inbox, siblings) {
|
|
|
4465
5291
|
} catch (error) {
|
|
4466
5292
|
logSidecarEvent("settle-skip", {
|
|
4467
5293
|
sidecarPath: sibling,
|
|
4468
|
-
message: error instanceof Error ? error.message : String(error)
|
|
5294
|
+
...reviewRequired(config) ? {} : { message: error instanceof Error ? error.message : String(error) }
|
|
4469
5295
|
});
|
|
4470
5296
|
}
|
|
4471
5297
|
}
|
|
@@ -4500,33 +5326,44 @@ function reportSyncHealth(root, config, outcome) {
|
|
|
4500
5326
|
const sidecarPath = resolveSidecarPath(root, config);
|
|
4501
5327
|
if (!hasGitMetadata(sidecarPath))
|
|
4502
5328
|
return;
|
|
4503
|
-
|
|
4504
|
-
const previous = readHealthRecordAt(sidecarPath, `origin/${branch}`);
|
|
4505
|
-
const identity = {
|
|
4506
|
-
machine: `${currentUser()}@${currentHost()}`,
|
|
4507
|
-
root,
|
|
4508
|
-
peer: config.peer === DEFAULT_PEER ? undefined : config.peer,
|
|
4509
|
-
inbox: expandInbox(config, sidecarPath),
|
|
4510
|
-
version: packageVersion()
|
|
4511
|
-
};
|
|
4512
|
-
const record = nextHealthRecord(previous, identity, outcome, nowIso());
|
|
4513
|
-
if (!shouldPublishHealth(previous, record))
|
|
4514
|
-
return;
|
|
4515
|
-
publishHealthRecord(sidecarPath, branch, record);
|
|
4516
|
-
logSidecarEvent("health", {
|
|
4517
|
-
branch,
|
|
4518
|
-
status: record.status,
|
|
4519
|
-
stage: record.stage,
|
|
4520
|
-
consecutiveFailures: record.consecutiveFailures
|
|
4521
|
-
});
|
|
5329
|
+
withPublicGit(sidecarPath, () => publishHealth(root, config, sidecarPath, outcome));
|
|
4522
5330
|
} catch (error) {
|
|
4523
5331
|
logSidecarEvent("failure", {
|
|
4524
5332
|
command: "health",
|
|
4525
5333
|
root,
|
|
4526
|
-
message: `could not publish health: ${error instanceof Error ? error.message : String(error)}`
|
|
5334
|
+
message: reviewRequired(config) ? "could not publish health" : `could not publish health: ${error instanceof Error ? error.message : String(error)}`
|
|
4527
5335
|
});
|
|
4528
5336
|
}
|
|
4529
5337
|
}
|
|
5338
|
+
function publishHealth(root, config, sidecarPath, outcome) {
|
|
5339
|
+
const branch = healthBranchFor(sidecarPath);
|
|
5340
|
+
const gated = reviewRequired(config);
|
|
5341
|
+
if (gated && outcome.status === "failed") {
|
|
5342
|
+
try {
|
|
5343
|
+
fetchPublic(sidecarPath);
|
|
5344
|
+
} catch {}
|
|
5345
|
+
}
|
|
5346
|
+
const previous = readHealthRecordAt(sidecarPath, gated ? publicRef(branch) : `origin/${branch}`);
|
|
5347
|
+
const identity = {
|
|
5348
|
+
machine: currentMachine(),
|
|
5349
|
+
root,
|
|
5350
|
+
peer: config.peer === DEFAULT_PEER ? undefined : config.peer,
|
|
5351
|
+
inbox: expandInbox(config, sidecarPath),
|
|
5352
|
+
version: packageVersion()
|
|
5353
|
+
};
|
|
5354
|
+
const record = nextHealthRecord(previous, identity, outcome, nowIso());
|
|
5355
|
+
if (!shouldPublishHealth(previous, record))
|
|
5356
|
+
return;
|
|
5357
|
+
if (gated)
|
|
5358
|
+
assertPublishDestination(sidecarPath);
|
|
5359
|
+
publishHealthRecord(sidecarPath, branch, record);
|
|
5360
|
+
logSidecarEvent("health", {
|
|
5361
|
+
branch,
|
|
5362
|
+
status: record.status,
|
|
5363
|
+
stage: record.stage,
|
|
5364
|
+
consecutiveFailures: record.consecutiveFailures
|
|
5365
|
+
});
|
|
5366
|
+
}
|
|
4530
5367
|
function publishHealthRecord(sidecarPath, branch, record) {
|
|
4531
5368
|
const blob = git(sidecarPath, ["hash-object", "-w", "--stdin"], {
|
|
4532
5369
|
input: serializeHealthRecord(record)
|
|
@@ -4535,17 +5372,8 @@ function publishHealthRecord(sidecarPath, branch, record) {
|
|
|
4535
5372
|
input: `100644 blob ${blob} ${HEALTH_FILE}
|
|
4536
5373
|
`
|
|
4537
5374
|
}).stdout.trim();
|
|
4538
|
-
const commit = git(sidecarPath, [
|
|
4539
|
-
|
|
4540
|
-
`user.name=${currentUser()}`,
|
|
4541
|
-
"-c",
|
|
4542
|
-
`user.email=${slug(currentUser())}@${slug(currentHost())}.local`,
|
|
4543
|
-
"commit-tree",
|
|
4544
|
-
tree,
|
|
4545
|
-
"-m",
|
|
4546
|
-
`health: ${record.status} — ${record.machine}`
|
|
4547
|
-
]).stdout.trim();
|
|
4548
|
-
git(sidecarPath, ["push", "--force", "origin", `${commit}:refs/heads/${branch}`]);
|
|
5375
|
+
const commit = git(sidecarPath, ["commit-tree", tree, "-m", `health: ${record.status} — ${record.machine}`]).stdout.trim();
|
|
5376
|
+
git(sidecarPath, ["push", "--no-follow-tags", "--force", "origin", `${commit}:refs/heads/${branch}`]);
|
|
4549
5377
|
}
|
|
4550
5378
|
function readHealthRecordAt(sidecarPath, ref) {
|
|
4551
5379
|
const result = git(sidecarPath, ["show", `${ref}:${HEALTH_FILE}`], { check: false });
|
|
@@ -4568,13 +5396,12 @@ function readFleetHealth(sidecarPath) {
|
|
|
4568
5396
|
return entries.sort((left, right) => rank[left.state] - rank[right.state] || left.record.machine.localeCompare(right.record.machine) || left.branch.localeCompare(right.branch));
|
|
4569
5397
|
}
|
|
4570
5398
|
function mergeInboxBranches(sidecarPath, config, options) {
|
|
4571
|
-
ensureClean(sidecarPath);
|
|
4572
5399
|
ensureCommitIdentity(sidecarPath);
|
|
4573
5400
|
if (options.remote)
|
|
4574
5401
|
fetch(sidecarPath, false);
|
|
4575
|
-
if (!hasPendingInboxWork(sidecarPath, config)) {
|
|
5402
|
+
if (!hasPendingInboxWork(sidecarPath, config) && mainContainsRemote(sidecarPath, config)) {
|
|
4576
5403
|
if (options.push && !mainMatchesRemote(sidecarPath, config)) {
|
|
4577
|
-
const push = git(sidecarPath, ["push", "origin", `refs/heads/${config.branch}:refs/heads/${config.branch}`], {
|
|
5404
|
+
const push = git(sidecarPath, ["push", "--no-follow-tags", "origin", `refs/heads/${config.branch}:refs/heads/${config.branch}`], {
|
|
4578
5405
|
check: false
|
|
4579
5406
|
});
|
|
4580
5407
|
if (push.status === 0) {
|
|
@@ -4588,23 +5415,28 @@ function mergeInboxBranches(sidecarPath, config, options) {
|
|
|
4588
5415
|
}
|
|
4589
5416
|
}
|
|
4590
5417
|
if (!hasAnyCommit(sidecarPath)) {
|
|
5418
|
+
ensureClean(sidecarPath);
|
|
4591
5419
|
return mergeInboxBranchesAt(sidecarPath, config, options);
|
|
4592
5420
|
}
|
|
4593
5421
|
if (git(sidecarPath, ["branch", "--show-current"]).stdout.trim() === config.branch) {
|
|
5422
|
+
ensureClean(sidecarPath);
|
|
4594
5423
|
ensureInboxBranch(sidecarPath, config, expandInbox(config, sidecarPath));
|
|
4595
5424
|
}
|
|
4596
|
-
|
|
4597
|
-
|
|
5425
|
+
return withScratchWorktree(sidecarPath, config, "HEAD", (worktree) => mergeInboxBranchesAt(worktree, config, options));
|
|
5426
|
+
}
|
|
5427
|
+
function withScratchWorktree(sidecarPath, config, start, fn) {
|
|
5428
|
+
const scratch = path9.join(os5.tmpdir(), `sidecar-merge-${crypto5.createHash("sha1").update(sidecarPath).digest("hex").slice(0, 12)}`);
|
|
5429
|
+
const worktree = path9.join(scratch, "checkout");
|
|
4598
5430
|
git(sidecarPath, ["worktree", "remove", "--force", worktree], { check: false });
|
|
4599
|
-
|
|
5431
|
+
fs9.rmSync(scratch, { recursive: true, force: true });
|
|
4600
5432
|
git(sidecarPath, ["worktree", "prune", "--expire", "now"], { check: false });
|
|
4601
5433
|
try {
|
|
4602
|
-
git(sidecarPath, ["worktree", "add", "--detach", worktree]);
|
|
5434
|
+
git(sidecarPath, ["worktree", "add", "--detach", worktree, start]);
|
|
4603
5435
|
ensureRedactionFilter(worktree, config.redaction, config);
|
|
4604
|
-
return
|
|
5436
|
+
return fn(worktree);
|
|
4605
5437
|
} finally {
|
|
4606
5438
|
git(sidecarPath, ["worktree", "remove", "--force", worktree], { check: false });
|
|
4607
|
-
|
|
5439
|
+
fs9.rmSync(scratch, { recursive: true, force: true });
|
|
4608
5440
|
}
|
|
4609
5441
|
}
|
|
4610
5442
|
function mainMatchesRemote(repo, config) {
|
|
@@ -4615,6 +5447,11 @@ function mainMatchesRemote(repo, config) {
|
|
|
4615
5447
|
const remote = refs.get(remoteRef);
|
|
4616
5448
|
return local !== undefined && local === remote;
|
|
4617
5449
|
}
|
|
5450
|
+
function mainContainsRemote(repo, config) {
|
|
5451
|
+
if (!remoteRefExists(repo, config.branch) || !branchExists(repo, config.branch))
|
|
5452
|
+
return true;
|
|
5453
|
+
return isAncestor(repo, `origin/${config.branch}`, `refs/heads/${config.branch}`);
|
|
5454
|
+
}
|
|
4618
5455
|
function hasPendingInboxWork(repo, config) {
|
|
4619
5456
|
const main = `refs/heads/${config.branch}`;
|
|
4620
5457
|
return pendingInboxBranches(repo, config).some((branch) => !isAncestor(repo, branch, main));
|
|
@@ -4638,7 +5475,7 @@ function mergeInboxBranchesAt(sidecarPath, config, options) {
|
|
|
4638
5475
|
merged.push(remoteBranch);
|
|
4639
5476
|
}
|
|
4640
5477
|
if (options.push) {
|
|
4641
|
-
const push = git(sidecarPath, ["push", "-u", "origin", `HEAD:refs/heads/${config.branch}`], { check: false });
|
|
5478
|
+
const push = git(sidecarPath, ["push", "--no-follow-tags", "-u", "origin", `HEAD:refs/heads/${config.branch}`], { check: false });
|
|
4642
5479
|
if (push.status !== 0) {
|
|
4643
5480
|
if (attempt >= maxAttempts) {
|
|
4644
5481
|
throw new SidecarError(push.stderr.trim() || `could not push ${config.branch}`);
|
|
@@ -4709,10 +5546,10 @@ function familySidecarCheckout(root, config) {
|
|
|
4709
5546
|
} catch {
|
|
4710
5547
|
return;
|
|
4711
5548
|
}
|
|
4712
|
-
if (primaryConfig
|
|
5549
|
+
if (!sharesFamilyCheckout(primaryConfig, config))
|
|
4713
5550
|
return;
|
|
4714
5551
|
const primaryPath = resolveSidecarPath(primary, primaryConfig);
|
|
4715
|
-
if (
|
|
5552
|
+
if (path9.resolve(primaryPath) === path9.resolve(primary))
|
|
4716
5553
|
return;
|
|
4717
5554
|
if (!hasGitMetadata(primaryPath))
|
|
4718
5555
|
cloneOrUpdate(primary, primaryConfig, true);
|
|
@@ -4732,7 +5569,7 @@ function checkoutIsUnlinkedFromFamily(root, config, sidecarPath) {
|
|
|
4732
5569
|
if (isStandalone(config))
|
|
4733
5570
|
return false;
|
|
4734
5571
|
try {
|
|
4735
|
-
if (!
|
|
5572
|
+
if (!fs9.statSync(path9.join(sidecarPath, ".git")).isDirectory())
|
|
4736
5573
|
return false;
|
|
4737
5574
|
} catch {
|
|
4738
5575
|
return false;
|
|
@@ -4741,21 +5578,34 @@ function checkoutIsUnlinkedFromFamily(root, config, sidecarPath) {
|
|
|
4741
5578
|
if (!primary)
|
|
4742
5579
|
return false;
|
|
4743
5580
|
try {
|
|
4744
|
-
|
|
4745
|
-
return primaryConfig.remote === config.remote;
|
|
5581
|
+
return sharesFamilyCheckout(readConfig(peerConfigPath(primary, config.peer)), config);
|
|
4746
5582
|
} catch {
|
|
4747
5583
|
return false;
|
|
4748
5584
|
}
|
|
4749
5585
|
}
|
|
5586
|
+
function sharesFamilyCheckout(primaryConfig, config) {
|
|
5587
|
+
return primaryConfig.remote === config.remote && reviewRequired(primaryConfig) === reviewRequired(config);
|
|
5588
|
+
}
|
|
5589
|
+
function bindReviewPolicy(sidecarPath, config) {
|
|
5590
|
+
const bound = git(sidecarPath, ["config", "--local", "--get", REVIEW_POLICY_KEY], { check: false }).stdout.trim();
|
|
5591
|
+
if (reviewRequired(config)) {
|
|
5592
|
+
if (bound !== "required")
|
|
5593
|
+
git(sidecarPath, ["config", "--local", REVIEW_POLICY_KEY, "required"]);
|
|
5594
|
+
return;
|
|
5595
|
+
}
|
|
5596
|
+
if (bound === "required") {
|
|
5597
|
+
throw new SidecarError(`${sidecarPath} holds work from a peer that requires review, and this peer would publish it unreviewed; set review = "required" in this repo's ${peerFileName(config.peer)} too — or, to publish that work without review, \`git -C ${sidecarPath} config --unset ${REVIEW_POLICY_KEY}\``);
|
|
5598
|
+
}
|
|
5599
|
+
}
|
|
4750
5600
|
function cloneOrUpdate(root, config, bootstrapMain, options) {
|
|
4751
5601
|
const sidecarPath = resolveSidecarPath(root, config);
|
|
4752
|
-
if (
|
|
4753
|
-
if (
|
|
5602
|
+
if (fs9.existsSync(sidecarPath) && !hasGitMetadata(sidecarPath)) {
|
|
5603
|
+
if (fs9.readdirSync(sidecarPath).length) {
|
|
4754
5604
|
throw new SidecarError(`${sidecarPath} exists and is not an empty Git repo`);
|
|
4755
5605
|
}
|
|
4756
|
-
|
|
5606
|
+
fs9.rmdirSync(sidecarPath);
|
|
4757
5607
|
}
|
|
4758
|
-
if (!
|
|
5608
|
+
if (!fs9.existsSync(sidecarPath)) {
|
|
4759
5609
|
const primaryPath = familySidecarCheckout(root, config);
|
|
4760
5610
|
if (primaryPath)
|
|
4761
5611
|
git(primaryPath, ["worktree", "add", "--detach", sidecarPath]);
|
|
@@ -4776,7 +5626,7 @@ function cloneOrUpdate(root, config, bootstrapMain, options) {
|
|
|
4776
5626
|
throw new SidecarError(`${sidecarPath} is not usable as a sidecar checkout`);
|
|
4777
5627
|
}
|
|
4778
5628
|
if (options?.checkoutId) {
|
|
4779
|
-
|
|
5629
|
+
fs9.writeFileSync(path9.join(gitDir(sidecarPath), "sidecar-id"), `${options.checkoutId}
|
|
4780
5630
|
`, {
|
|
4781
5631
|
encoding: "utf8",
|
|
4782
5632
|
mode: 384
|
|
@@ -4784,6 +5634,7 @@ function cloneOrUpdate(root, config, bootstrapMain, options) {
|
|
|
4784
5634
|
}
|
|
4785
5635
|
ensureCommitIdentity(sidecarPath);
|
|
4786
5636
|
ensureRedactionFilter(sidecarPath, config.redaction, config);
|
|
5637
|
+
bindReviewPolicy(sidecarPath, config);
|
|
4787
5638
|
if (bootstrapMain)
|
|
4788
5639
|
bootstrapMainBranch(sidecarPath, config);
|
|
4789
5640
|
const inbox = expandInbox(config, sidecarPath);
|
|
@@ -4793,6 +5644,10 @@ function cloneOrUpdate(root, config, bootstrapMain, options) {
|
|
|
4793
5644
|
function bootstrapMainBranch(repo, config) {
|
|
4794
5645
|
if (remoteRefExists(repo, config.branch))
|
|
4795
5646
|
return;
|
|
5647
|
+
if (reviewRequired(config)) {
|
|
5648
|
+
withPublicGit(repo, () => bootstrapReviewedMain(repo, config));
|
|
5649
|
+
return;
|
|
5650
|
+
}
|
|
4796
5651
|
if (hasAnyCommit(repo)) {
|
|
4797
5652
|
const current = git(repo, ["branch", "--show-current"]).stdout.trim();
|
|
4798
5653
|
if (current !== config.branch) {
|
|
@@ -4811,7 +5666,7 @@ function bootstrapMainBranch(repo, config) {
|
|
|
4811
5666
|
pushBranch(repo, config.branch);
|
|
4812
5667
|
return;
|
|
4813
5668
|
}
|
|
4814
|
-
|
|
5669
|
+
fs9.writeFileSync(path9.join(repo, "README.md"), `# Sidecar
|
|
4815
5670
|
|
|
4816
5671
|
Scratch space for a code repository: plans, notes, and agent context.
|
|
4817
5672
|
This is a plain git repo you own — read it, edit it, clone it anywhere.
|
|
@@ -4821,6 +5676,21 @@ Kept in sync by [sidecar](https://github.com/endograph/sidecar).
|
|
|
4821
5676
|
git(repo, ["commit", "-m", "Initialize sidecar"]);
|
|
4822
5677
|
pushBranch(repo, config.branch);
|
|
4823
5678
|
}
|
|
5679
|
+
function bootstrapReviewedMain(repo, config) {
|
|
5680
|
+
assertPublishDestination(repo);
|
|
5681
|
+
const root = git(repo, ["commit-tree", emptyTree(repo), "-m", "Initialize sidecar"]).stdout.trim();
|
|
5682
|
+
git(repo, ["push", "--no-follow-tags", "origin", `${root}:refs/heads/${config.branch}`]);
|
|
5683
|
+
fetchPublic(repo);
|
|
5684
|
+
console.log(`started ${config.branch} on the remote empty; this checkout's content is published through review`);
|
|
5685
|
+
const ref = `refs/heads/${config.branch}`;
|
|
5686
|
+
if (!hasAnyCommit(repo)) {
|
|
5687
|
+
git(repo, ["update-ref", ref, root]);
|
|
5688
|
+
return;
|
|
5689
|
+
}
|
|
5690
|
+
const local = git(repo, ["rev-parse", "--verify", "--quiet", ref], { check: false }).stdout.trim() || git(repo, ["rev-parse", "HEAD"]).stdout.trim();
|
|
5691
|
+
const link = git(repo, ["commit-tree", treeOf(repo, local), "-p", local, "-p", root, "-m", "sidecar: link the remote's initial main"]).stdout.trim();
|
|
5692
|
+
git(repo, ["update-ref", ref, link]);
|
|
5693
|
+
}
|
|
4824
5694
|
function ensureMainBranch(repo, config) {
|
|
4825
5695
|
if (branchExists(repo, config.branch)) {
|
|
4826
5696
|
git(repo, ["switch", config.branch]);
|
|
@@ -4842,6 +5712,16 @@ function ensureMainBranch(repo, config) {
|
|
|
4842
5712
|
git(repo, ["merge", "--ff-only", remoteBranch]);
|
|
4843
5713
|
return;
|
|
4844
5714
|
}
|
|
5715
|
+
if (reviewRequired(config)) {
|
|
5716
|
+
if (git(repo, ["merge-base", "HEAD", remoteBranch], { check: false }).status !== 0) {
|
|
5717
|
+
const link = git(repo, ["commit-tree", treeOf(repo, "HEAD"), "-p", "HEAD", "-p", remoteBranch, "-m", `sidecar: link the remote's new ${config.branch}`]).stdout.trim();
|
|
5718
|
+
git(repo, ["merge", "--ff-only", link]);
|
|
5719
|
+
console.log(`${remoteBranch} was started over; local ${config.branch} is joined to it and reviewed from there`);
|
|
5720
|
+
return;
|
|
5721
|
+
}
|
|
5722
|
+
mergeInboxBranch(repo, config, remoteBranch, { forkFiles: true });
|
|
5723
|
+
return;
|
|
5724
|
+
}
|
|
4845
5725
|
const tip = git(repo, ["rev-parse", "--short", "HEAD"]).stdout.trim();
|
|
4846
5726
|
const discarded = `refs/sidecar-discarded/${config.branch}/${utcTimestamp()}-${tip}`;
|
|
4847
5727
|
git(repo, ["update-ref", discarded, "HEAD"], { check: false });
|
|
@@ -4889,29 +5769,29 @@ function ensureSidecarCheckout(root, config) {
|
|
|
4889
5769
|
return requireSidecarCheckout(root, config);
|
|
4890
5770
|
}
|
|
4891
5771
|
function snapshot(repo, mainRoot, inbox, message = "sidecar snapshot", redactionMode = DEFAULT_REDACTION_MODE, policy) {
|
|
4892
|
-
if (
|
|
5772
|
+
if (fs9.existsSync(path9.join(gitDir(repo), "MERGE_HEAD"))) {
|
|
4893
5773
|
throw new SidecarError("cannot snapshot an unfinished merge; resolve or abort it before syncing");
|
|
4894
5774
|
}
|
|
4895
5775
|
const rewired = ensureRedactionFilter(repo, redactionMode, policy);
|
|
4896
|
-
const revisionPath =
|
|
5776
|
+
const revisionPath = path9.join(gitDir(repo), "sidecar-redaction-revision");
|
|
4897
5777
|
const revision = `${REDACTION_FILTER_REVISION}:${redactionMode}:${rulesFingerprint(policy?.rules)}`;
|
|
4898
5778
|
let appliedRevision = "";
|
|
4899
5779
|
try {
|
|
4900
|
-
appliedRevision =
|
|
5780
|
+
appliedRevision = fs9.readFileSync(revisionPath, "utf8");
|
|
4901
5781
|
} catch {}
|
|
4902
5782
|
git(repo, ["add", "-A"]);
|
|
4903
5783
|
if ((rewired || appliedRevision !== revision) && hasAnyCommit(repo)) {
|
|
4904
5784
|
git(repo, ["add", "--renormalize", "."]);
|
|
4905
5785
|
}
|
|
4906
5786
|
if (git(repo, ["diff", "--cached", "--quiet"], { check: false }).status === 0) {
|
|
4907
|
-
|
|
5787
|
+
fs9.writeFileSync(revisionPath, revision, "utf8");
|
|
4908
5788
|
console.log("no sidecar changes to snapshot");
|
|
4909
5789
|
return false;
|
|
4910
5790
|
}
|
|
4911
5791
|
const staged = git(repo, ["diff", "--cached", "--name-only", "-z", "--diff-filter=d"]).stdout.split("\x00").filter(Boolean);
|
|
4912
|
-
const source =
|
|
5792
|
+
const source = currentMachine();
|
|
4913
5793
|
const body = [message, "", `source: ${source}`];
|
|
4914
|
-
if (
|
|
5794
|
+
if (path9.resolve(repo) !== path9.resolve(mainRoot)) {
|
|
4915
5795
|
const mainHead = git(mainRoot, ["rev-parse", "--short", "HEAD"], { check: false });
|
|
4916
5796
|
body.push(`main-head: ${mainHead.status === 0 ? mainHead.stdout.trim() : "unborn"}`);
|
|
4917
5797
|
}
|
|
@@ -4919,7 +5799,7 @@ function snapshot(repo, mainRoot, inbox, message = "sidecar snapshot", redaction
|
|
|
4919
5799
|
body.push(...writtenTrailers(repo, staged));
|
|
4920
5800
|
git(repo, ["commit", "-m", body.join(`
|
|
4921
5801
|
`)]);
|
|
4922
|
-
|
|
5802
|
+
fs9.writeFileSync(revisionPath, revision, "utf8");
|
|
4923
5803
|
console.log(`committed sidecar snapshot to ${paint("brand", inbox)}`);
|
|
4924
5804
|
reportRedactions(repo, staged, redactionMode, policy?.rules);
|
|
4925
5805
|
return true;
|
|
@@ -4933,7 +5813,7 @@ function writtenTrailers(repo, staged) {
|
|
|
4933
5813
|
`))
|
|
4934
5814
|
continue;
|
|
4935
5815
|
try {
|
|
4936
|
-
const seconds = Math.floor(
|
|
5816
|
+
const seconds = Math.floor(fs9.lstatSync(path9.join(repo, relPath)).mtimeMs / 1000);
|
|
4937
5817
|
if (seconds > 0)
|
|
4938
5818
|
trailers.push(`${WRITTEN_TRAILER} ${seconds} ${relPath}`);
|
|
4939
5819
|
} catch {}
|
|
@@ -4944,7 +5824,7 @@ function reportRedactions(repo, staged, mode, rules) {
|
|
|
4944
5824
|
let files = 0;
|
|
4945
5825
|
let items = 0;
|
|
4946
5826
|
for (const relPath of staged) {
|
|
4947
|
-
const delta = fileRedactionDelta(
|
|
5827
|
+
const delta = fileRedactionDelta(path9.join(repo, relPath), mode, { rules, relativePath: relPath });
|
|
4948
5828
|
if (!delta)
|
|
4949
5829
|
continue;
|
|
4950
5830
|
files += 1;
|
|
@@ -4958,7 +5838,7 @@ function reportRedactions(repo, staged, mode, rules) {
|
|
|
4958
5838
|
function fileRedactionDelta(filePath, mode, policy) {
|
|
4959
5839
|
let data;
|
|
4960
5840
|
try {
|
|
4961
|
-
data =
|
|
5841
|
+
data = fs9.readFileSync(filePath);
|
|
4962
5842
|
} catch {
|
|
4963
5843
|
return;
|
|
4964
5844
|
}
|
|
@@ -4973,14 +5853,14 @@ function fileRedactionDelta(filePath, mode, policy) {
|
|
|
4973
5853
|
return { text, redacted, items };
|
|
4974
5854
|
}
|
|
4975
5855
|
function checkoutRedactionPolicy(repo) {
|
|
4976
|
-
const policyPath =
|
|
5856
|
+
const policyPath = path9.join(gitDir(repo), REDACTION_POLICY_FILE);
|
|
4977
5857
|
let bound;
|
|
4978
5858
|
try {
|
|
4979
|
-
bound = JSON.parse(
|
|
5859
|
+
bound = JSON.parse(fs9.readFileSync(policyPath, "utf8"));
|
|
4980
5860
|
} catch {
|
|
4981
5861
|
throw new SidecarError("missing or invalid checkout redaction policy; run `sidecar sync`");
|
|
4982
5862
|
}
|
|
4983
|
-
if (!bound || typeof bound !== "object" || typeof bound.fingerprint !== "string" || bound.rulesPath !== undefined && (typeof bound.rulesPath !== "string" || !
|
|
5863
|
+
if (!bound || typeof bound !== "object" || typeof bound.fingerprint !== "string" || bound.rulesPath !== undefined && (typeof bound.rulesPath !== "string" || !path9.isAbsolute(bound.rulesPath))) {
|
|
4984
5864
|
throw new SidecarError("invalid checkout redaction policy; run `sidecar sync`");
|
|
4985
5865
|
}
|
|
4986
5866
|
const mode = redactionModeConfigValue(bound.mode, "checkout redaction mode");
|
|
@@ -4995,27 +5875,28 @@ function ensureRedactionFilter(repo, mode = DEFAULT_REDACTION_MODE, policy) {
|
|
|
4995
5875
|
if (policy?.rules?.length && !policy.rulesPath) {
|
|
4996
5876
|
throw new SidecarError("path redaction rules require an explicit host rules file");
|
|
4997
5877
|
}
|
|
4998
|
-
const rulesPath = policy?.rulesPath ?
|
|
5878
|
+
const rulesPath = policy?.rulesPath ? path9.resolve(policy.rulesPath) : undefined;
|
|
4999
5879
|
if (rulesPath && rulesFingerprint(readRules(rulesPath)) !== fingerprint) {
|
|
5000
5880
|
throw new SidecarError("sidecar rules changed during this operation; run `sidecar sync` to apply them");
|
|
5001
5881
|
}
|
|
5002
|
-
const policyPath =
|
|
5882
|
+
const policyPath = path9.join(gitDir(repo), REDACTION_POLICY_FILE);
|
|
5003
5883
|
const bound = JSON.stringify({ mode, rulesPath, fingerprint });
|
|
5004
5884
|
let previous = "";
|
|
5005
5885
|
try {
|
|
5006
|
-
previous =
|
|
5886
|
+
previous = fs9.readFileSync(policyPath, "utf8");
|
|
5007
5887
|
} catch {}
|
|
5008
5888
|
const policyChanged = previous !== bound;
|
|
5009
5889
|
if (policyChanged)
|
|
5010
|
-
|
|
5011
|
-
const command = `${filterCommandQuote(process.execPath)} ${filterCommandQuote(
|
|
5890
|
+
fs9.writeFileSync(policyPath, bound, { encoding: "utf8", mode: 384 });
|
|
5891
|
+
const command = `${filterCommandQuote(process.execPath)} ${filterCommandQuote(sidecarCliPath())} redact --checkout-policy --path %f`;
|
|
5012
5892
|
const wanted = [
|
|
5013
5893
|
[`filter.${REDACTION_FILTER_NAME}.clean`, command],
|
|
5014
5894
|
[`filter.${REDACTION_FILTER_NAME}.revision`, REDACTION_FILTER_REVISION],
|
|
5015
5895
|
[`filter.${REDACTION_FILTER_NAME}.smudge`, "cat"],
|
|
5016
5896
|
[`filter.${REDACTION_FILTER_NAME}.required`, "true"]
|
|
5017
5897
|
];
|
|
5018
|
-
const
|
|
5898
|
+
const processKey = `filter.${REDACTION_FILTER_NAME}.process`;
|
|
5899
|
+
const attributesPath = path9.join(gitCommonDir(repo), "info", "attributes");
|
|
5019
5900
|
const line = `* filter=${REDACTION_FILTER_NAME}`;
|
|
5020
5901
|
const configured = git(repo, ["config", "--get-regexp", `^filter\\.${REDACTION_FILTER_NAME}\\.`], {
|
|
5021
5902
|
check: false
|
|
@@ -5025,10 +5906,10 @@ function ensureRedactionFilter(repo, mode = DEFAULT_REDACTION_MODE, policy) {
|
|
|
5025
5906
|
const space = entry.indexOf(" ");
|
|
5026
5907
|
return [entry.slice(0, space), entry.slice(space + 1)];
|
|
5027
5908
|
}));
|
|
5028
|
-
const configOk = wanted.every(([key, value]) => current.get(key) === value);
|
|
5909
|
+
const configOk = wanted.every(([key, value]) => current.get(key) === value) && !current.has(processKey);
|
|
5029
5910
|
let attributes = "";
|
|
5030
5911
|
try {
|
|
5031
|
-
attributes =
|
|
5912
|
+
attributes = fs9.readFileSync(attributesPath, "utf8");
|
|
5032
5913
|
} catch {}
|
|
5033
5914
|
const attributesOk = attributes.split(/\r?\n/).includes(line);
|
|
5034
5915
|
if (configOk && attributesOk)
|
|
@@ -5036,9 +5917,10 @@ function ensureRedactionFilter(repo, mode = DEFAULT_REDACTION_MODE, policy) {
|
|
|
5036
5917
|
for (const [key, value] of wanted) {
|
|
5037
5918
|
git(repo, ["config", key, value]);
|
|
5038
5919
|
}
|
|
5920
|
+
git(repo, ["config", "--unset-all", processKey], { check: false });
|
|
5039
5921
|
if (!attributesOk) {
|
|
5040
|
-
|
|
5041
|
-
|
|
5922
|
+
fs9.mkdirSync(path9.dirname(attributesPath), { recursive: true });
|
|
5923
|
+
fs9.appendFileSync(attributesPath, attributes && !attributes.endsWith(`
|
|
5042
5924
|
`) ? `
|
|
5043
5925
|
${line}
|
|
5044
5926
|
` : `${line}
|
|
@@ -5048,13 +5930,13 @@ ${line}
|
|
|
5048
5930
|
}
|
|
5049
5931
|
function removeRedactionFilter(repo) {
|
|
5050
5932
|
git(repo, ["config", "--remove-section", `filter.${REDACTION_FILTER_NAME}`], { check: false });
|
|
5051
|
-
|
|
5052
|
-
|
|
5053
|
-
const attributesPath =
|
|
5933
|
+
fs9.rmSync(path9.join(gitDir(repo), "sidecar-redaction-revision"), { force: true });
|
|
5934
|
+
fs9.rmSync(path9.join(gitDir(repo), REDACTION_POLICY_FILE), { force: true });
|
|
5935
|
+
const attributesPath = path9.join(gitCommonDir(repo), "info", "attributes");
|
|
5054
5936
|
const line = `* filter=${REDACTION_FILTER_NAME}`;
|
|
5055
5937
|
let contents;
|
|
5056
5938
|
try {
|
|
5057
|
-
contents =
|
|
5939
|
+
contents = fs9.readFileSync(attributesPath, "utf8");
|
|
5058
5940
|
} catch {
|
|
5059
5941
|
return;
|
|
5060
5942
|
}
|
|
@@ -5063,17 +5945,13 @@ function removeRedactionFilter(repo) {
|
|
|
5063
5945
|
if (kept.length === lines.length)
|
|
5064
5946
|
return;
|
|
5065
5947
|
if (kept.every((entry) => !entry.trim())) {
|
|
5066
|
-
|
|
5948
|
+
fs9.rmSync(attributesPath, { force: true });
|
|
5067
5949
|
} else {
|
|
5068
|
-
|
|
5950
|
+
fs9.writeFileSync(attributesPath, `${kept.join(`
|
|
5069
5951
|
`).replace(/\s+$/g, "")}
|
|
5070
5952
|
`, "utf8");
|
|
5071
5953
|
}
|
|
5072
5954
|
}
|
|
5073
|
-
function redactCliPath() {
|
|
5074
|
-
const self = fileURLToPath2(import.meta.url);
|
|
5075
|
-
return self.endsWith(".ts") ? path8.join(path8.dirname(self), "..", "dist", "cli.js") : self;
|
|
5076
|
-
}
|
|
5077
5955
|
function filterCommandQuote(value) {
|
|
5078
5956
|
return `"${value.replace(/([\\"$`])/g, "\\$1")}"`;
|
|
5079
5957
|
}
|
|
@@ -5101,7 +5979,7 @@ function syncBranchBeforePush(repo, branch, config) {
|
|
|
5101
5979
|
if (isAncestor(repo, remoteBranch, "HEAD"))
|
|
5102
5980
|
return;
|
|
5103
5981
|
if (isDirty(repo)) {
|
|
5104
|
-
throw new SidecarError(`${remoteBranch} has commits not in local ${branch}, and the sidecar checkout has uncommitted changes
|
|
5982
|
+
throw new SidecarError(`${remoteBranch} has commits not in local ${branch}, and the sidecar checkout has uncommitted changes`, { reason: "dirty-checkout" });
|
|
5105
5983
|
}
|
|
5106
5984
|
if (isAncestor(repo, "HEAD", remoteBranch)) {
|
|
5107
5985
|
git(repo, ["merge", "--ff-only", remoteBranch]);
|
|
@@ -5112,15 +5990,22 @@ function syncBranchBeforePush(repo, branch, config) {
|
|
|
5112
5990
|
function refreshInboxFromMain(repo, config, inbox) {
|
|
5113
5991
|
if (!branchExists(repo, inbox) || !branchExists(repo, config.branch))
|
|
5114
5992
|
return;
|
|
5115
|
-
|
|
5993
|
+
if (isDirty(repo)) {
|
|
5994
|
+
logSidecarEvent("settle-deferred", { sidecarPath: repo });
|
|
5995
|
+
return;
|
|
5996
|
+
}
|
|
5116
5997
|
git(repo, ["switch", inbox]);
|
|
5117
5998
|
const result = git(repo, ["merge", "--ff-only", config.branch], { check: false });
|
|
5118
|
-
if (result.status
|
|
5119
|
-
|
|
5999
|
+
if (result.status === 0)
|
|
6000
|
+
return;
|
|
6001
|
+
if (isDirty(repo)) {
|
|
6002
|
+
logSidecarEvent("settle-deferred", { sidecarPath: repo });
|
|
6003
|
+
return;
|
|
5120
6004
|
}
|
|
6005
|
+
throw new SidecarError(result.stderr.trim() || `could not fast-forward ${inbox} to ${config.branch}`);
|
|
5121
6006
|
}
|
|
5122
6007
|
function pushBranch(repo, branch) {
|
|
5123
|
-
git(repo, ["push", "-u", "origin", `HEAD:refs/heads/${branch}`]);
|
|
6008
|
+
git(repo, ["push", "--no-follow-tags", "-u", "origin", `HEAD:refs/heads/${branch}`]);
|
|
5124
6009
|
console.log(`pushed ${paint("brand", branch)}`);
|
|
5125
6010
|
}
|
|
5126
6011
|
function forkConflicts(repo, remoteBranch, selectedPaths) {
|
|
@@ -5163,14 +6048,14 @@ function forkConflicts(repo, remoteBranch, selectedPaths) {
|
|
|
5163
6048
|
continue;
|
|
5164
6049
|
const { oid, mode } = stages[stage];
|
|
5165
6050
|
const outPath = forkPath(conflictPath, label, oid);
|
|
5166
|
-
const fullOut =
|
|
5167
|
-
|
|
5168
|
-
|
|
6051
|
+
const fullOut = path9.join(repo, outPath);
|
|
6052
|
+
fs9.mkdirSync(path9.dirname(fullOut), { recursive: true });
|
|
6053
|
+
fs9.rmSync(fullOut, { force: true });
|
|
5169
6054
|
if (mode === "120000")
|
|
5170
|
-
|
|
6055
|
+
fs9.symlinkSync(blob.toString("utf8"), fullOut);
|
|
5171
6056
|
else {
|
|
5172
|
-
|
|
5173
|
-
|
|
6057
|
+
fs9.writeFileSync(fullOut, blob);
|
|
6058
|
+
fs9.chmodSync(fullOut, mode === "100755" ? 493 : 420);
|
|
5174
6059
|
}
|
|
5175
6060
|
git(repo, ["add", "--", `:(literal)${outPath}`]);
|
|
5176
6061
|
versions.push({
|
|
@@ -5178,18 +6063,18 @@ function forkConflicts(repo, remoteBranch, selectedPaths) {
|
|
|
5178
6063
|
label,
|
|
5179
6064
|
oid,
|
|
5180
6065
|
path: outPath,
|
|
5181
|
-
sha256:
|
|
6066
|
+
sha256: crypto5.createHash("sha256").update(blob).digest("hex")
|
|
5182
6067
|
});
|
|
5183
6068
|
}
|
|
5184
6069
|
git(repo, ["rm", "-f", "--", `:(literal)${conflictPath}`]);
|
|
5185
6070
|
manifest.paths.push({ path: conflictPath, versions });
|
|
5186
6071
|
}
|
|
5187
|
-
const manifestDir =
|
|
5188
|
-
|
|
5189
|
-
const manifestPath =
|
|
5190
|
-
|
|
6072
|
+
const manifestDir = path9.join(repo, ".sidecar-conflicts");
|
|
6073
|
+
fs9.mkdirSync(manifestDir, { recursive: true });
|
|
6074
|
+
const manifestPath = path9.join(manifestDir, `${timestamp}-${manifestLabel}-fork-files.json`);
|
|
6075
|
+
fs9.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}
|
|
5191
6076
|
`, "utf8");
|
|
5192
|
-
git(repo, ["add", "--", `:(literal)${
|
|
6077
|
+
git(repo, ["add", "--", `:(literal)${path9.relative(repo, manifestPath)}`]);
|
|
5193
6078
|
if (Object.keys(selectConflictPaths(unmergedPaths(repo), selectedPaths)).length) {
|
|
5194
6079
|
throw new SidecarError("fork-files did not clear all unmerged paths");
|
|
5195
6080
|
}
|
|
@@ -5219,7 +6104,7 @@ function resolveLastWriterWins(repo, canonicalBranch, remoteBranch, selectedPath
|
|
|
5219
6104
|
...selections.filter((entry) => entry.winner).map((entry) => entry.filePath)
|
|
5220
6105
|
]);
|
|
5221
6106
|
for (const filePath of present) {
|
|
5222
|
-
for (let parent =
|
|
6107
|
+
for (let parent = path9.posix.dirname(filePath);parent !== "."; parent = path9.posix.dirname(parent)) {
|
|
5223
6108
|
if (present.has(parent))
|
|
5224
6109
|
throw new SidecarError(`last-writer-wins selected incompatible file and directory paths: ${parent}, ${filePath}`);
|
|
5225
6110
|
}
|
|
@@ -5246,12 +6131,12 @@ function resolveLastWriterWins(repo, canonicalBranch, remoteBranch, selectedPath
|
|
|
5246
6131
|
written.push(lwwWrittenTrailer(filePath, write));
|
|
5247
6132
|
}
|
|
5248
6133
|
if (manifest.paths.length) {
|
|
5249
|
-
const manifestDir =
|
|
5250
|
-
|
|
5251
|
-
const manifestPath =
|
|
5252
|
-
|
|
6134
|
+
const manifestDir = path9.join(repo, ".sidecar-conflicts");
|
|
6135
|
+
fs9.mkdirSync(manifestDir, { recursive: true });
|
|
6136
|
+
const manifestPath = path9.join(manifestDir, `${timestamp}-${fileLabel(branch)}-lww.json`);
|
|
6137
|
+
fs9.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}
|
|
5253
6138
|
`, "utf8");
|
|
5254
|
-
git(repo, ["add", "--", `:(literal)${
|
|
6139
|
+
git(repo, ["add", "--", `:(literal)${path9.relative(repo, manifestPath)}`]);
|
|
5255
6140
|
}
|
|
5256
6141
|
if (Object.keys(selectConflictPaths(unmergedPaths(repo), selectedPaths)).length) {
|
|
5257
6142
|
throw new SidecarError("last-writer-wins did not clear all unmerged paths");
|
|
@@ -5279,7 +6164,7 @@ function treeEntry(repo, ref, filePath) {
|
|
|
5279
6164
|
return;
|
|
5280
6165
|
}
|
|
5281
6166
|
function lwwTrailerPrefix(filePath) {
|
|
5282
|
-
return `sidecar-lww-${
|
|
6167
|
+
return `sidecar-lww-${crypto5.createHash("sha256").update(filePath).digest("hex")}:`;
|
|
5283
6168
|
}
|
|
5284
6169
|
function lwwWrittenTrailer(filePath, write) {
|
|
5285
6170
|
return `${lwwTrailerPrefix(filePath)} ${write.time} ${write.source || "-"}`;
|
|
@@ -5326,11 +6211,11 @@ function recordedLwwEvent(body, prefix) {
|
|
|
5326
6211
|
return Number.isInteger(time) && time >= 0 && source ? { time, source: source === "-" ? "" : source } : undefined;
|
|
5327
6212
|
}
|
|
5328
6213
|
function forkPath(conflictPath, label, oid) {
|
|
5329
|
-
const parsed =
|
|
6214
|
+
const parsed = path9.parse(conflictPath);
|
|
5330
6215
|
const shortOid = oid ? oid.slice(0, 7) : "missing";
|
|
5331
6216
|
const safeLabel = fileLabel(label);
|
|
5332
6217
|
const forkName = parsed.ext ? `${parsed.name}.conflict.${safeLabel}.${shortOid}${parsed.ext}` : `${parsed.name}.conflict.${safeLabel}.${shortOid}`;
|
|
5333
|
-
return
|
|
6218
|
+
return path9.join(parsed.dir, forkName);
|
|
5334
6219
|
}
|
|
5335
6220
|
function fileLabel(value) {
|
|
5336
6221
|
return slug(value).replaceAll("/", "-");
|
|
@@ -5389,7 +6274,7 @@ function pendingInboxBranches(repo, config) {
|
|
|
5389
6274
|
});
|
|
5390
6275
|
return [...local, ...remote].sort();
|
|
5391
6276
|
}
|
|
5392
|
-
var
|
|
6277
|
+
var REVIEW_POLICY_KEY = "sidecar.review", WRITTEN_TRAILER = "written:", WRITTEN_TRAILER_LIMIT = 500, REDACTION_FILTER_NAME = "sidecar-redact", REDACTION_FILTER_REVISION = "2", REDACTION_POLICY_FILE = "sidecar-redaction-policy";
|
|
5393
6278
|
var init_sync = __esm(() => {
|
|
5394
6279
|
init_color();
|
|
5395
6280
|
init_util();
|
|
@@ -5397,13 +6282,14 @@ var init_sync = __esm(() => {
|
|
|
5397
6282
|
init_install();
|
|
5398
6283
|
init_config();
|
|
5399
6284
|
init_state();
|
|
6285
|
+
init_review();
|
|
5400
6286
|
init_rules();
|
|
5401
6287
|
init_health();
|
|
5402
6288
|
init_redaction();
|
|
5403
6289
|
});
|
|
5404
6290
|
|
|
5405
6291
|
// src/ui.ts
|
|
5406
|
-
import
|
|
6292
|
+
import fs10 from "node:fs";
|
|
5407
6293
|
function announcePeer(peer, peers) {
|
|
5408
6294
|
const index = peers.indexOf(peer);
|
|
5409
6295
|
if (index > 0)
|
|
@@ -5465,13 +6351,13 @@ function promptYesNoDefaultNo(question) {
|
|
|
5465
6351
|
return answer === "y" || answer === "yes";
|
|
5466
6352
|
}
|
|
5467
6353
|
function promptLine(prompt) {
|
|
5468
|
-
|
|
5469
|
-
const fd =
|
|
6354
|
+
fs10.writeSync(1, prompt);
|
|
6355
|
+
const fd = fs10.openSync(process.platform === "win32" ? "CONIN$" : "/dev/tty", "r");
|
|
5470
6356
|
try {
|
|
5471
6357
|
const chunks = [];
|
|
5472
6358
|
const buffer = Buffer.alloc(1);
|
|
5473
6359
|
while (true) {
|
|
5474
|
-
const bytesRead =
|
|
6360
|
+
const bytesRead = fs10.readSync(fd, buffer, 0, 1, null);
|
|
5475
6361
|
if (bytesRead === 0)
|
|
5476
6362
|
break;
|
|
5477
6363
|
const char = buffer.toString("utf8", 0, bytesRead);
|
|
@@ -5482,7 +6368,7 @@ function promptLine(prompt) {
|
|
|
5482
6368
|
}
|
|
5483
6369
|
return chunks.join("").trim();
|
|
5484
6370
|
} finally {
|
|
5485
|
-
|
|
6371
|
+
fs10.closeSync(fd);
|
|
5486
6372
|
}
|
|
5487
6373
|
}
|
|
5488
6374
|
var init_ui = __esm(() => {
|
|
@@ -5491,9 +6377,9 @@ var init_ui = __esm(() => {
|
|
|
5491
6377
|
});
|
|
5492
6378
|
|
|
5493
6379
|
// src/cmd-init.ts
|
|
5494
|
-
import
|
|
5495
|
-
import
|
|
5496
|
-
import { spawnSync as
|
|
6380
|
+
import fs11 from "node:fs";
|
|
6381
|
+
import path10 from "node:path";
|
|
6382
|
+
import { spawnSync as spawnSync5 } from "node:child_process";
|
|
5497
6383
|
function cmdDeinit(args) {
|
|
5498
6384
|
const parsed = parseOptions(args, { boolean: new Set(["--yes", "-y"]), value: new Set(["--peer"]) });
|
|
5499
6385
|
if (parsed.positional.length)
|
|
@@ -5515,7 +6401,7 @@ function cmdDeinit(args) {
|
|
|
5515
6401
|
const configPath = peerConfigPath(root, name);
|
|
5516
6402
|
const leftovers = [];
|
|
5517
6403
|
let config;
|
|
5518
|
-
if (
|
|
6404
|
+
if (fs11.existsSync(configPath)) {
|
|
5519
6405
|
try {
|
|
5520
6406
|
config = readConfig(configPath);
|
|
5521
6407
|
} catch {
|
|
@@ -5548,12 +6434,14 @@ function cmdDeinit(args) {
|
|
|
5548
6434
|
removeCheckout(checkoutPath);
|
|
5549
6435
|
const ignoreEntry = ignoreEntryForSidecarPath(root, config.path);
|
|
5550
6436
|
if (ignoreEntry) {
|
|
5551
|
-
removeIgnoreEntry(
|
|
6437
|
+
removeIgnoreEntry(path10.join(root, ".gitignore"), ignoreEntry);
|
|
5552
6438
|
removeZedInclusion(root, ignoreEntry);
|
|
5553
6439
|
}
|
|
5554
6440
|
}
|
|
5555
|
-
|
|
5556
|
-
|
|
6441
|
+
fs11.rmSync(configPath, { force: true });
|
|
6442
|
+
fs11.rmSync(peerRulesPath(root, name), { force: true });
|
|
6443
|
+
fs11.rmSync(peerReviewGuidelinesPath(root, name), { force: true });
|
|
6444
|
+
pruneOrphanedReviews();
|
|
5557
6445
|
unregisterInstance(configPath);
|
|
5558
6446
|
});
|
|
5559
6447
|
console.log(`removed sidecar from ${paint("repo", root)}${name === DEFAULT_PEER ? "" : ` (peer ${paint("brand", name)})`}`);
|
|
@@ -5568,9 +6456,9 @@ function cmdDeinit(args) {
|
|
|
5568
6456
|
function assertDeinitTarget(root, checkoutPath) {
|
|
5569
6457
|
const target = realpathOr(checkoutPath);
|
|
5570
6458
|
const host = realpathOr(root);
|
|
5571
|
-
const hostFromTarget =
|
|
5572
|
-
const parts =
|
|
5573
|
-
const containsHost = !
|
|
6459
|
+
const hostFromTarget = path10.relative(target, host);
|
|
6460
|
+
const parts = path10.relative(host, target).split(path10.sep);
|
|
6461
|
+
const containsHost = !path10.isAbsolute(hostFromTarget) && hostFromTarget.split(path10.sep)[0] !== "..";
|
|
5574
6462
|
if (containsHost || parts.includes(".git") || parts.includes(".jj")) {
|
|
5575
6463
|
throw new SidecarError(`refusing to delete ${checkoutPath}, which contains the host repo or its VCS metadata`);
|
|
5576
6464
|
}
|
|
@@ -5591,19 +6479,19 @@ function releaseStandaloneCheckout(root, config) {
|
|
|
5591
6479
|
}
|
|
5592
6480
|
function removeCheckout(checkoutPath) {
|
|
5593
6481
|
try {
|
|
5594
|
-
if (
|
|
6482
|
+
if (fs11.statSync(path10.join(checkoutPath, ".git")).isFile()) {
|
|
5595
6483
|
git(checkoutPath, ["worktree", "remove", "--force", checkoutPath], { check: false });
|
|
5596
6484
|
}
|
|
5597
6485
|
} catch {}
|
|
5598
|
-
|
|
6486
|
+
fs11.rmSync(checkoutPath, { recursive: true, force: true });
|
|
5599
6487
|
}
|
|
5600
6488
|
function cmdInit(args) {
|
|
5601
6489
|
const parsed = parseOptions(args, {
|
|
5602
6490
|
boolean: new Set(["--no-clone", "--no-bootstrap-main", "--local-install", "--ignored"]),
|
|
5603
|
-
value: new Set(["--path", "--branch", "--inbox", "--redaction", "--resolve", "--debounce", "--interval", "--peer"])
|
|
6491
|
+
value: new Set(["--path", "--branch", "--inbox", "--redaction", "--resolve", "--review", "--review-timeout", "--debounce", "--interval", "--peer"])
|
|
5604
6492
|
});
|
|
5605
6493
|
if (parsed.positional.length > 1) {
|
|
5606
|
-
throw new SidecarError("usage: sidecar init [remote] [--peer name] [--path sidecar] [--branch main] [--inbox template] [--redaction mode] [--resolve fork|lww] [--debounce 10m] [--interval 1h] [--ignored]");
|
|
6494
|
+
throw new SidecarError("usage: sidecar init [remote] [--peer name] [--path sidecar] [--branch main] [--inbox template] [--redaction mode] [--resolve fork|lww] [--review off|required] [--review-timeout 1h] [--debounce 10m] [--interval 1h] [--ignored]");
|
|
5607
6495
|
}
|
|
5608
6496
|
const remote = parsed.positional[0];
|
|
5609
6497
|
const requestedPeer = parsed.values.get("--peer");
|
|
@@ -5643,13 +6531,15 @@ function cmdInit(args) {
|
|
|
5643
6531
|
}
|
|
5644
6532
|
function configurePeer(root, name, remote, parsed) {
|
|
5645
6533
|
const configPath = peerConfigPath(root, name);
|
|
5646
|
-
let reuse = !remote &&
|
|
5647
|
-
|
|
6534
|
+
let reuse = !remote && fs11.existsSync(configPath);
|
|
6535
|
+
let built;
|
|
6536
|
+
if (remote && fs11.existsSync(configPath)) {
|
|
5648
6537
|
const existing = readConfig(configPath);
|
|
5649
|
-
|
|
5650
|
-
reuse =
|
|
6538
|
+
built = buildInitConfig(root, name, remote, parsed, existing);
|
|
6539
|
+
reuse = configText(built) === configText(existing) || !promptOverwriteConfig(configPath, existing.remote, remote);
|
|
5651
6540
|
}
|
|
5652
|
-
const config = reuse ? readConfig(configPath) : buildInitConfig(root, name, remote, parsed);
|
|
6541
|
+
const config = reuse ? readConfig(configPath) : built ?? buildInitConfig(root, name, remote, parsed);
|
|
6542
|
+
notePeerPolicy(config);
|
|
5653
6543
|
if (!reuse) {
|
|
5654
6544
|
config.rulesPath = peerRulesPath(root, name);
|
|
5655
6545
|
config.rules = readRules(config.rulesPath);
|
|
@@ -5658,7 +6548,7 @@ function configurePeer(root, name, remote, parsed) {
|
|
|
5658
6548
|
if (isStandalone(config)) {
|
|
5659
6549
|
throw new SidecarError("--ignored cannot apply to a standalone sidecar: its .sidecar is part of the tree it syncs");
|
|
5660
6550
|
}
|
|
5661
|
-
for (const file of [peerFileName(name), peerRulesFileName(name)]) {
|
|
6551
|
+
for (const file of [peerFileName(name), peerRulesFileName(name), peerReviewGuidelinesFileName(name)]) {
|
|
5662
6552
|
if (isGitTracked(root, file)) {
|
|
5663
6553
|
throw new SidecarError(`${file} is tracked by git; \`git rm --cached ${file}\` before ignoring it`);
|
|
5664
6554
|
}
|
|
@@ -5691,9 +6581,11 @@ function excludePeerFile(root, name) {
|
|
|
5691
6581
|
throw new SidecarError(`--ignored needs a git exclude file, and ${root} has none; ignore ${configFile} in your own VCS config`);
|
|
5692
6582
|
}
|
|
5693
6583
|
const rulesFile = peerRulesFileName(name);
|
|
6584
|
+
const guidelinesFile = peerReviewGuidelinesFileName(name);
|
|
5694
6585
|
ensureIgnoreLine(exclude, `/${configFile}`);
|
|
5695
6586
|
ensureIgnoreLine(exclude, `/${rulesFile}`);
|
|
5696
|
-
|
|
6587
|
+
ensureIgnoreLine(exclude, `/${guidelinesFile}`);
|
|
6588
|
+
console.log(`ignored ${configFile}, ${rulesFile}, and ${guidelinesFile} via .git/info/exclude`);
|
|
5697
6589
|
}
|
|
5698
6590
|
function initRoot(remote, parsed) {
|
|
5699
6591
|
const cwd = process.cwd();
|
|
@@ -5708,9 +6600,9 @@ function initRoot(remote, parsed) {
|
|
|
5708
6600
|
console.log(`initialized ${paint("repo", cwd)} as a Git repository`);
|
|
5709
6601
|
return cwd;
|
|
5710
6602
|
}
|
|
5711
|
-
function buildInitConfig(root, name, remote, parsed) {
|
|
6603
|
+
function buildInitConfig(root, name, remote, parsed, existing) {
|
|
5712
6604
|
const defaultPath = name === DEFAULT_PEER ? DEFAULT_PATH : name;
|
|
5713
|
-
const rawPath = parsed.values.has("--path") ? getValue(parsed, "--path", defaultPath) : promptSidecarPath(root, name, defaultPath);
|
|
6605
|
+
const rawPath = parsed.values.has("--path") ? getValue(parsed, "--path", defaultPath) : existing?.path ?? promptSidecarPath(root, name, defaultPath);
|
|
5714
6606
|
const sidecarPath = pathIsRepoRoot(root, rawPath) ? "." : rawPath;
|
|
5715
6607
|
const standalone = isStandalonePath(sidecarPath);
|
|
5716
6608
|
if (standalone && name !== DEFAULT_PEER) {
|
|
@@ -5721,12 +6613,14 @@ function buildInitConfig(root, name, remote, parsed) {
|
|
|
5721
6613
|
remote: remote ?? (standalone ? standaloneRemote(root) : promptRemote(root)),
|
|
5722
6614
|
version: 1,
|
|
5723
6615
|
path: sidecarPath,
|
|
5724
|
-
branch: getValue(parsed, "--branch", DEFAULT_BRANCH),
|
|
5725
|
-
inbox: getValue(parsed, "--inbox", DEFAULT_INBOX),
|
|
5726
|
-
redaction: parsed.values.has("--redaction") ? redactionModeConfigValue(getValue(parsed, "--redaction", DEFAULT_REDACTION_MODE), "--redaction") : promptRedactionMode(),
|
|
5727
|
-
resolve: parsed.values.has("--resolve") ? resolveModeConfigValue(getValue(parsed, "--resolve", DEFAULT_RESOLVE), "--resolve") : DEFAULT_RESOLVE,
|
|
5728
|
-
debounce: durationConfigValue(parsed.values.get("--debounce"), "--debounce"),
|
|
5729
|
-
interval: durationConfigValue(parsed.values.get("--interval"), "--interval")
|
|
6616
|
+
branch: getValue(parsed, "--branch", existing?.branch ?? DEFAULT_BRANCH),
|
|
6617
|
+
inbox: getValue(parsed, "--inbox", existing?.inbox ?? DEFAULT_INBOX),
|
|
6618
|
+
redaction: parsed.values.has("--redaction") ? redactionModeConfigValue(getValue(parsed, "--redaction", DEFAULT_REDACTION_MODE), "--redaction") : existing?.redaction ?? promptRedactionMode(),
|
|
6619
|
+
resolve: parsed.values.has("--resolve") ? resolveModeConfigValue(getValue(parsed, "--resolve", DEFAULT_RESOLVE), "--resolve") : existing?.resolve ?? DEFAULT_RESOLVE,
|
|
6620
|
+
debounce: durationConfigValue(parsed.values.get("--debounce"), "--debounce") ?? existing?.debounce,
|
|
6621
|
+
interval: durationConfigValue(parsed.values.get("--interval"), "--interval") ?? existing?.interval,
|
|
6622
|
+
review: reviewModeConfigValue(getValue(parsed, "--review", existing?.review ?? "off"), "--review"),
|
|
6623
|
+
reviewTimeout: durationConfigValue(parsed.values.get("--review-timeout"), "--review-timeout") ?? existing?.reviewTimeout
|
|
5730
6624
|
};
|
|
5731
6625
|
}
|
|
5732
6626
|
function printCheckoutVisibility(root, config) {
|
|
@@ -5743,7 +6637,7 @@ function printCheckoutVisibility(root, config) {
|
|
|
5743
6637
|
console.log(`sidecar path outside repo; not updating .gitignore`);
|
|
5744
6638
|
return;
|
|
5745
6639
|
}
|
|
5746
|
-
ensureIgnoreEntry(exclude ??
|
|
6640
|
+
ensureIgnoreEntry(exclude ?? path10.join(root, ".gitignore"), ignoreEntry);
|
|
5747
6641
|
const name = ignoreEntry.replace(/\/+$/, "");
|
|
5748
6642
|
console.log(`ignored ${name}/ via ${exclude ? ".git/info/exclude" : ".gitignore"}`);
|
|
5749
6643
|
if (hasZedInclusion(root, ignoreEntry)) {
|
|
@@ -5757,8 +6651,8 @@ function printCheckoutVisibility(root, config) {
|
|
|
5757
6651
|
}
|
|
5758
6652
|
}
|
|
5759
6653
|
function offerLocalInstall(root, standalone, forced) {
|
|
5760
|
-
const manifestPath =
|
|
5761
|
-
if (!
|
|
6654
|
+
const manifestPath = path10.join(root, "package.json");
|
|
6655
|
+
if (!fs11.existsSync(manifestPath)) {
|
|
5762
6656
|
if (forced)
|
|
5763
6657
|
throw new SidecarError("--local-install requires a package.json");
|
|
5764
6658
|
return;
|
|
@@ -5768,7 +6662,7 @@ function offerLocalInstall(root, standalone, forced) {
|
|
|
5768
6662
|
let source;
|
|
5769
6663
|
let manifest;
|
|
5770
6664
|
try {
|
|
5771
|
-
source =
|
|
6665
|
+
source = fs11.readFileSync(manifestPath, "utf8");
|
|
5772
6666
|
const parsed = JSON.parse(source);
|
|
5773
6667
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
5774
6668
|
throw new Error("not an object");
|
|
@@ -5794,7 +6688,7 @@ function offerLocalInstall(root, standalone, forced) {
|
|
|
5794
6688
|
manifest.pnpm = pnpm;
|
|
5795
6689
|
}
|
|
5796
6690
|
const indent = /^([ \t]+)"/m.exec(source)?.[1] ?? " ";
|
|
5797
|
-
|
|
6691
|
+
fs11.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, indent)}
|
|
5798
6692
|
`);
|
|
5799
6693
|
console.log(`added ${paint("brand", PACKAGE_NAME)} to devDependencies; run your package manager's install to pin it`);
|
|
5800
6694
|
if (managers.has("bun")) {
|
|
@@ -5822,7 +6716,7 @@ function detectPackageManagers(root) {
|
|
|
5822
6716
|
["package-lock.json", "npm"],
|
|
5823
6717
|
["yarn.lock", "yarn"]
|
|
5824
6718
|
];
|
|
5825
|
-
return new Set(lockfiles.filter(([file]) =>
|
|
6719
|
+
return new Set(lockfiles.filter(([file]) => fs11.existsSync(path10.join(root, file))).map(([, manager]) => manager));
|
|
5826
6720
|
}
|
|
5827
6721
|
function ensureDaemonSetup(globalSidecar) {
|
|
5828
6722
|
if (process.env[SKIP_SERVICE_ENV] === "1")
|
|
@@ -5832,7 +6726,7 @@ function ensureDaemonSetup(globalSidecar) {
|
|
|
5832
6726
|
const service = daemonServiceStatus();
|
|
5833
6727
|
if (!service.available || service.installed && service.running)
|
|
5834
6728
|
return;
|
|
5835
|
-
const result =
|
|
6729
|
+
const result = spawnSync5(globalSidecar, ["daemon", "enable"], {
|
|
5836
6730
|
encoding: "utf8",
|
|
5837
6731
|
env: {
|
|
5838
6732
|
...process.env,
|
|
@@ -5884,7 +6778,7 @@ function warnIfGlobalPredatesPeers(executable, peers) {
|
|
|
5884
6778
|
console.error(`sidecar: warning: the global sidecar (${version ? `v${version}` : "unknown version"}) predates peers; its daemon syncs only .sidecar until it is updated`);
|
|
5885
6779
|
}
|
|
5886
6780
|
function registerInstallWithGlobalSidecar(executable, root) {
|
|
5887
|
-
const result =
|
|
6781
|
+
const result = spawnSync5(executable, ["register-install"], {
|
|
5888
6782
|
cwd: root,
|
|
5889
6783
|
encoding: "utf8",
|
|
5890
6784
|
env: {
|
|
@@ -5901,7 +6795,7 @@ function installGlobalSidecar() {
|
|
|
5901
6795
|
const bun = findExecutableOnPath(process.platform === "win32" ? "bun.exe" : "bun");
|
|
5902
6796
|
const command = bun ? [bun, "add", "-g", PACKAGE_SPEC] : ["npm", "install", "-g", PACKAGE_SPEC];
|
|
5903
6797
|
console.log(`running ${command.join(" ")}`);
|
|
5904
|
-
const result =
|
|
6798
|
+
const result = spawnSync5(command[0], command.slice(1), { stdio: "inherit" });
|
|
5905
6799
|
if (result.status !== 0) {
|
|
5906
6800
|
throw new SidecarError(`global sidecar install failed; run \`${command.join(" ")}\` manually`);
|
|
5907
6801
|
}
|
|
@@ -5920,7 +6814,7 @@ function cmdClone(args) {
|
|
|
5920
6814
|
const { root, config } = peer;
|
|
5921
6815
|
if (parsed.flags.has("--if-missing")) {
|
|
5922
6816
|
const sidecarPath = resolveSidecarPath(root, config);
|
|
5923
|
-
if (
|
|
6817
|
+
if (fs11.existsSync(sidecarPath) && hasGitMetadata(sidecarPath))
|
|
5924
6818
|
continue;
|
|
5925
6819
|
}
|
|
5926
6820
|
cloneOrUpdate(root, config, !parsed.flags.has("--no-bootstrap-main"));
|
|
@@ -6008,7 +6902,7 @@ function createRemoteWithGh(root) {
|
|
|
6008
6902
|
const origin = git(root, ["remote", "get-url", "origin"], { check: false }).stdout.trim() || undefined;
|
|
6009
6903
|
const parsedOrigin = origin ? parseGitHubRemote(origin) : undefined;
|
|
6010
6904
|
const owner = parsedOrigin?.owner ?? ghLogin(gh);
|
|
6011
|
-
const baseName = parsedOrigin?.repo ??
|
|
6905
|
+
const baseName = parsedOrigin?.repo ?? path10.basename(root);
|
|
6012
6906
|
const suggested = owner ? `${owner}/${baseName}-sidecar` : `${baseName}-sidecar`;
|
|
6013
6907
|
const answer = promptLine(`repository to create ${paint("quiet", `[${suggested}]`)}: `) || suggested;
|
|
6014
6908
|
const fullName = answer.includes("/") ? answer : owner ? `${owner}/${answer}` : undefined;
|
|
@@ -6016,7 +6910,7 @@ function createRemoteWithGh(root) {
|
|
|
6016
6910
|
throw new SidecarError("could not determine the repository owner; enter it as owner/name");
|
|
6017
6911
|
}
|
|
6018
6912
|
console.log(`running gh repo create ${fullName} --private`);
|
|
6019
|
-
const create =
|
|
6913
|
+
const create = spawnSync5(gh, ["repo", "create", fullName, "--private"], { stdio: "inherit" });
|
|
6020
6914
|
if (create.status !== 0) {
|
|
6021
6915
|
throw new SidecarError("gh repo create failed; create the repo yourself and rerun `sidecar init <remote>`");
|
|
6022
6916
|
}
|
|
@@ -6030,14 +6924,14 @@ function parseGitHubRemote(url) {
|
|
|
6030
6924
|
return { owner: match[1], repo: match[2] };
|
|
6031
6925
|
}
|
|
6032
6926
|
function ghLogin(gh) {
|
|
6033
|
-
const result =
|
|
6927
|
+
const result = spawnSync5(gh, ["api", "user", "-q", ".login"], { encoding: "utf8" });
|
|
6034
6928
|
if (result.status !== 0)
|
|
6035
6929
|
return;
|
|
6036
6930
|
const login = result.stdout.trim();
|
|
6037
6931
|
return login || undefined;
|
|
6038
6932
|
}
|
|
6039
6933
|
function ghGitProtocol(gh) {
|
|
6040
|
-
const result =
|
|
6934
|
+
const result = spawnSync5(gh, ["config", "get", "git_protocol"], { encoding: "utf8" });
|
|
6041
6935
|
if (result.status !== 0)
|
|
6042
6936
|
return "https";
|
|
6043
6937
|
return result.stdout.trim() || "https";
|
|
@@ -6057,38 +6951,38 @@ function removeIgnoreEntry(ignorePath, sidecarPath) {
|
|
|
6057
6951
|
removeIgnoreLine(ignorePath, `/${sidecarPath.replace(/^\/+|\/+$/g, "")}/`);
|
|
6058
6952
|
}
|
|
6059
6953
|
function ensureIgnoreLine(ignorePath, entry) {
|
|
6060
|
-
const lines =
|
|
6954
|
+
const lines = fs11.existsSync(ignorePath) ? fs11.readFileSync(ignorePath, "utf8").split(/\r?\n/) : [];
|
|
6061
6955
|
if (lines.includes(entry))
|
|
6062
6956
|
return;
|
|
6063
6957
|
while (lines.length && lines[lines.length - 1] === "")
|
|
6064
6958
|
lines.pop();
|
|
6065
6959
|
lines.push(entry);
|
|
6066
|
-
|
|
6067
|
-
|
|
6960
|
+
fs11.mkdirSync(path10.dirname(ignorePath), { recursive: true });
|
|
6961
|
+
fs11.writeFileSync(ignorePath, `${lines.join(`
|
|
6068
6962
|
`).replace(/\s+$/g, "")}
|
|
6069
6963
|
`, "utf8");
|
|
6070
6964
|
}
|
|
6071
6965
|
function removeIgnoreLine(ignorePath, entry) {
|
|
6072
|
-
if (!
|
|
6966
|
+
if (!fs11.existsSync(ignorePath))
|
|
6073
6967
|
return;
|
|
6074
|
-
const lines =
|
|
6968
|
+
const lines = fs11.readFileSync(ignorePath, "utf8").split(/\r?\n/);
|
|
6075
6969
|
const kept = lines.filter((line) => line !== entry);
|
|
6076
6970
|
if (kept.length === lines.length)
|
|
6077
6971
|
return;
|
|
6078
6972
|
if (kept.every((line) => !line.trim())) {
|
|
6079
|
-
|
|
6973
|
+
fs11.rmSync(ignorePath);
|
|
6080
6974
|
} else {
|
|
6081
|
-
|
|
6975
|
+
fs11.writeFileSync(ignorePath, `${kept.join(`
|
|
6082
6976
|
`).replace(/\s+$/g, "")}
|
|
6083
6977
|
`, "utf8");
|
|
6084
6978
|
}
|
|
6085
6979
|
}
|
|
6086
6980
|
function hasZedInclusion(root, sidecarPath) {
|
|
6087
|
-
const settingsPath2 =
|
|
6088
|
-
if (!
|
|
6981
|
+
const settingsPath2 = path10.join(root, ".zed", "settings.json");
|
|
6982
|
+
if (!fs11.existsSync(settingsPath2))
|
|
6089
6983
|
return false;
|
|
6090
6984
|
try {
|
|
6091
|
-
const parsed = JSON.parse(
|
|
6985
|
+
const parsed = JSON.parse(fs11.readFileSync(settingsPath2, "utf8"));
|
|
6092
6986
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
6093
6987
|
return false;
|
|
6094
6988
|
const inclusions = parsed.file_scan_inclusions;
|
|
@@ -6102,11 +6996,11 @@ function zedInclusionGlob(sidecarPath) {
|
|
|
6102
6996
|
}
|
|
6103
6997
|
function ensureZedInclusion(root, sidecarPath) {
|
|
6104
6998
|
const glob = zedInclusionGlob(sidecarPath);
|
|
6105
|
-
const settingsPath2 =
|
|
6999
|
+
const settingsPath2 = path10.join(root, ".zed", "settings.json");
|
|
6106
7000
|
let settings = {};
|
|
6107
|
-
if (
|
|
7001
|
+
if (fs11.existsSync(settingsPath2)) {
|
|
6108
7002
|
try {
|
|
6109
|
-
const parsed = JSON.parse(
|
|
7003
|
+
const parsed = JSON.parse(fs11.readFileSync(settingsPath2, "utf8"));
|
|
6110
7004
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
6111
7005
|
return false;
|
|
6112
7006
|
settings = parsed;
|
|
@@ -6118,18 +7012,18 @@ function ensureZedInclusion(root, sidecarPath) {
|
|
|
6118
7012
|
if (!inclusions.includes(glob)) {
|
|
6119
7013
|
inclusions.push(glob);
|
|
6120
7014
|
settings.file_scan_inclusions = inclusions;
|
|
6121
|
-
|
|
6122
|
-
|
|
7015
|
+
fs11.mkdirSync(path10.dirname(settingsPath2), { recursive: true });
|
|
7016
|
+
fs11.writeFileSync(settingsPath2, `${JSON.stringify(settings, null, 2)}
|
|
6123
7017
|
`, "utf8");
|
|
6124
7018
|
}
|
|
6125
7019
|
return true;
|
|
6126
7020
|
}
|
|
6127
7021
|
function removeZedInclusion(root, sidecarPath) {
|
|
6128
|
-
const settingsPath2 =
|
|
6129
|
-
if (!
|
|
7022
|
+
const settingsPath2 = path10.join(root, ".zed", "settings.json");
|
|
7023
|
+
if (!fs11.existsSync(settingsPath2))
|
|
6130
7024
|
return;
|
|
6131
7025
|
try {
|
|
6132
|
-
const settings = JSON.parse(
|
|
7026
|
+
const settings = JSON.parse(fs11.readFileSync(settingsPath2, "utf8"));
|
|
6133
7027
|
if (!settings || typeof settings !== "object" || Array.isArray(settings))
|
|
6134
7028
|
return;
|
|
6135
7029
|
const inclusions = settings.file_scan_inclusions;
|
|
@@ -6144,17 +7038,17 @@ function removeZedInclusion(root, sidecarPath) {
|
|
|
6144
7038
|
} else {
|
|
6145
7039
|
delete settings.file_scan_inclusions;
|
|
6146
7040
|
}
|
|
6147
|
-
|
|
7041
|
+
fs11.writeFileSync(settingsPath2, `${JSON.stringify(settings, null, 2)}
|
|
6148
7042
|
`, "utf8");
|
|
6149
7043
|
} catch {
|
|
6150
7044
|
console.error(`sidecar: warning: could not safely remove the Zed inclusion from ${settingsPath2}`);
|
|
6151
7045
|
}
|
|
6152
7046
|
}
|
|
6153
7047
|
function ignoreEntryForSidecarPath(root, sidecarPath) {
|
|
6154
|
-
const resolvedRoot =
|
|
6155
|
-
const resolvedSidecarPath =
|
|
6156
|
-
const relative =
|
|
6157
|
-
if (!relative || relative.startsWith("..") ||
|
|
7048
|
+
const resolvedRoot = path10.resolve(root);
|
|
7049
|
+
const resolvedSidecarPath = path10.resolve(root, sidecarPath);
|
|
7050
|
+
const relative = path10.relative(resolvedRoot, resolvedSidecarPath);
|
|
7051
|
+
if (!relative || relative.startsWith("..") || path10.isAbsolute(relative))
|
|
6158
7052
|
return;
|
|
6159
7053
|
return relative;
|
|
6160
7054
|
}
|
|
@@ -6170,11 +7064,12 @@ var init_cmd_init = __esm(() => {
|
|
|
6170
7064
|
init_ui();
|
|
6171
7065
|
init_redaction();
|
|
6172
7066
|
init_rules();
|
|
7067
|
+
init_review();
|
|
6173
7068
|
});
|
|
6174
7069
|
|
|
6175
7070
|
// src/cmd-refresh.ts
|
|
6176
|
-
import
|
|
6177
|
-
import
|
|
7071
|
+
import fs12 from "node:fs";
|
|
7072
|
+
import path11 from "node:path";
|
|
6178
7073
|
function worktreeHoldingBranch(repo, branch) {
|
|
6179
7074
|
const result = git(repo, ["worktree", "list", "--porcelain"], { check: false });
|
|
6180
7075
|
if (result.status !== 0)
|
|
@@ -6202,13 +7097,53 @@ function unpushedCommits(sidecarPath) {
|
|
|
6202
7097
|
});
|
|
6203
7098
|
return counted.status === 0 ? Number(counted.stdout.trim()) || 0 : 0;
|
|
6204
7099
|
}
|
|
6205
|
-
function
|
|
7100
|
+
function heldWork(sidecarPath, config, inbox) {
|
|
7101
|
+
const gated = reviewRequired(config);
|
|
7102
|
+
const unpushed = gated ? 0 : unpushedCommits(sidecarPath);
|
|
7103
|
+
const unpublished = gated && !gatedWorkIsPublished(sidecarPath, config, inbox);
|
|
7104
|
+
const dirtyFiles = git(sidecarPath, ["status", "--porcelain"], { check: false }).stdout.split(`
|
|
7105
|
+
`).filter(Boolean).length;
|
|
7106
|
+
return [
|
|
7107
|
+
unpushed ? `${unpushed} commit(s) the remote has not seen` : "",
|
|
7108
|
+
unpublished ? "changes not yet published through review" : "",
|
|
7109
|
+
dirtyFiles ? `${dirtyFiles} uncommitted file(s)` : ""
|
|
7110
|
+
].filter(Boolean);
|
|
7111
|
+
}
|
|
7112
|
+
function gatedWorkIsPublished(sidecarPath, config, inbox) {
|
|
7113
|
+
if (!hasAnyCommit(sidecarPath))
|
|
7114
|
+
return true;
|
|
7115
|
+
const tree = (ref) => {
|
|
7116
|
+
const result = git(sidecarPath, ["rev-parse", "--verify", "--quiet", `${ref}^{tree}`], { check: false });
|
|
7117
|
+
return result.status === 0 ? result.stdout.trim() : undefined;
|
|
7118
|
+
};
|
|
7119
|
+
const main = `refs/heads/${config.branch}`;
|
|
7120
|
+
const publicMain = tree(`${PUBLIC_REFS}${config.branch}`);
|
|
7121
|
+
const mainTree = tree(main);
|
|
7122
|
+
const mainPublished = mainTree === undefined || mainTree === publicMain;
|
|
7123
|
+
const published = (ref, branch) => {
|
|
7124
|
+
const own = tree(ref);
|
|
7125
|
+
return own === publicMain || own === tree(`${PUBLIC_REFS}${branch}`) || mainPublished && isAncestor(sidecarPath, ref, main);
|
|
7126
|
+
};
|
|
7127
|
+
if (!published("HEAD", inbox))
|
|
7128
|
+
return false;
|
|
7129
|
+
if (!ownsStore(sidecarPath))
|
|
7130
|
+
return true;
|
|
7131
|
+
if (!mainPublished)
|
|
7132
|
+
return false;
|
|
7133
|
+
const prefix = inboxPrefix(config);
|
|
7134
|
+
return git(sidecarPath, ["for-each-ref", "--format=%(refname)", "refs/heads/"]).stdout.split(`
|
|
7135
|
+
`).filter((ref) => ref && matchesInboxPrefix(prefix, ref.slice("refs/heads/".length))).every((ref) => published(ref, ref.slice("refs/heads/".length)));
|
|
7136
|
+
}
|
|
7137
|
+
function ownsStore(sidecarPath) {
|
|
6206
7138
|
try {
|
|
6207
|
-
|
|
6208
|
-
return [];
|
|
7139
|
+
return fs12.statSync(path11.join(sidecarPath, ".git")).isDirectory();
|
|
6209
7140
|
} catch {
|
|
6210
|
-
return
|
|
7141
|
+
return false;
|
|
6211
7142
|
}
|
|
7143
|
+
}
|
|
7144
|
+
function dependentWorktrees(sidecarPath) {
|
|
7145
|
+
if (!ownsStore(sidecarPath))
|
|
7146
|
+
return [];
|
|
6212
7147
|
const result = git(sidecarPath, ["worktree", "list", "--porcelain"], { check: false });
|
|
6213
7148
|
if (result.status !== 0)
|
|
6214
7149
|
return [];
|
|
@@ -6219,11 +7154,11 @@ function existingCheckoutId(sidecarPath) {
|
|
|
6219
7154
|
const candidates = [];
|
|
6220
7155
|
const reported = git(sidecarPath, ["rev-parse", "--git-dir"], { check: false });
|
|
6221
7156
|
if (reported.status === 0)
|
|
6222
|
-
candidates.push(
|
|
6223
|
-
candidates.push(
|
|
7157
|
+
candidates.push(path11.resolve(sidecarPath, reported.stdout.trim()));
|
|
7158
|
+
candidates.push(path11.join(sidecarPath, ".git"));
|
|
6224
7159
|
for (const candidate of candidates) {
|
|
6225
7160
|
try {
|
|
6226
|
-
const id = slug(
|
|
7161
|
+
const id = slug(fs12.readFileSync(path11.join(candidate, "sidecar-id"), "utf8"));
|
|
6227
7162
|
if (id)
|
|
6228
7163
|
return id;
|
|
6229
7164
|
} catch {}
|
|
@@ -6235,12 +7170,20 @@ function refreshCheckout(root, config) {
|
|
|
6235
7170
|
if (isStandalone(config)) {
|
|
6236
7171
|
throw new SidecarError("refusing to delete a standalone sidecar, which is the repo itself");
|
|
6237
7172
|
}
|
|
6238
|
-
const relative =
|
|
6239
|
-
if (!relative || relative.startsWith("..") ||
|
|
7173
|
+
const relative = path11.relative(root, sidecarPath);
|
|
7174
|
+
if (!relative || relative.startsWith("..") || path11.isAbsolute(relative)) {
|
|
6240
7175
|
throw new SidecarError(`refusing to delete ${sidecarPath}, which is not inside ${root}`);
|
|
6241
7176
|
}
|
|
6242
7177
|
const checkoutId = existingCheckoutId(sidecarPath);
|
|
6243
|
-
|
|
7178
|
+
if (ownsStore(sidecarPath)) {
|
|
7179
|
+
let open;
|
|
7180
|
+
try {
|
|
7181
|
+
open = openReview(sidecarPath);
|
|
7182
|
+
} catch {}
|
|
7183
|
+
if (open)
|
|
7184
|
+
markSuperseded(open);
|
|
7185
|
+
}
|
|
7186
|
+
fs12.rmSync(sidecarPath, { recursive: true, force: true });
|
|
6244
7187
|
const family = familySidecarCheckout(root, config);
|
|
6245
7188
|
if (family) {
|
|
6246
7189
|
git(family, ["worktree", "prune", "--expire", "now"], { check: false });
|
|
@@ -6301,16 +7244,17 @@ function refreshPeer({ root, config, name }, parsed) {
|
|
|
6301
7244
|
let inbox;
|
|
6302
7245
|
if (readable) {
|
|
6303
7246
|
inbox = expandInbox(config, sidecarPath);
|
|
6304
|
-
|
|
6305
|
-
|
|
6306
|
-
|
|
6307
|
-
|
|
6308
|
-
|
|
6309
|
-
|
|
6310
|
-
|
|
6311
|
-
|
|
6312
|
-
|
|
6313
|
-
|
|
7247
|
+
if (reviewRequired(config)) {
|
|
7248
|
+
try {
|
|
7249
|
+
refreshPublicRefs(sidecarPath);
|
|
7250
|
+
} catch {}
|
|
7251
|
+
} else {
|
|
7252
|
+
fetch(sidecarPath, true, false);
|
|
7253
|
+
}
|
|
7254
|
+
const held = heldWork(sidecarPath, config, inbox);
|
|
7255
|
+
if (held.length && !force) {
|
|
7256
|
+
const publish = reviewRequired(config) ? "approve its review (`sidecar review`) and sync to publish them" : "run `sidecar sync` to push them";
|
|
7257
|
+
throw new SidecarError(`this checkout still holds ${held.join(" and ")}; ${publish}, then refresh — or \`sidecar refresh --force\` to discard them`);
|
|
6314
7258
|
}
|
|
6315
7259
|
}
|
|
6316
7260
|
if (standalone) {
|
|
@@ -6337,7 +7281,7 @@ function refreshPeer({ root, config, name }, parsed) {
|
|
|
6337
7281
|
}
|
|
6338
7282
|
let declined;
|
|
6339
7283
|
withSyncLock(root, name, "throw", () => {
|
|
6340
|
-
if (readable && !force &&
|
|
7284
|
+
if (readable && !force && inbox && heldWork(sidecarPath, config, inbox).length) {
|
|
6341
7285
|
throw new SidecarError("the sidecar checkout changed while waiting for confirmation; rerun refresh");
|
|
6342
7286
|
}
|
|
6343
7287
|
if (standalone)
|
|
@@ -6359,10 +7303,11 @@ var init_cmd_refresh = __esm(() => {
|
|
|
6359
7303
|
init_sync();
|
|
6360
7304
|
init_ui();
|
|
6361
7305
|
init_rules();
|
|
7306
|
+
init_review();
|
|
6362
7307
|
});
|
|
6363
7308
|
|
|
6364
7309
|
// src/cmd-status.ts
|
|
6365
|
-
import
|
|
7310
|
+
import fs13 from "node:fs";
|
|
6366
7311
|
function statusLine(label, value, role) {
|
|
6367
7312
|
labelLine(STATUS_LABEL_WIDTH, label, value, role);
|
|
6368
7313
|
}
|
|
@@ -6395,6 +7340,8 @@ function printPeerStatus({ root, config, configPath }) {
|
|
|
6395
7340
|
statusLine("main branch", config.branch);
|
|
6396
7341
|
statusLine("inbox branch", inbox);
|
|
6397
7342
|
statusLine("conflicts", config.resolve === "lww" ? "last writer wins" : "fork files");
|
|
7343
|
+
if (reviewRequired(config))
|
|
7344
|
+
printReviewLine(sidecarPath);
|
|
6398
7345
|
if (!checkoutPresent) {
|
|
6399
7346
|
statusLine("checkout", "missing — run `sidecar init`", "bad");
|
|
6400
7347
|
printDaemonLine();
|
|
@@ -6425,6 +7372,36 @@ function printPeerStatus({ root, config, configPath }) {
|
|
|
6425
7372
|
statusLine("pending inbox", "none", "quiet");
|
|
6426
7373
|
}
|
|
6427
7374
|
}
|
|
7375
|
+
function printReviewLine(sidecarPath) {
|
|
7376
|
+
const review = hasGitMetadata(sidecarPath) ? openReview(sidecarPath) : undefined;
|
|
7377
|
+
const reviewer = readSettings().reviewer;
|
|
7378
|
+
if (!review) {
|
|
7379
|
+
if (reviewer)
|
|
7380
|
+
statusLine("review", `required — nothing waiting (reviewer: ${reviewer})`, "quiet");
|
|
7381
|
+
else
|
|
7382
|
+
statusLine("review", "required — no reviewer configured; reviews wait for `sidecar review approve`", "attn");
|
|
7383
|
+
return;
|
|
7384
|
+
}
|
|
7385
|
+
const attention = reviewAttention(review);
|
|
7386
|
+
const age = formatRelativeTime(review.record.createdAt) ?? review.record.createdAt;
|
|
7387
|
+
const summary = `${review.state} ${review.record.id}, opened ${age}, ${review.record.files.length} file(s)`;
|
|
7388
|
+
statusLine("review", attention ? `${summary} — needs attention: ${attention}` : summary, attention ? "attn" : undefined);
|
|
7389
|
+
if (review.verdict?.reason)
|
|
7390
|
+
console.log(` ${paint("quiet", review.verdict.reason)}`);
|
|
7391
|
+
console.log(` ${paint("quiet", `sidecar review show ${review.record.id}`)}`);
|
|
7392
|
+
}
|
|
7393
|
+
function openReviewPayload(sidecarPath) {
|
|
7394
|
+
const review = openReview(sidecarPath);
|
|
7395
|
+
if (!review)
|
|
7396
|
+
return;
|
|
7397
|
+
return {
|
|
7398
|
+
id: review.record.id,
|
|
7399
|
+
state: review.state,
|
|
7400
|
+
attention: reviewAttention(review),
|
|
7401
|
+
createdAt: review.record.createdAt,
|
|
7402
|
+
files: review.record.files.length
|
|
7403
|
+
};
|
|
7404
|
+
}
|
|
6428
7405
|
function statusPayload({ root, name, config, configPath }) {
|
|
6429
7406
|
const sidecarPath = resolveSidecarPath(root, config);
|
|
6430
7407
|
const checkoutPresent = hasGitMetadata(sidecarPath);
|
|
@@ -6445,13 +7422,15 @@ function statusPayload({ root, name, config, configPath }) {
|
|
|
6445
7422
|
familyLinked: checkoutPresent ? !checkoutIsUnlinkedFromFamily(root, config, sidecarPath) : undefined,
|
|
6446
7423
|
daemon: daemonHealth().text,
|
|
6447
7424
|
lastSyncAt: readInstances().find((instance) => instance.configPath === configPath)?.lastSyncAt,
|
|
6448
|
-
pendingInbox: checkoutPresent ? pendingStatusInboxBranches(sidecarPath, config) : undefined
|
|
7425
|
+
pendingInbox: checkoutPresent ? pendingStatusInboxBranches(sidecarPath, config) : undefined,
|
|
7426
|
+
review: reviewRequired(config) ? { required: true, open: checkoutPresent ? openReviewPayload(sidecarPath) : undefined } : undefined
|
|
6449
7427
|
};
|
|
6450
7428
|
}
|
|
6451
7429
|
function pendingStatusInboxBranches(sidecarPath, config) {
|
|
6452
7430
|
fetch(sidecarPath, true, false);
|
|
6453
7431
|
const base = remoteRefExists(sidecarPath, config.branch) ? `origin/${config.branch}` : branchExists(sidecarPath, config.branch) ? config.branch : "HEAD";
|
|
6454
|
-
|
|
7432
|
+
const candidates = reviewRequired(config) ? pendingInboxBranches(sidecarPath, config).filter((branch) => branch.startsWith("origin/")) : pendingInboxBranches(sidecarPath, config);
|
|
7433
|
+
return candidates.filter((remoteBranch) => !isAncestor(sidecarPath, remoteBranch, base));
|
|
6455
7434
|
}
|
|
6456
7435
|
function daemonHealth() {
|
|
6457
7436
|
if (!shouldUseGlobalRegistry()) {
|
|
@@ -6553,6 +7532,10 @@ function healthStatusLine(state, record) {
|
|
|
6553
7532
|
const age = formatRelativeTime(record.updatedAt) ?? record.updatedAt;
|
|
6554
7533
|
return { text: `stale — last reported ${age}`, role: "attn" };
|
|
6555
7534
|
}
|
|
7535
|
+
if (record.review === "attention")
|
|
7536
|
+
return { text: "ok — publishing held, review needs attention", role: "attn" };
|
|
7537
|
+
if (record.review === "pending")
|
|
7538
|
+
return { text: "ok — publishing held for review", role: "ok" };
|
|
6556
7539
|
return { text: "ok", role: "ok" };
|
|
6557
7540
|
}
|
|
6558
7541
|
function cmdInstances(args) {
|
|
@@ -6606,16 +7589,16 @@ function cmdTail(args) {
|
|
|
6606
7589
|
throw new SidecarError("--lines requires a positive integer");
|
|
6607
7590
|
}
|
|
6608
7591
|
const filePath = sidecarLogPath();
|
|
6609
|
-
if (!
|
|
7592
|
+
if (!fs13.existsSync(filePath)) {
|
|
6610
7593
|
if (parsed.flags.has("-f") || parsed.flags.has("--follow")) {
|
|
6611
7594
|
followLog(filePath, 0);
|
|
6612
7595
|
return 0;
|
|
6613
7596
|
}
|
|
6614
7597
|
return 0;
|
|
6615
7598
|
}
|
|
6616
|
-
const stat =
|
|
7599
|
+
const stat = fs13.statSync(filePath);
|
|
6617
7600
|
if (stat.size > 0) {
|
|
6618
|
-
process.stdout.write(lastLines(
|
|
7601
|
+
process.stdout.write(lastLines(fs13.readFileSync(filePath, "utf8"), lines));
|
|
6619
7602
|
}
|
|
6620
7603
|
if (parsed.flags.has("-f") || parsed.flags.has("--follow")) {
|
|
6621
7604
|
followLog(filePath, stat.size);
|
|
@@ -6638,7 +7621,7 @@ function followLog(filePath, startOffset) {
|
|
|
6638
7621
|
sleep(1000);
|
|
6639
7622
|
let stat;
|
|
6640
7623
|
try {
|
|
6641
|
-
stat =
|
|
7624
|
+
stat = fs13.statSync(filePath);
|
|
6642
7625
|
} catch {
|
|
6643
7626
|
offset = 0;
|
|
6644
7627
|
continue;
|
|
@@ -6647,17 +7630,17 @@ function followLog(filePath, startOffset) {
|
|
|
6647
7630
|
offset = 0;
|
|
6648
7631
|
if (stat.size <= offset)
|
|
6649
7632
|
continue;
|
|
6650
|
-
const fd =
|
|
7633
|
+
const fd = fs13.openSync(filePath, "r");
|
|
6651
7634
|
try {
|
|
6652
7635
|
const length = stat.size - offset;
|
|
6653
7636
|
const buffer = Buffer.alloc(length);
|
|
6654
|
-
const bytesRead =
|
|
7637
|
+
const bytesRead = fs13.readSync(fd, buffer, 0, length, offset);
|
|
6655
7638
|
if (bytesRead > 0) {
|
|
6656
7639
|
process.stdout.write(buffer.subarray(0, bytesRead).toString("utf8"));
|
|
6657
7640
|
offset += bytesRead;
|
|
6658
7641
|
}
|
|
6659
7642
|
} finally {
|
|
6660
|
-
|
|
7643
|
+
fs13.closeSync(fd);
|
|
6661
7644
|
}
|
|
6662
7645
|
}
|
|
6663
7646
|
}
|
|
@@ -6676,6 +7659,7 @@ var init_cmd_status = __esm(() => {
|
|
|
6676
7659
|
init_sync();
|
|
6677
7660
|
init_ui();
|
|
6678
7661
|
init_health();
|
|
7662
|
+
init_review();
|
|
6679
7663
|
STATUS_LABEL_WIDTH = "pending inbox:".length;
|
|
6680
7664
|
});
|
|
6681
7665
|
|
|
@@ -7064,20 +8048,20 @@ var require_ignore = __commonJS((exports, module) => {
|
|
|
7064
8048
|
const index = body.indexOf(SLASH);
|
|
7065
8049
|
return index < 0 || index === body.length - 1;
|
|
7066
8050
|
};
|
|
7067
|
-
var basenameOf = (
|
|
7068
|
-
const end =
|
|
7069
|
-
const index =
|
|
7070
|
-
return index < 0 ?
|
|
8051
|
+
var basenameOf = (path12) => {
|
|
8052
|
+
const end = path12.length - 1;
|
|
8053
|
+
const index = path12.lastIndexOf(SLASH, path12[end] === SLASH ? end - 1 : end);
|
|
8054
|
+
return index < 0 ? path12 : path12.slice(index + 1);
|
|
7071
8055
|
};
|
|
7072
|
-
var parentOf = (
|
|
7073
|
-
if (
|
|
7074
|
-
const slices =
|
|
8056
|
+
var parentOf = (path12) => {
|
|
8057
|
+
if (path12.charCodeAt(0) === SLASH_CODE || path12.indexOf(DOUBLE_SLASH) >= 0) {
|
|
8058
|
+
const slices = path12.split(SLASH).filter(Boolean);
|
|
7075
8059
|
slices.pop();
|
|
7076
8060
|
return slices.length ? slices.join(SLASH) + SLASH : EMPTY;
|
|
7077
8061
|
}
|
|
7078
|
-
const end =
|
|
7079
|
-
const cut =
|
|
7080
|
-
return cut < 0 ? EMPTY :
|
|
8062
|
+
const end = path12.length - 1;
|
|
8063
|
+
const cut = path12.lastIndexOf(SLASH, path12.charCodeAt(end) === SLASH_CODE ? end - 1 : end);
|
|
8064
|
+
return cut < 0 ? EMPTY : path12.slice(0, cut + 1);
|
|
7081
8065
|
};
|
|
7082
8066
|
var isString = (subject) => typeof subject === "string";
|
|
7083
8067
|
var checkPattern = (pattern) => pattern && isString(pattern) && !REGEX_TEST_BLANK_LINE.test(pattern) && !REGEX_INVALID_TRAILING_BACKSLASH.test(pattern) && pattern.indexOf("#") !== 0;
|
|
@@ -7162,19 +8146,19 @@ var require_ignore = __commonJS((exports, module) => {
|
|
|
7162
8146
|
makeArray(isString(pattern) ? splitPattern(pattern) : pattern).forEach(this._add, this);
|
|
7163
8147
|
return this._added;
|
|
7164
8148
|
}
|
|
7165
|
-
test(
|
|
8149
|
+
test(path12, checkUnignored, mode) {
|
|
7166
8150
|
let ignored = false;
|
|
7167
8151
|
let unignored = false;
|
|
7168
8152
|
let matchedRule;
|
|
7169
8153
|
const rules = this._rules;
|
|
7170
8154
|
const { length } = rules;
|
|
7171
8155
|
const shortcut = this._basenameCount * 2 >= length;
|
|
7172
|
-
const basename = shortcut ? basenameOf(
|
|
8156
|
+
const basename = shortcut ? basenameOf(path12) : path12;
|
|
7173
8157
|
for (let index = 0;index < length; index++) {
|
|
7174
8158
|
const rule = rules[index];
|
|
7175
8159
|
const { negative } = rule;
|
|
7176
8160
|
const skip = unignored === negative && ignored !== unignored || negative && !ignored && !unignored && !checkUnignored;
|
|
7177
|
-
if (!skip && rule[mode].test(shortcut && rule._basenameOnly ? basename :
|
|
8161
|
+
if (!skip && rule[mode].test(shortcut && rule._basenameOnly ? basename : path12)) {
|
|
7178
8162
|
ignored = !negative;
|
|
7179
8163
|
unignored = negative;
|
|
7180
8164
|
matchedRule = negative ? UNDEFINED : rule;
|
|
@@ -7193,38 +8177,38 @@ var require_ignore = __commonJS((exports, module) => {
|
|
|
7193
8177
|
var throwError = (message, Ctor) => {
|
|
7194
8178
|
throw new Ctor(message);
|
|
7195
8179
|
};
|
|
7196
|
-
var checkPath = (
|
|
7197
|
-
if (!isString(
|
|
8180
|
+
var checkPath = (path12, originalPath, doThrow) => {
|
|
8181
|
+
if (!isString(path12)) {
|
|
7198
8182
|
return doThrow(`path must be a string, but got \`${originalPath}\``, TypeError);
|
|
7199
8183
|
}
|
|
7200
|
-
if (!
|
|
8184
|
+
if (!path12) {
|
|
7201
8185
|
return doThrow(`path must not be empty`, TypeError);
|
|
7202
8186
|
}
|
|
7203
|
-
if (checkPath.isNotRelative(
|
|
8187
|
+
if (checkPath.isNotRelative(path12)) {
|
|
7204
8188
|
const r = "`path.relative()`d";
|
|
7205
8189
|
return doThrow(`path should be a ${r} string, but got "${originalPath}"`, RangeError);
|
|
7206
8190
|
}
|
|
7207
8191
|
return true;
|
|
7208
8192
|
};
|
|
7209
|
-
var isNotRelative = (
|
|
7210
|
-
const first =
|
|
8193
|
+
var isNotRelative = (path12) => {
|
|
8194
|
+
const first = path12.charCodeAt(0);
|
|
7211
8195
|
if (first === SLASH_CODE) {
|
|
7212
8196
|
return true;
|
|
7213
8197
|
}
|
|
7214
8198
|
if (first !== DOT_CODE) {
|
|
7215
8199
|
return false;
|
|
7216
8200
|
}
|
|
7217
|
-
if (
|
|
8201
|
+
if (path12.length === 1) {
|
|
7218
8202
|
return true;
|
|
7219
8203
|
}
|
|
7220
|
-
const second =
|
|
8204
|
+
const second = path12.charCodeAt(1);
|
|
7221
8205
|
if (second === SLASH_CODE) {
|
|
7222
8206
|
return true;
|
|
7223
8207
|
}
|
|
7224
8208
|
if (second !== DOT_CODE) {
|
|
7225
8209
|
return false;
|
|
7226
8210
|
}
|
|
7227
|
-
return
|
|
8211
|
+
return path12.length === 2 || path12.charCodeAt(2) === SLASH_CODE;
|
|
7228
8212
|
};
|
|
7229
8213
|
checkPath.isNotRelative = isNotRelative;
|
|
7230
8214
|
checkPath.convert = (p) => p;
|
|
@@ -7254,51 +8238,51 @@ var require_ignore = __commonJS((exports, module) => {
|
|
|
7254
8238
|
return this.add(pattern);
|
|
7255
8239
|
}
|
|
7256
8240
|
_test(originalPath, cache, checkUnignored) {
|
|
7257
|
-
const
|
|
7258
|
-
checkPath(
|
|
7259
|
-
return this._t(
|
|
8241
|
+
const path12 = originalPath && checkPath.convert(originalPath);
|
|
8242
|
+
checkPath(path12, originalPath, this._strictPathCheck ? throwError : RETURN_FALSE);
|
|
8243
|
+
return this._t(path12, cache, checkUnignored);
|
|
7260
8244
|
}
|
|
7261
|
-
checkIgnore(
|
|
7262
|
-
if (
|
|
7263
|
-
return this.test(
|
|
8245
|
+
checkIgnore(path12) {
|
|
8246
|
+
if (path12.charCodeAt(path12.length - 1) !== SLASH_CODE) {
|
|
8247
|
+
return this.test(path12);
|
|
7264
8248
|
}
|
|
7265
|
-
const parentPath = parentOf(
|
|
8249
|
+
const parentPath = parentOf(path12);
|
|
7266
8250
|
if (parentPath) {
|
|
7267
8251
|
const parent = this._t(parentPath, this._testCache, true);
|
|
7268
8252
|
if (parent.ignored) {
|
|
7269
8253
|
return parent;
|
|
7270
8254
|
}
|
|
7271
8255
|
}
|
|
7272
|
-
return this._rules.test(
|
|
8256
|
+
return this._rules.test(path12, false, MODE_CHECK_IGNORE);
|
|
7273
8257
|
}
|
|
7274
|
-
_t(
|
|
7275
|
-
if (
|
|
7276
|
-
return cache[
|
|
8258
|
+
_t(path12, cache, checkUnignored) {
|
|
8259
|
+
if (path12 in cache) {
|
|
8260
|
+
return cache[path12];
|
|
7277
8261
|
}
|
|
7278
|
-
const parentPath = parentOf(
|
|
8262
|
+
const parentPath = parentOf(path12);
|
|
7279
8263
|
const parent = parentPath ? this._t(parentPath, cache, checkUnignored) : UNDEFINED;
|
|
7280
|
-
return cache[
|
|
8264
|
+
return cache[path12] = parent && parent.ignored ? parent : this._rules.test(path12, checkUnignored, MODE_IGNORE);
|
|
7281
8265
|
}
|
|
7282
|
-
ignores(
|
|
7283
|
-
return this._test(
|
|
8266
|
+
ignores(path12) {
|
|
8267
|
+
return this._test(path12, this._ignoreCache, false).ignored;
|
|
7284
8268
|
}
|
|
7285
8269
|
createFilter() {
|
|
7286
|
-
return (
|
|
8270
|
+
return (path12) => !this.ignores(path12);
|
|
7287
8271
|
}
|
|
7288
8272
|
filter(paths) {
|
|
7289
8273
|
return makeArray(paths).filter(this.createFilter());
|
|
7290
8274
|
}
|
|
7291
|
-
test(
|
|
7292
|
-
return this._test(
|
|
8275
|
+
test(path12) {
|
|
8276
|
+
return this._test(path12, this._testCache, true);
|
|
7293
8277
|
}
|
|
7294
8278
|
}
|
|
7295
8279
|
var factory = (options) => new Ignore(options);
|
|
7296
|
-
var isPathValid = (
|
|
8280
|
+
var isPathValid = (path12) => checkPath(path12 && checkPath.convert(path12), path12, RETURN_FALSE);
|
|
7297
8281
|
var setupWindows = () => {
|
|
7298
8282
|
const makePosix = (str) => /^\\\\\?\\/.test(str) || /["<>|\u0000-\u001F]+/u.test(str) ? str : str.replace(/\\/g, "/");
|
|
7299
8283
|
checkPath.convert = makePosix;
|
|
7300
8284
|
const REGEX_TEST_WINDOWS_PATH_ABSOLUTE = /^[a-z]:\//i;
|
|
7301
|
-
checkPath.isNotRelative = (
|
|
8285
|
+
checkPath.isNotRelative = (path12) => REGEX_TEST_WINDOWS_PATH_ABSOLUTE.test(path12) || isNotRelative(path12);
|
|
7302
8286
|
};
|
|
7303
8287
|
if (typeof process !== "undefined" && process.platform === "win32") {
|
|
7304
8288
|
setupWindows();
|
|
@@ -7316,20 +8300,28 @@ __export(exports_daemon, {
|
|
|
7316
8300
|
selectWatchTargets: () => selectWatchTargets,
|
|
7317
8301
|
scheduleFor: () => scheduleFor,
|
|
7318
8302
|
runDaemonLoop: () => runDaemonLoop,
|
|
8303
|
+
reviewDueAt: () => reviewDueAt,
|
|
7319
8304
|
refreshWatchers: () => refreshWatchers,
|
|
7320
8305
|
compileGitignoreMatcher: () => compileGitignoreMatcher,
|
|
7321
8306
|
checkAndInstallUpdate: () => checkAndInstallUpdate,
|
|
7322
8307
|
WATCH_LIMIT: () => WATCH_LIMIT
|
|
7323
8308
|
});
|
|
7324
|
-
import
|
|
7325
|
-
import
|
|
7326
|
-
import { spawn as
|
|
7327
|
-
import { fileURLToPath as
|
|
8309
|
+
import fs14 from "node:fs";
|
|
8310
|
+
import path12 from "node:path";
|
|
8311
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
8312
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
8313
|
+
function outputIsPrivate(key) {
|
|
8314
|
+
try {
|
|
8315
|
+
return reviewRequired(readConfig(key, { loadRules: false }));
|
|
8316
|
+
} catch {
|
|
8317
|
+
return true;
|
|
8318
|
+
}
|
|
8319
|
+
}
|
|
7328
8320
|
function peerRoot(key) {
|
|
7329
|
-
return
|
|
8321
|
+
return path12.dirname(key);
|
|
7330
8322
|
}
|
|
7331
8323
|
function peerName(key) {
|
|
7332
|
-
return peerNameOf(
|
|
8324
|
+
return peerNameOf(path12.basename(key)) ?? DEFAULT_PEER;
|
|
7333
8325
|
}
|
|
7334
8326
|
function peerFields(key) {
|
|
7335
8327
|
const peer = peerName(key);
|
|
@@ -7379,6 +8371,7 @@ async function runDaemonLoop(options) {
|
|
|
7379
8371
|
await acquireDaemonPid();
|
|
7380
8372
|
installShutdownHandlers();
|
|
7381
8373
|
await watchRegistry(state);
|
|
8374
|
+
await watchReviewDue(state);
|
|
7382
8375
|
const bootVersion = packageVersion();
|
|
7383
8376
|
while (true) {
|
|
7384
8377
|
maybeAdoptNewerInstall(state, bootVersion);
|
|
@@ -7417,7 +8410,7 @@ function maybeAdoptNewerInstall(state, bootVersion) {
|
|
|
7417
8410
|
executable: onPath,
|
|
7418
8411
|
reason: "new-install"
|
|
7419
8412
|
});
|
|
7420
|
-
const child =
|
|
8413
|
+
const child = spawn3(onPath, ["daemon", "restart"], {
|
|
7421
8414
|
detached: true,
|
|
7422
8415
|
stdio: "ignore",
|
|
7423
8416
|
windowsHide: true,
|
|
@@ -7437,7 +8430,7 @@ async function runCycle(state) {
|
|
|
7437
8430
|
let skipped = 0;
|
|
7438
8431
|
for (const instance of readInstances()) {
|
|
7439
8432
|
const key = instance.configPath;
|
|
7440
|
-
if (!
|
|
8433
|
+
if (!fs14.existsSync(instance.configPath)) {
|
|
7441
8434
|
const misses = (state.misses.get(key) ?? 0) + 1;
|
|
7442
8435
|
state.misses.set(key, misses);
|
|
7443
8436
|
if (misses >= PRUNE_AFTER_MISSES) {
|
|
@@ -7491,10 +8484,11 @@ async function syncInstance(state, key, trigger, options = {}) {
|
|
|
7491
8484
|
state.syncingFamilies.add(family);
|
|
7492
8485
|
if (!options.localOnly) {
|
|
7493
8486
|
state.lastRemoteSyncAt.set(key, Date.now());
|
|
7494
|
-
clearTimeout(state.remoteTimers.get(key));
|
|
8487
|
+
clearTimeout(state.remoteTimers.get(key)?.timer);
|
|
7495
8488
|
state.remoteTimers.delete(key);
|
|
7496
8489
|
}
|
|
7497
8490
|
let succeeded = false;
|
|
8491
|
+
let dirtyCheckout = false;
|
|
7498
8492
|
try {
|
|
7499
8493
|
const localCli = localSidecarCliPath(root);
|
|
7500
8494
|
const cli = localCli ?? currentCliPath();
|
|
@@ -7511,20 +8505,28 @@ async function syncInstance(state, key, trigger, options = {}) {
|
|
|
7511
8505
|
},
|
|
7512
8506
|
timeoutMs: SYNC_TIMEOUT_MS
|
|
7513
8507
|
});
|
|
8508
|
+
dirtyCheckout = !result.timedOut && result.status === DIRTY_CHECKOUT_EXIT;
|
|
7514
8509
|
if (result.status === 0) {
|
|
7515
|
-
|
|
7516
|
-
|
|
8510
|
+
if (!options.localOnly) {
|
|
8511
|
+
state.failures.delete(key);
|
|
8512
|
+
state.skipUntilCycle.delete(key);
|
|
8513
|
+
}
|
|
7517
8514
|
logSidecarEvent("daemon-sync", { ...peerFields(key), trigger, local: Boolean(localCli) });
|
|
7518
8515
|
succeeded = true;
|
|
7519
8516
|
} else {
|
|
7520
|
-
|
|
7521
|
-
|
|
7522
|
-
|
|
8517
|
+
if (!dirtyCheckout) {
|
|
8518
|
+
const failures = (state.failures.get(key) ?? 0) + 1;
|
|
8519
|
+
state.failures.set(key, failures);
|
|
8520
|
+
state.skipUntilCycle.set(key, state.cycleCount + Math.min(2 ** (failures - 1), MAX_BACKOFF_CYCLES));
|
|
8521
|
+
}
|
|
8522
|
+
if (!options.localOnly)
|
|
8523
|
+
state.lastRemoteSyncAt.set(key, Date.now());
|
|
7523
8524
|
logSidecarEvent("failure", {
|
|
7524
8525
|
command: "daemon",
|
|
7525
8526
|
...peerFields(key),
|
|
7526
8527
|
trigger,
|
|
7527
|
-
|
|
8528
|
+
reason: result.timedOut ? "timeout" : dirtyCheckout ? "dirty-checkout" : `exit-${result.status}`,
|
|
8529
|
+
message: result.timedOut ? "sync timed out" : !outputIsPrivate(key) && result.output.trim().slice(-500) || `sync exited ${result.status}`
|
|
7528
8530
|
});
|
|
7529
8531
|
}
|
|
7530
8532
|
} finally {
|
|
@@ -7533,12 +8535,24 @@ async function syncInstance(state, key, trigger, options = {}) {
|
|
|
7533
8535
|
if (options.localOnly)
|
|
7534
8536
|
armRemoteSync(state, key);
|
|
7535
8537
|
}
|
|
7536
|
-
if (succeeded)
|
|
8538
|
+
if (succeeded) {
|
|
8539
|
+
if (!options.localOnly)
|
|
8540
|
+
armReviewDue(state, key, "after-sync");
|
|
7537
8541
|
await followUpTrailingSync(state, key);
|
|
7538
|
-
else
|
|
7539
|
-
state.
|
|
8542
|
+
} else {
|
|
8543
|
+
followUpFailedSync(state, key, { dirtyCheckout, remote: !options.localOnly });
|
|
8544
|
+
}
|
|
7540
8545
|
return succeeded;
|
|
7541
8546
|
}
|
|
8547
|
+
function followUpFailedSync(state, key, failure) {
|
|
8548
|
+
if (failure.dirtyCheckout) {
|
|
8549
|
+
state.trailingPending.add(key);
|
|
8550
|
+
if (failure.remote)
|
|
8551
|
+
armRemoteSync(state, key);
|
|
8552
|
+
}
|
|
8553
|
+
if (state.trailingPending.has(key) && !state.pendingTimers.has(key))
|
|
8554
|
+
openTrailingWindow(state, key, SETTLE_WINDOW_MS);
|
|
8555
|
+
}
|
|
7542
8556
|
async function followUpTrailingSync(state, key) {
|
|
7543
8557
|
if (!state.trailingPending.delete(key))
|
|
7544
8558
|
return;
|
|
@@ -7551,15 +8565,17 @@ async function syncIfDirty(state, key, trigger) {
|
|
|
7551
8565
|
}
|
|
7552
8566
|
async function checkoutIsDirty(key) {
|
|
7553
8567
|
const sidecarPath = readInstances().find((instance) => instance.configPath === key)?.sidecarPath;
|
|
7554
|
-
if (!sidecarPath || !
|
|
8568
|
+
if (!sidecarPath || !fs14.existsSync(sidecarPath))
|
|
7555
8569
|
return false;
|
|
7556
8570
|
const result = await runChild("git", ["-C", sidecarPath, "status", "--porcelain"], { timeoutMs: 30000 });
|
|
7557
|
-
|
|
8571
|
+
if (result.status !== 0)
|
|
8572
|
+
return true;
|
|
8573
|
+
return Boolean(result.stdout.trim());
|
|
7558
8574
|
}
|
|
7559
8575
|
function localSidecarCliPath(root) {
|
|
7560
8576
|
if (!projectDependsOnSidecar(root))
|
|
7561
8577
|
return;
|
|
7562
|
-
const candidate =
|
|
8578
|
+
const candidate = path12.join(root, "node_modules", PACKAGE_NAME, "dist", "cli.js");
|
|
7563
8579
|
if (!isFile(candidate))
|
|
7564
8580
|
return;
|
|
7565
8581
|
const localVersion = installedPackageVersion(root);
|
|
@@ -7568,10 +8584,10 @@ function localSidecarCliPath(root) {
|
|
|
7568
8584
|
return candidate;
|
|
7569
8585
|
}
|
|
7570
8586
|
function currentCliPath() {
|
|
7571
|
-
return process.argv[1] ||
|
|
8587
|
+
return process.argv[1] || fileURLToPath2(import.meta.url);
|
|
7572
8588
|
}
|
|
7573
8589
|
function selectWatchTargets(instances, limit = WATCH_LIMIT) {
|
|
7574
|
-
return [...instances].filter((instance) =>
|
|
8590
|
+
return [...instances].filter((instance) => fs14.existsSync(instance.configPath) && fs14.existsSync(instance.sidecarPath)).sort((left, right) => instanceRecency(right) - instanceRecency(left)).slice(0, limit);
|
|
7575
8591
|
}
|
|
7576
8592
|
function instanceRecency(instance) {
|
|
7577
8593
|
const time = Date.parse(instance.lastSyncAt ?? instance.updatedAt ?? instance.registeredAt);
|
|
@@ -7622,8 +8638,8 @@ async function refreshWatchers(state) {
|
|
|
7622
8638
|
});
|
|
7623
8639
|
watcher.on("all", (...args) => {
|
|
7624
8640
|
scheduleWatchSync(state, key);
|
|
7625
|
-
const changedPath = typeof args[1] === "string" ?
|
|
7626
|
-
if (changedPath !==
|
|
8641
|
+
const changedPath = typeof args[1] === "string" ? path12.resolve(args[1]) : "";
|
|
8642
|
+
if (changedPath !== path12.join(path12.resolve(sidecarPath), ".gitignore"))
|
|
7627
8643
|
return;
|
|
7628
8644
|
if (state.watchers.get(key) !== watcher)
|
|
7629
8645
|
return;
|
|
@@ -7636,7 +8652,7 @@ async function refreshWatchers(state) {
|
|
|
7636
8652
|
logSidecarEvent("failure", {
|
|
7637
8653
|
command: "daemon",
|
|
7638
8654
|
...peerFields(key),
|
|
7639
|
-
message: `watcher error: ${error instanceof Error ? error.message : String(error)}`
|
|
8655
|
+
message: outputIsPrivate(key) ? "watcher error" : `watcher error: ${error instanceof Error ? error.message : String(error)}`
|
|
7640
8656
|
});
|
|
7641
8657
|
});
|
|
7642
8658
|
state.watchers.set(key, watcher);
|
|
@@ -7644,7 +8660,7 @@ async function refreshWatchers(state) {
|
|
|
7644
8660
|
logSidecarEvent("failure", {
|
|
7645
8661
|
command: "daemon",
|
|
7646
8662
|
...peerFields(key),
|
|
7647
|
-
message: `could not watch ${sidecarPath}: ${error instanceof Error ? error.message : String(error)}`
|
|
8663
|
+
message: outputIsPrivate(key) ? `could not watch ${sidecarPath}` : `could not watch ${sidecarPath}: ${error instanceof Error ? error.message : String(error)}`
|
|
7648
8664
|
});
|
|
7649
8665
|
}
|
|
7650
8666
|
}
|
|
@@ -7682,14 +8698,35 @@ async function beginWatchSync(state, key) {
|
|
|
7682
8698
|
state.trailingPending.add(key);
|
|
7683
8699
|
}
|
|
7684
8700
|
function armRemoteSync(state, key) {
|
|
7685
|
-
if (state.remoteTimers.has(key))
|
|
7686
|
-
return;
|
|
7687
8701
|
const elapsed = Date.now() - (state.lastRemoteSyncAt.get(key) ?? 0);
|
|
8702
|
+
armRemoteAt(state, key, Date.now() + Math.max(SETTLE_WINDOW_MS, scheduleFor(key, state.options).debounceSeconds * 1000 - elapsed), "remote-due");
|
|
8703
|
+
}
|
|
8704
|
+
function armRemoteAt(state, key, at, trigger) {
|
|
8705
|
+
const booked = state.remoteTimers.get(key);
|
|
8706
|
+
if (booked && booked.at <= at)
|
|
8707
|
+
return;
|
|
8708
|
+
clearTimeout(booked?.timer);
|
|
7688
8709
|
const timer = setTimeout(() => {
|
|
7689
8710
|
state.remoteTimers.delete(key);
|
|
7690
|
-
syncInstance(state, key,
|
|
7691
|
-
}, Math.max(
|
|
7692
|
-
state.remoteTimers.set(key, timer);
|
|
8711
|
+
syncInstance(state, key, trigger);
|
|
8712
|
+
}, Math.max(0, at - Date.now()));
|
|
8713
|
+
state.remoteTimers.set(key, { timer, at });
|
|
8714
|
+
}
|
|
8715
|
+
function armReviewDue(state, key, when) {
|
|
8716
|
+
const at = reviewDueAt(readReviewDue(key), when, Date.now(), scheduleFor(key, state.options).debounceSeconds * 1000);
|
|
8717
|
+
if (at !== undefined)
|
|
8718
|
+
armRemoteAt(state, key, at, "review-due");
|
|
8719
|
+
}
|
|
8720
|
+
function reviewDueAt(due, when, now, debounceMs) {
|
|
8721
|
+
if (due === undefined)
|
|
8722
|
+
return;
|
|
8723
|
+
if (due > now)
|
|
8724
|
+
return due;
|
|
8725
|
+
if (when === "start")
|
|
8726
|
+
return now;
|
|
8727
|
+
if (when === "after-sync")
|
|
8728
|
+
return now + Math.max(SETTLE_WINDOW_MS, debounceMs);
|
|
8729
|
+
return;
|
|
7693
8730
|
}
|
|
7694
8731
|
function remoteIsDue(state, key) {
|
|
7695
8732
|
const last = state.lastRemoteSyncAt.get(key) ?? 0;
|
|
@@ -7717,7 +8754,7 @@ async function watchRegistry(state) {
|
|
|
7717
8754
|
const watcher = chokidar.watch(sidecarStateDir(), { ignoreInitial: true, depth: 0 });
|
|
7718
8755
|
watcher.on("all", (...args) => {
|
|
7719
8756
|
const filePath = typeof args[1] === "string" ? args[1] : "";
|
|
7720
|
-
if (
|
|
8757
|
+
if (path12.basename(filePath) !== "instances.json")
|
|
7721
8758
|
return;
|
|
7722
8759
|
if (state.registryTimer)
|
|
7723
8760
|
return;
|
|
@@ -7733,10 +8770,38 @@ async function watchRegistry(state) {
|
|
|
7733
8770
|
});
|
|
7734
8771
|
}
|
|
7735
8772
|
}
|
|
8773
|
+
async function watchReviewDue(state) {
|
|
8774
|
+
const scan = (when) => {
|
|
8775
|
+
for (const instance of readInstances())
|
|
8776
|
+
armReviewDue(state, instance.configPath, when);
|
|
8777
|
+
};
|
|
8778
|
+
scan("start");
|
|
8779
|
+
const chokidar = await loadChokidar();
|
|
8780
|
+
if (!chokidar)
|
|
8781
|
+
return;
|
|
8782
|
+
try {
|
|
8783
|
+
fs14.mkdirSync(reviewDueDir(), { recursive: true });
|
|
8784
|
+
const watcher = chokidar.watch(reviewDueDir(), { ignoreInitial: true, depth: 0 });
|
|
8785
|
+
let timer;
|
|
8786
|
+
watcher.on("all", () => {
|
|
8787
|
+
if (timer)
|
|
8788
|
+
return;
|
|
8789
|
+
timer = setTimeout(() => {
|
|
8790
|
+
timer = undefined;
|
|
8791
|
+
scan("change");
|
|
8792
|
+
}, 500);
|
|
8793
|
+
});
|
|
8794
|
+
} catch (error) {
|
|
8795
|
+
logSidecarEvent("failure", {
|
|
8796
|
+
command: "daemon",
|
|
8797
|
+
message: `could not watch review due times: ${error instanceof Error ? error.message : String(error)}`
|
|
8798
|
+
});
|
|
8799
|
+
}
|
|
8800
|
+
}
|
|
7736
8801
|
function compileGitignoreMatcher(lines) {
|
|
7737
8802
|
const rules = import_ignore.default({ ignorecase: false }).add(lines);
|
|
7738
8803
|
return (relativePath) => {
|
|
7739
|
-
const normalized = relativePath.split(
|
|
8804
|
+
const normalized = relativePath.split(path12.sep).join("/");
|
|
7740
8805
|
if (!import_ignore.default.isPathValid(normalized))
|
|
7741
8806
|
return false;
|
|
7742
8807
|
return rules.ignores(normalized);
|
|
@@ -7745,19 +8810,19 @@ function compileGitignoreMatcher(lines) {
|
|
|
7745
8810
|
function watchIgnoreMatcher(sidecarPath) {
|
|
7746
8811
|
let gitignore;
|
|
7747
8812
|
try {
|
|
7748
|
-
const ignoreFile =
|
|
7749
|
-
if (
|
|
7750
|
-
gitignore = compileGitignoreMatcher(
|
|
8813
|
+
const ignoreFile = path12.join(sidecarPath, ".gitignore");
|
|
8814
|
+
if (fs14.existsSync(ignoreFile)) {
|
|
8815
|
+
gitignore = compileGitignoreMatcher(fs14.readFileSync(ignoreFile, "utf8").split(`
|
|
7751
8816
|
`));
|
|
7752
8817
|
}
|
|
7753
8818
|
} catch {}
|
|
7754
|
-
const root =
|
|
8819
|
+
const root = path12.resolve(sidecarPath);
|
|
7755
8820
|
return (candidate, stats) => {
|
|
7756
|
-
const relative =
|
|
8821
|
+
const relative = path12.relative(root, candidate);
|
|
7757
8822
|
if (!relative)
|
|
7758
8823
|
return false;
|
|
7759
|
-
const normalized = relative.split(
|
|
7760
|
-
if (normalized === ".." || normalized.startsWith("../") ||
|
|
8824
|
+
const normalized = relative.split(path12.sep).join("/");
|
|
8825
|
+
if (normalized === ".." || normalized.startsWith("../") || path12.isAbsolute(relative))
|
|
7761
8826
|
return true;
|
|
7762
8827
|
if (normalized === ".git" || normalized.startsWith(".git/"))
|
|
7763
8828
|
return true;
|
|
@@ -7836,10 +8901,10 @@ function restartAfterUpdate() {
|
|
|
7836
8901
|
}
|
|
7837
8902
|
async function acquireDaemonPid() {
|
|
7838
8903
|
const pidPath = daemonPidPath();
|
|
7839
|
-
|
|
8904
|
+
fs14.mkdirSync(path12.dirname(pidPath), { recursive: true });
|
|
7840
8905
|
while (true) {
|
|
7841
8906
|
try {
|
|
7842
|
-
|
|
8907
|
+
fs14.writeFileSync(pidPath, `${process.pid}
|
|
7843
8908
|
`, { encoding: "utf8", flag: "wx" });
|
|
7844
8909
|
return;
|
|
7845
8910
|
} catch (error) {
|
|
@@ -7855,7 +8920,7 @@ async function acquireDaemonPid() {
|
|
|
7855
8920
|
continue;
|
|
7856
8921
|
}
|
|
7857
8922
|
logSidecarEvent("daemon-pid-heal", { holder: holder ?? null });
|
|
7858
|
-
|
|
8923
|
+
fs14.rmSync(pidPath, { force: true });
|
|
7859
8924
|
}
|
|
7860
8925
|
}
|
|
7861
8926
|
function installShutdownHandlers() {
|
|
@@ -7870,12 +8935,12 @@ function installShutdownHandlers() {
|
|
|
7870
8935
|
function removeOwnPidFile() {
|
|
7871
8936
|
try {
|
|
7872
8937
|
if (readPid(daemonPidPath()) === process.pid)
|
|
7873
|
-
|
|
8938
|
+
fs14.rmSync(daemonPidPath(), { force: true });
|
|
7874
8939
|
} catch {}
|
|
7875
8940
|
}
|
|
7876
8941
|
function readPid(pidPath) {
|
|
7877
8942
|
try {
|
|
7878
|
-
const pid = Number(
|
|
8943
|
+
const pid = Number(fs14.readFileSync(pidPath, "utf8").trim());
|
|
7879
8944
|
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
7880
8945
|
} catch {
|
|
7881
8946
|
return;
|
|
@@ -7883,7 +8948,7 @@ function readPid(pidPath) {
|
|
|
7883
8948
|
}
|
|
7884
8949
|
function runChild(command, args, options) {
|
|
7885
8950
|
return new Promise((resolve) => {
|
|
7886
|
-
const child =
|
|
8951
|
+
const child = spawn3(command, args, {
|
|
7887
8952
|
cwd: options.cwd,
|
|
7888
8953
|
env: options.env ?? process.env,
|
|
7889
8954
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -7916,21 +8981,21 @@ function runChild(command, args, options) {
|
|
|
7916
8981
|
}
|
|
7917
8982
|
function isFile(filePath) {
|
|
7918
8983
|
try {
|
|
7919
|
-
return
|
|
8984
|
+
return fs14.statSync(filePath).isFile();
|
|
7920
8985
|
} catch {
|
|
7921
8986
|
return false;
|
|
7922
8987
|
}
|
|
7923
8988
|
}
|
|
7924
8989
|
function realpathOr2(filePath) {
|
|
7925
8990
|
try {
|
|
7926
|
-
return
|
|
8991
|
+
return fs14.realpathSync(filePath);
|
|
7927
8992
|
} catch {
|
|
7928
|
-
return
|
|
8993
|
+
return path12.resolve(filePath);
|
|
7929
8994
|
}
|
|
7930
8995
|
}
|
|
7931
8996
|
function isInsidePath2(child, parent) {
|
|
7932
|
-
const relative =
|
|
7933
|
-
return Boolean(relative) && !relative.startsWith("..") && !
|
|
8997
|
+
const relative = path12.relative(parent, child);
|
|
8998
|
+
return Boolean(relative) && !relative.startsWith("..") && !path12.isAbsolute(relative);
|
|
7934
8999
|
}
|
|
7935
9000
|
function delay(ms) {
|
|
7936
9001
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -8058,7 +9123,7 @@ async function cmdDaemonRun(args) {
|
|
|
8058
9123
|
if (!Number.isFinite(intervalSeconds) || intervalSeconds <= 0) {
|
|
8059
9124
|
throw new SidecarError("--interval must be > 0");
|
|
8060
9125
|
}
|
|
8061
|
-
const debounceSeconds = Number(getValue(parsed, "--debounce",
|
|
9126
|
+
const debounceSeconds = Number(getValue(parsed, "--debounce", String(DEFAULT_DEBOUNCE_SECONDS)));
|
|
8062
9127
|
if (!Number.isFinite(debounceSeconds) || debounceSeconds < 0) {
|
|
8063
9128
|
throw new SidecarError("--debounce must be >= 0");
|
|
8064
9129
|
}
|
|
@@ -8132,9 +9197,9 @@ var init_cmd_daemon = __esm(() => {
|
|
|
8132
9197
|
});
|
|
8133
9198
|
|
|
8134
9199
|
// src/cmd-sync.ts
|
|
8135
|
-
import
|
|
9200
|
+
import fs15 from "node:fs";
|
|
8136
9201
|
import os6 from "node:os";
|
|
8137
|
-
import
|
|
9202
|
+
import path13 from "node:path";
|
|
8138
9203
|
function cmdSnapshot(args) {
|
|
8139
9204
|
const parsed = parseOptions(args, {
|
|
8140
9205
|
boolean: new Set(["--push"]),
|
|
@@ -8143,17 +9208,23 @@ function cmdSnapshot(args) {
|
|
|
8143
9208
|
if (parsed.positional.length)
|
|
8144
9209
|
throw new SidecarError("usage: sidecar snapshot [--push] [-m message] [--peer name]");
|
|
8145
9210
|
const peers = loadPeers(selectedPeer(parsed));
|
|
9211
|
+
const push = parsed.flags.has("--push");
|
|
9212
|
+
const gated = push ? peers.filter((peer) => reviewRequired(peer.config)) : [];
|
|
9213
|
+
if (gated.length) {
|
|
9214
|
+
throw new SidecarError(`${peers.length === 1 ? "this peer requires" : `${gated.map((peer) => peer.name).join(", ")} require`} review before anything is published; snapshot without --push, and \`sidecar sync\` opens or advances the review`);
|
|
9215
|
+
}
|
|
8146
9216
|
for (const peer of peers) {
|
|
8147
9217
|
announcePeer(peer, peers);
|
|
8148
9218
|
const { root, config } = peer;
|
|
8149
9219
|
const sidecarPath = requireSidecarCheckout(root, config);
|
|
8150
9220
|
withSyncLock(root, peer.name, "throw", () => {
|
|
9221
|
+
bindReviewPolicy(sidecarPath, config);
|
|
8151
9222
|
const inbox = expandInbox(config, sidecarPath);
|
|
8152
9223
|
ensureCommitIdentity(sidecarPath);
|
|
8153
9224
|
ensureRedactionFilter(sidecarPath, config.redaction, config);
|
|
8154
9225
|
ensureInboxBranch(sidecarPath, config, inbox);
|
|
8155
9226
|
const committed = snapshot(sidecarPath, root, inbox, getValue(parsed, "--message", getValue(parsed, "-m", "")) || undefined, config.redaction, config);
|
|
8156
|
-
if (committed &&
|
|
9227
|
+
if (committed && push) {
|
|
8157
9228
|
syncBranchBeforePush(sidecarPath, inbox, config);
|
|
8158
9229
|
pushBranch(sidecarPath, inbox);
|
|
8159
9230
|
}
|
|
@@ -8171,36 +9242,47 @@ function cmdSync(args) {
|
|
|
8171
9242
|
}
|
|
8172
9243
|
const peers = loadPeers(selectedPeer(parsed), { loadRules: false });
|
|
8173
9244
|
const failed = [];
|
|
9245
|
+
const soft = parsed.flags.has("--soft") || process.env[SOFT_SYNC_ENV] === "1";
|
|
9246
|
+
const options = {
|
|
9247
|
+
lock: soft ? "skip" : "throw",
|
|
9248
|
+
remote: !parsed.flags.has("--local") && process.env[LOCAL_SYNC_ENV] !== "1",
|
|
9249
|
+
snapshot: !parsed.flags.has("--no-snapshot"),
|
|
9250
|
+
message: getValue(parsed, "--message", getValue(parsed, "-m", "")) || undefined,
|
|
9251
|
+
reviewFloor: soft
|
|
9252
|
+
};
|
|
8174
9253
|
for (const peer of peers) {
|
|
8175
9254
|
announcePeer(peer, peers);
|
|
8176
9255
|
try {
|
|
8177
9256
|
if (peer.config.rulesPath)
|
|
8178
9257
|
peer.config.rules = readRules(peer.config.rulesPath);
|
|
8179
|
-
syncPeer(peer,
|
|
9258
|
+
syncPeer(peer, options);
|
|
8180
9259
|
} catch (error) {
|
|
8181
9260
|
if (peers.length === 1)
|
|
8182
9261
|
throw error;
|
|
8183
|
-
failed.push(peer.name);
|
|
9262
|
+
failed.push({ name: peer.name, error });
|
|
8184
9263
|
console.error(`sidecar: ${peer.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
8185
9264
|
}
|
|
8186
9265
|
}
|
|
8187
9266
|
if (failed.length) {
|
|
8188
|
-
|
|
9267
|
+
const dirty = failed.every(({ error }) => error instanceof SidecarError && error.reason === "dirty-checkout");
|
|
9268
|
+
throw new SidecarError(`${failed.length} of ${peers.length} peers failed to sync: ${failed.map(({ name }) => name).join(", ")}`, dirty ? { reason: "dirty-checkout" } : {});
|
|
8189
9269
|
}
|
|
8190
9270
|
return 0;
|
|
8191
9271
|
}
|
|
8192
|
-
function syncPeer(peer,
|
|
9272
|
+
function syncPeer(peer, options) {
|
|
8193
9273
|
const { root, config } = peer;
|
|
8194
|
-
const
|
|
8195
|
-
const
|
|
9274
|
+
const { remote } = options;
|
|
9275
|
+
const soft = options.lock === "skip";
|
|
8196
9276
|
let stage = "start";
|
|
9277
|
+
let outcome = {};
|
|
8197
9278
|
let synced;
|
|
8198
9279
|
try {
|
|
8199
|
-
synced = withSyncLock(root, peer.name,
|
|
8200
|
-
syncProject(root, config, {
|
|
8201
|
-
snapshot:
|
|
9280
|
+
synced = withSyncLock(root, peer.name, options.lock, () => {
|
|
9281
|
+
outcome = syncProject(root, config, {
|
|
9282
|
+
snapshot: options.snapshot,
|
|
8202
9283
|
remote,
|
|
8203
|
-
message:
|
|
9284
|
+
message: options.message,
|
|
9285
|
+
reviewFloor: options.reviewFloor,
|
|
8204
9286
|
onStage: (name) => {
|
|
8205
9287
|
stage = name;
|
|
8206
9288
|
}
|
|
@@ -8208,17 +9290,25 @@ function syncPeer(peer, parsed) {
|
|
|
8208
9290
|
registerCurrentInstance(root, config, remote ? { event: "sync", lastSyncAt: nowIso() } : { event: "sync-local" });
|
|
8209
9291
|
});
|
|
8210
9292
|
} catch (error) {
|
|
9293
|
+
if (error instanceof SidecarError)
|
|
9294
|
+
error.stage ??= stage;
|
|
8211
9295
|
if (remote)
|
|
8212
9296
|
reportSyncHealth(root, config, {
|
|
8213
9297
|
status: "failed",
|
|
8214
9298
|
stage,
|
|
8215
|
-
message: error instanceof Error ? error.message : String(error)
|
|
9299
|
+
message: reviewRequired(config) ? "details are shown only by a sync run by hand on that machine" : error instanceof Error ? error.message : String(error)
|
|
8216
9300
|
});
|
|
8217
9301
|
throw error;
|
|
8218
9302
|
}
|
|
8219
9303
|
if (synced) {
|
|
8220
|
-
|
|
8221
|
-
|
|
9304
|
+
const held = outcome.held;
|
|
9305
|
+
if (held) {
|
|
9306
|
+
const { id } = held.review.record;
|
|
9307
|
+
console.log(held.attention ? `sidecar: publishing is held — review ${id} needs attention: ${held.attention}; \`sidecar review show ${id}\`` : `sidecar: publishing is held for review ${id}; \`sidecar review show ${id}\``);
|
|
9308
|
+
}
|
|
9309
|
+
if (remote) {
|
|
9310
|
+
reportSyncHealth(root, config, held ? { status: "ok", review: held.attention ? "attention" : "pending" } : outcome.reviewDue ? { status: "ok", review: "pending" } : { status: "ok" });
|
|
9311
|
+
}
|
|
8222
9312
|
if (!soft) {
|
|
8223
9313
|
const sidecarPath = resolveSidecarPath(root, config);
|
|
8224
9314
|
if (checkoutIsUnlinkedFromFamily(root, config, sidecarPath)) {
|
|
@@ -8243,12 +9333,16 @@ function cmdMerge(args) {
|
|
|
8243
9333
|
const { root, config } = peer;
|
|
8244
9334
|
const sidecarPath = requireSidecarCheckout(root, config);
|
|
8245
9335
|
withSyncLock(root, peer.name, "throw", () => {
|
|
9336
|
+
bindReviewPolicy(sidecarPath, config);
|
|
8246
9337
|
ensureRedactionFilter(sidecarPath, config.redaction, config);
|
|
8247
|
-
|
|
8248
|
-
|
|
8249
|
-
|
|
8250
|
-
|
|
8251
|
-
|
|
9338
|
+
const forkFiles = parsed.flags.has("--fork-files");
|
|
9339
|
+
const push = !parsed.flags.has("--no-push");
|
|
9340
|
+
if (reviewRequired(config) && push) {
|
|
9341
|
+
mergePublishedInboxes(sidecarPath, config, { forkFiles });
|
|
9342
|
+
mergeInboxBranches(sidecarPath, config, { forkFiles, push: false, remote: false });
|
|
9343
|
+
return;
|
|
9344
|
+
}
|
|
9345
|
+
mergeInboxBranches(sidecarPath, config, { forkFiles, push, remote: true });
|
|
8252
9346
|
});
|
|
8253
9347
|
}
|
|
8254
9348
|
return 0;
|
|
@@ -8272,7 +9366,7 @@ function printPeerRedactions({ root, config }) {
|
|
|
8272
9366
|
let shown = 0;
|
|
8273
9367
|
let items = 0;
|
|
8274
9368
|
for (const relPath of files) {
|
|
8275
|
-
const delta = fileRedactionDelta(
|
|
9369
|
+
const delta = fileRedactionDelta(path13.join(sidecarPath, relPath), config.redaction, { rules: config.rules, relativePath: relPath });
|
|
8276
9370
|
if (!delta)
|
|
8277
9371
|
continue;
|
|
8278
9372
|
if (shown)
|
|
@@ -8291,12 +9385,12 @@ ${items} redaction(s) in ${shown} file(s) will be pushed this way (default: ${co
|
|
|
8291
9385
|
console.log("local files are untouched; redaction is controlled by the peer's configuration and rules");
|
|
8292
9386
|
}
|
|
8293
9387
|
function printRedactionDiff(original, redacted) {
|
|
8294
|
-
const scratch =
|
|
9388
|
+
const scratch = fs15.mkdtempSync(path13.join(os6.tmpdir(), "sidecar-redactions-"));
|
|
8295
9389
|
try {
|
|
8296
|
-
const localPath =
|
|
8297
|
-
const pushedPath =
|
|
8298
|
-
|
|
8299
|
-
|
|
9390
|
+
const localPath = path13.join(scratch, "local");
|
|
9391
|
+
const pushedPath = path13.join(scratch, "pushed");
|
|
9392
|
+
fs15.writeFileSync(localPath, original, "utf8");
|
|
9393
|
+
fs15.writeFileSync(pushedPath, redacted, "utf8");
|
|
8300
9394
|
const color = colorLevel() > 0 ? ["--color"] : [];
|
|
8301
9395
|
const diff = gitRaw(["diff", "--no-index", ...color, "--", localPath, pushedPath], { check: false });
|
|
8302
9396
|
const lines = diff.stdout.split(`
|
|
@@ -8307,7 +9401,7 @@ function printRedactionDiff(original, redacted) {
|
|
|
8307
9401
|
if (body)
|
|
8308
9402
|
console.log(body);
|
|
8309
9403
|
} finally {
|
|
8310
|
-
|
|
9404
|
+
fs15.rmSync(scratch, { recursive: true, force: true });
|
|
8311
9405
|
}
|
|
8312
9406
|
}
|
|
8313
9407
|
function cmdRedact(args) {
|
|
@@ -8323,15 +9417,15 @@ function cmdRedact(args) {
|
|
|
8323
9417
|
}
|
|
8324
9418
|
const mode = redactionModeConfigValue(getValue(parsed, "--mode", DEFAULT_REDACTION_MODE), "--mode");
|
|
8325
9419
|
const rulesPath = parsed.values.get("--rules");
|
|
8326
|
-
const effective = checkoutPolicy ? checkoutRedactionPolicy(process.cwd()) : { mode, rules: rulesPath ? readRules(
|
|
9420
|
+
const effective = checkoutPolicy ? checkoutRedactionPolicy(process.cwd()) : { mode, rules: rulesPath ? readRules(path13.resolve(rulesPath)) : [] };
|
|
8327
9421
|
const relativePath = parsed.values.get("--path");
|
|
8328
9422
|
if ((checkoutPolicy || rulesPath) && !relativePath)
|
|
8329
9423
|
throw new SidecarError("redaction rules require --path");
|
|
8330
9424
|
const effectiveMode = relativePath ? resolveFileRules(effective.rules, relativePath, { resolve: "fork", redaction: effective.mode }).redaction : effective.mode;
|
|
8331
|
-
const output = redactBuffer(
|
|
9425
|
+
const output = redactBuffer(fs15.readFileSync(0), effectiveMode);
|
|
8332
9426
|
let offset = 0;
|
|
8333
9427
|
while (offset < output.length) {
|
|
8334
|
-
offset +=
|
|
9428
|
+
offset += fs15.writeSync(1, output, offset, output.length - offset);
|
|
8335
9429
|
}
|
|
8336
9430
|
return 0;
|
|
8337
9431
|
}
|
|
@@ -8347,6 +9441,228 @@ var init_cmd_sync = __esm(() => {
|
|
|
8347
9441
|
init_ui();
|
|
8348
9442
|
});
|
|
8349
9443
|
|
|
9444
|
+
// src/cmd-review.ts
|
|
9445
|
+
import fs16 from "node:fs";
|
|
9446
|
+
import path14 from "node:path";
|
|
9447
|
+
function cmdReview(args) {
|
|
9448
|
+
const [subcommand = "list", ...rest] = args[0]?.startsWith("-") ? ["list", ...args] : args;
|
|
9449
|
+
switch (subcommand) {
|
|
9450
|
+
case "list":
|
|
9451
|
+
return listCommand(rest);
|
|
9452
|
+
case "show":
|
|
9453
|
+
return showCommand(rest);
|
|
9454
|
+
case "approve":
|
|
9455
|
+
case "reject":
|
|
9456
|
+
return decideCommand(subcommand, rest);
|
|
9457
|
+
case "retry":
|
|
9458
|
+
return retryCommand(rest);
|
|
9459
|
+
case "reviewer":
|
|
9460
|
+
return reviewerCommand(rest);
|
|
9461
|
+
case "run":
|
|
9462
|
+
return runCommand(rest);
|
|
9463
|
+
default:
|
|
9464
|
+
throw new SidecarError(`unknown review command ${JSON.stringify(subcommand)}; ${USAGE}`);
|
|
9465
|
+
}
|
|
9466
|
+
}
|
|
9467
|
+
function listCommand(args) {
|
|
9468
|
+
const parsed = parseOptions(args, { boolean: new Set(["--json"]), value: new Set(["--peer"]) });
|
|
9469
|
+
if (parsed.positional.length)
|
|
9470
|
+
throw new SidecarError(USAGE);
|
|
9471
|
+
const peers = loadPeers(selectedPeer(parsed));
|
|
9472
|
+
if (parsed.flags.has("--json")) {
|
|
9473
|
+
console.log(JSON.stringify(peers.map((peer) => ({
|
|
9474
|
+
peer: peer.name,
|
|
9475
|
+
required: reviewRequired(peer.config),
|
|
9476
|
+
reviews: peerReviews(peer).map(reviewPayload)
|
|
9477
|
+
})), null, 2));
|
|
9478
|
+
return 0;
|
|
9479
|
+
}
|
|
9480
|
+
for (const peer of peers) {
|
|
9481
|
+
announcePeer(peer, peers);
|
|
9482
|
+
if (!reviewRequired(peer.config)) {
|
|
9483
|
+
console.log(paint("quiet", 'review is off for this peer; turn it on with review = "required" in its config'));
|
|
9484
|
+
continue;
|
|
9485
|
+
}
|
|
9486
|
+
const reviews = peerReviews(peer);
|
|
9487
|
+
if (!reviews.length) {
|
|
9488
|
+
console.log(paint("quiet", "no reviews yet; the next sync with unpublished changes opens one"));
|
|
9489
|
+
continue;
|
|
9490
|
+
}
|
|
9491
|
+
for (const review of reviews)
|
|
9492
|
+
printReviewRow(review);
|
|
9493
|
+
}
|
|
9494
|
+
return 0;
|
|
9495
|
+
}
|
|
9496
|
+
function peerReviews(peer) {
|
|
9497
|
+
const sidecarPath = resolveSidecarPath(peer.root, peer.config);
|
|
9498
|
+
return hasGitMetadata(sidecarPath) ? listReviews(sidecarPath) : [];
|
|
9499
|
+
}
|
|
9500
|
+
function printReviewRow(review) {
|
|
9501
|
+
const attention = reviewAttention(review);
|
|
9502
|
+
const age = formatRelativeTime(review.record.createdAt) ?? review.record.createdAt;
|
|
9503
|
+
const text = `${review.record.id} ${review.state.padEnd(10)} ${age}, ${review.record.files.length} file(s)`;
|
|
9504
|
+
if (attention)
|
|
9505
|
+
console.log(`${paint("attn", text)} needs attention: ${attention}`);
|
|
9506
|
+
else
|
|
9507
|
+
console.log(isOpen(review) ? text : paint("quiet", text));
|
|
9508
|
+
}
|
|
9509
|
+
function reviewPayload(review) {
|
|
9510
|
+
return {
|
|
9511
|
+
id: review.record.id,
|
|
9512
|
+
state: review.state,
|
|
9513
|
+
attention: reviewAttention(review),
|
|
9514
|
+
createdAt: review.record.createdAt,
|
|
9515
|
+
files: review.record.files,
|
|
9516
|
+
reason: review.verdict?.reason,
|
|
9517
|
+
decidedAt: review.verdict?.decidedAt,
|
|
9518
|
+
publishedAt: review.record.publishedAt,
|
|
9519
|
+
publishedCommit: review.record.publishedCommit,
|
|
9520
|
+
dir: review.dir
|
|
9521
|
+
};
|
|
9522
|
+
}
|
|
9523
|
+
function showCommand(args) {
|
|
9524
|
+
const parsed = parseOptions(args, { boolean: new Set(["--json"]), value: new Set(["--peer"]) });
|
|
9525
|
+
if (parsed.positional.length > 1)
|
|
9526
|
+
throw new SidecarError(USAGE);
|
|
9527
|
+
const review = selectReview(parsed.positional[0], selectedPeer(parsed));
|
|
9528
|
+
if (parsed.flags.has("--json")) {
|
|
9529
|
+
console.log(JSON.stringify(reviewPayload(review), null, 2));
|
|
9530
|
+
return 0;
|
|
9531
|
+
}
|
|
9532
|
+
const { record } = review;
|
|
9533
|
+
const line = (label, value, role) => labelLine(LABEL_WIDTH, label, value, role);
|
|
9534
|
+
const attention = reviewAttention(review);
|
|
9535
|
+
line("review", record.id, "brand");
|
|
9536
|
+
line("state", attention ? `${review.state} — needs attention: ${attention}` : review.state, attention ? "attn" : undefined);
|
|
9537
|
+
line("repo", record.root, "repo");
|
|
9538
|
+
if (record.peer !== "default")
|
|
9539
|
+
line("peer", record.peer);
|
|
9540
|
+
line("opened", formatRelativeTime(record.createdAt) ?? record.createdAt);
|
|
9541
|
+
if (review.verdict)
|
|
9542
|
+
line("decided", `${review.verdict.verdict} ${formatRelativeTime(review.verdict.decidedAt) ?? review.verdict.decidedAt}`);
|
|
9543
|
+
if (review.verdict?.reason)
|
|
9544
|
+
line("reason", review.verdict.reason);
|
|
9545
|
+
if (review.run)
|
|
9546
|
+
line("reviewer", review.run.command, "quiet");
|
|
9547
|
+
line("packet", review.dir, "quiet");
|
|
9548
|
+
console.log("");
|
|
9549
|
+
for (const file of record.files) {
|
|
9550
|
+
console.log(` ${file.status} ${file.path}${file.artifact ? paint("quiet", ` (not in the diff; full content: ${path14.join(review.dir, file.artifact)})`) : ""}`);
|
|
9551
|
+
}
|
|
9552
|
+
console.log("");
|
|
9553
|
+
process.stdout.write(fs16.readFileSync(path14.join(review.dir, "diff.patch"), "utf8"));
|
|
9554
|
+
if (isOpen(review)) {
|
|
9555
|
+
console.log("");
|
|
9556
|
+
console.log(paint("quiet", `decide with \`sidecar review approve ${record.id}\` or \`sidecar review reject ${record.id} --reason "..."\``));
|
|
9557
|
+
}
|
|
9558
|
+
return 0;
|
|
9559
|
+
}
|
|
9560
|
+
function decideCommand(verdict, args) {
|
|
9561
|
+
const parsed = parseOptions(args, { boolean: new Set(["--no-sync"]), value: new Set(["--reason", "--peer"]) });
|
|
9562
|
+
if (parsed.positional.length > 1)
|
|
9563
|
+
throw new SidecarError(USAGE);
|
|
9564
|
+
const selected = selectReview(parsed.positional[0], selectedPeer(parsed));
|
|
9565
|
+
const reason = parsed.values.get("--reason")?.trim() || undefined;
|
|
9566
|
+
const { id } = selected.record;
|
|
9567
|
+
withLockedReview(selected, (review) => decideReview(review, verdict === "approve" ? "approved" : "rejected", reason));
|
|
9568
|
+
if (verdict === "reject") {
|
|
9569
|
+
console.log(`rejected review ${paint("brand", id)}; nothing it covers is published`);
|
|
9570
|
+
console.log("change the files it flags and the next sync opens a new review, or approve it to override");
|
|
9571
|
+
return 0;
|
|
9572
|
+
}
|
|
9573
|
+
console.log(`approved review ${paint("brand", id)}`);
|
|
9574
|
+
if (parsed.flags.has("--no-sync")) {
|
|
9575
|
+
console.log("it publishes on the next sync");
|
|
9576
|
+
return 0;
|
|
9577
|
+
}
|
|
9578
|
+
const peer = loadPeer(selected.record.root, selected.record.peer);
|
|
9579
|
+
try {
|
|
9580
|
+
syncPeer(peer, { lock: "wait", remote: true, snapshot: true, reviewFloor: true });
|
|
9581
|
+
} catch (error) {
|
|
9582
|
+
if (readReview(id)?.state !== "published") {
|
|
9583
|
+
console.error(`sidecar: review ${id} is approved, but this sync could not confirm it published; the next sync finishes publishing it`);
|
|
9584
|
+
throw error;
|
|
9585
|
+
}
|
|
9586
|
+
logSidecarEvent("review-followup-failure", {
|
|
9587
|
+
id,
|
|
9588
|
+
...error instanceof SidecarError && error.reason ? { reason: error.reason } : {},
|
|
9589
|
+
...error instanceof SidecarError && error.stage ? { stage: error.stage } : {}
|
|
9590
|
+
});
|
|
9591
|
+
console.error(`sidecar: published review ${id}; the sync after it stopped early and the next sync finishes it: ${error instanceof Error ? error.message : String(error)}`);
|
|
9592
|
+
}
|
|
9593
|
+
return 0;
|
|
9594
|
+
}
|
|
9595
|
+
function retryCommand(args) {
|
|
9596
|
+
const parsed = parseOptions(args, { boolean: new Set, value: new Set(["--peer"]) });
|
|
9597
|
+
if (parsed.positional.length > 1)
|
|
9598
|
+
throw new SidecarError(USAGE);
|
|
9599
|
+
configuredReviewer();
|
|
9600
|
+
const selected = selectReview(parsed.positional[0], selectedPeer(parsed));
|
|
9601
|
+
const fresh = withLockedReview(selected, retryReview);
|
|
9602
|
+
console.log(`retired review ${paint("brand", selected.record.id)}; review ${paint("brand", fresh.record.id)} covers the same change, and its reviewer is running`);
|
|
9603
|
+
return 0;
|
|
9604
|
+
}
|
|
9605
|
+
function withLockedReview(selected, fn) {
|
|
9606
|
+
const { id, root, peer } = selected.record;
|
|
9607
|
+
let result;
|
|
9608
|
+
withSyncLock(root, peer, "wait", () => {
|
|
9609
|
+
const review = readReview(id);
|
|
9610
|
+
if (!review)
|
|
9611
|
+
throw new SidecarError(`review ${id} is gone`);
|
|
9612
|
+
result = fn(review);
|
|
9613
|
+
});
|
|
9614
|
+
return result;
|
|
9615
|
+
}
|
|
9616
|
+
function reviewerCommand(args) {
|
|
9617
|
+
const settings = readSettings();
|
|
9618
|
+
if (!args.length) {
|
|
9619
|
+
console.log(settings.reviewer ?? paint("quiet", "no reviewer configured"));
|
|
9620
|
+
return 0;
|
|
9621
|
+
}
|
|
9622
|
+
if (args.length === 1 && args[0] === "--unset") {
|
|
9623
|
+
writeSettings({ ...settings, reviewer: undefined });
|
|
9624
|
+
console.log("reviewer unset; reviews now wait for `sidecar review approve`");
|
|
9625
|
+
return 0;
|
|
9626
|
+
}
|
|
9627
|
+
if (args.length !== 1) {
|
|
9628
|
+
throw new SidecarError('usage: sidecar review reviewer ["<command>"|--unset]; quote the command as one argument');
|
|
9629
|
+
}
|
|
9630
|
+
const command = args[0].trim();
|
|
9631
|
+
if (!command)
|
|
9632
|
+
throw new SidecarError("the reviewer command is empty");
|
|
9633
|
+
writeSettings({ ...settings, reviewer: command });
|
|
9634
|
+
console.log(`reviewer set to ${paint("brand", command)}`);
|
|
9635
|
+
return 0;
|
|
9636
|
+
}
|
|
9637
|
+
function runCommand(args) {
|
|
9638
|
+
if (args.length !== 1)
|
|
9639
|
+
throw new SidecarError("usage: sidecar review run <id>");
|
|
9640
|
+
return runReviewer(args[0]);
|
|
9641
|
+
}
|
|
9642
|
+
function selectReview(id, peer) {
|
|
9643
|
+
if (id)
|
|
9644
|
+
return findReview(id);
|
|
9645
|
+
const open = loadPeers(peer).map((candidate) => resolveSidecarPath(candidate.root, candidate.config)).filter(hasGitMetadata).map(openReview).filter((review) => review !== undefined);
|
|
9646
|
+
if (open.length === 1)
|
|
9647
|
+
return open[0];
|
|
9648
|
+
if (!open.length)
|
|
9649
|
+
throw new SidecarError("no open review here; pass a review id (`sidecar review` lists them)");
|
|
9650
|
+
throw new SidecarError(`more than one open review here (${open.map((review) => review.record.id).join(", ")}); pass the id`);
|
|
9651
|
+
}
|
|
9652
|
+
var USAGE = "usage: sidecar review [list|show|approve|reject|retry|reviewer] [id] [--reason text] [--no-sync] [--json] [--peer name]", LABEL_WIDTH;
|
|
9653
|
+
var init_cmd_review = __esm(() => {
|
|
9654
|
+
init_color();
|
|
9655
|
+
init_util();
|
|
9656
|
+
init_git();
|
|
9657
|
+
init_config();
|
|
9658
|
+
init_state();
|
|
9659
|
+
init_review();
|
|
9660
|
+
init_sync();
|
|
9661
|
+
init_cmd_sync();
|
|
9662
|
+
init_ui();
|
|
9663
|
+
LABEL_WIDTH = "reviewer:".length;
|
|
9664
|
+
});
|
|
9665
|
+
|
|
8350
9666
|
// src/commands.ts
|
|
8351
9667
|
function run(argv) {
|
|
8352
9668
|
const [command, ...rest] = argv;
|
|
@@ -8420,12 +9736,13 @@ var init_commands = __esm(() => {
|
|
|
8420
9736
|
init_cmd_status();
|
|
8421
9737
|
init_cmd_daemon();
|
|
8422
9738
|
init_cmd_sync();
|
|
9739
|
+
init_cmd_review();
|
|
8423
9740
|
COMMANDS = [
|
|
8424
9741
|
{
|
|
8425
9742
|
name: "init",
|
|
8426
9743
|
run: cmdInit,
|
|
8427
9744
|
section: "common",
|
|
8428
|
-
usage: "init [remote] [--peer name] [--path sidecar|.] [--branch main] [--inbox template] [--redaction none|secrets|secrets+pii] [--resolve fork|lww] [--debounce 10m] [--interval 1h] [--ignored] [--local-install]",
|
|
9745
|
+
usage: "init [remote] [--peer name] [--path sidecar|.] [--branch main] [--inbox template] [--redaction none|secrets|secrets+pii] [--resolve fork|lww] [--review off|required] [--review-timeout 1h] [--debounce 10m] [--interval 1h] [--ignored] [--local-install]",
|
|
8429
9746
|
notes: [
|
|
8430
9747
|
"--path . makes this repo itself the sidecar (standalone)",
|
|
8431
9748
|
"--peer name adds a second sidecar as .sidecar.name; --ignored keeps it out of the tree",
|
|
@@ -8447,6 +9764,16 @@ var init_commands = __esm(() => {
|
|
|
8447
9764
|
usage: "redactions [--peer name]",
|
|
8448
9765
|
summary: "preview what redaction rewrites before content is pushed"
|
|
8449
9766
|
},
|
|
9767
|
+
{
|
|
9768
|
+
name: "review",
|
|
9769
|
+
run: cmdReview,
|
|
9770
|
+
section: "common",
|
|
9771
|
+
usage: "review [show|approve|reject|retry] [id] [--reason text] [--no-sync] [--json] [--peer name]",
|
|
9772
|
+
notes: [
|
|
9773
|
+
'the reviews that gate a peer with review = "required"; approve publishes at once',
|
|
9774
|
+
"review reviewer [<command>|--unset] sets who reviews on this machine"
|
|
9775
|
+
]
|
|
9776
|
+
},
|
|
8450
9777
|
{
|
|
8451
9778
|
name: "sync",
|
|
8452
9779
|
run: cmdSync,
|
|
@@ -8517,12 +9844,14 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
8517
9844
|
if (command !== "redact" && command !== "deinit" && shouldUseGlobalRegistry()) {
|
|
8518
9845
|
logSidecarEvent("failure", {
|
|
8519
9846
|
command,
|
|
8520
|
-
|
|
9847
|
+
...error instanceof SidecarError && error.reason ? { reason: error.reason } : {},
|
|
9848
|
+
...error instanceof SidecarError && error.stage ? { stage: error.stage } : {},
|
|
9849
|
+
...privateFailures() ? {} : { message: error instanceof Error ? error.message : String(error) }
|
|
8521
9850
|
});
|
|
8522
9851
|
}
|
|
8523
9852
|
if (error instanceof SidecarError) {
|
|
8524
9853
|
console.error(`${paint("bad", "sidecar:", colorLevel(process.stderr))} ${error.message}`);
|
|
8525
|
-
return 1;
|
|
9854
|
+
return error.reason === "dirty-checkout" ? DIRTY_CHECKOUT_EXIT : 1;
|
|
8526
9855
|
}
|
|
8527
9856
|
if (error instanceof Error && error.name === "AbortError") {
|
|
8528
9857
|
console.error("sidecar: stopped");
|
|
@@ -8535,6 +9864,7 @@ var init_cli = __esm(() => {
|
|
|
8535
9864
|
init_color();
|
|
8536
9865
|
init_util();
|
|
8537
9866
|
init_install();
|
|
9867
|
+
init_config();
|
|
8538
9868
|
init_state();
|
|
8539
9869
|
init_commands();
|
|
8540
9870
|
init_util();
|
|
@@ -8556,20 +9886,20 @@ var init_cli = __esm(() => {
|
|
|
8556
9886
|
|
|
8557
9887
|
// src/bin.ts
|
|
8558
9888
|
init_cli();
|
|
8559
|
-
import
|
|
8560
|
-
import
|
|
8561
|
-
import { spawnSync as
|
|
8562
|
-
import { fileURLToPath as
|
|
9889
|
+
import fs17 from "node:fs";
|
|
9890
|
+
import path15 from "node:path";
|
|
9891
|
+
import { spawnSync as spawnSync6 } from "node:child_process";
|
|
9892
|
+
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
8563
9893
|
var SKIP_LOCAL_EXEC_ENV3 = "SIDECAR_SKIP_LOCAL_EXEC";
|
|
8564
9894
|
var GLOBAL_EXEC_ENV3 = "SIDECAR_GLOBAL_EXEC";
|
|
8565
9895
|
var PACKAGE_NAME2 = "sidecarsync";
|
|
8566
9896
|
var GLOBAL_ONLY_COMMANDS = new Set(["daemon", "deinit", "register-install", "set-install-source", "update"]);
|
|
8567
9897
|
if (process.argv[2] !== "redact" && !process.env[SKIP_LOCAL_EXEC_ENV3]) {
|
|
8568
|
-
const local = findLocalInstall(process.cwd(),
|
|
9898
|
+
const local = findLocalInstall(process.cwd(), fileURLToPath3(import.meta.url));
|
|
8569
9899
|
if (local) {
|
|
8570
9900
|
process.env[GLOBAL_EXEC_ENV3] = "1";
|
|
8571
9901
|
if (local.newer && !GLOBAL_ONLY_COMMANDS.has(process.argv[2])) {
|
|
8572
|
-
const result =
|
|
9902
|
+
const result = spawnSync6(process.execPath, [local.executable, ...process.argv.slice(2)], {
|
|
8573
9903
|
stdio: "inherit",
|
|
8574
9904
|
env: {
|
|
8575
9905
|
...process.env,
|
|
@@ -8586,15 +9916,15 @@ if (process.argv[2] !== "redact" && !process.env[SKIP_LOCAL_EXEC_ENV3]) {
|
|
|
8586
9916
|
}
|
|
8587
9917
|
process.exit(await main());
|
|
8588
9918
|
function findLocalInstall(start, self) {
|
|
8589
|
-
let current =
|
|
9919
|
+
let current = path15.resolve(start);
|
|
8590
9920
|
while (true) {
|
|
8591
9921
|
if (projectDependsOnSidecar2(current)) {
|
|
8592
|
-
const candidate =
|
|
9922
|
+
const candidate = path15.join(current, "node_modules", PACKAGE_NAME2, "dist", "cli.js");
|
|
8593
9923
|
if (isFile2(candidate) && !sameFile(candidate, self)) {
|
|
8594
9924
|
return { executable: candidate, newer: localIsNewer(current) };
|
|
8595
9925
|
}
|
|
8596
9926
|
}
|
|
8597
|
-
const parent =
|
|
9927
|
+
const parent = path15.dirname(current);
|
|
8598
9928
|
if (parent === current)
|
|
8599
9929
|
return;
|
|
8600
9930
|
current = parent;
|
|
@@ -8605,11 +9935,11 @@ function localIsNewer(projectRoot) {
|
|
|
8605
9935
|
return localVersion !== undefined && compareVersions(localVersion, packageVersion()) > 0;
|
|
8606
9936
|
}
|
|
8607
9937
|
function projectDependsOnSidecar2(projectRoot) {
|
|
8608
|
-
const manifestPath =
|
|
9938
|
+
const manifestPath = path15.join(projectRoot, "package.json");
|
|
8609
9939
|
if (!isFile2(manifestPath))
|
|
8610
9940
|
return false;
|
|
8611
9941
|
try {
|
|
8612
|
-
const manifest = JSON.parse(
|
|
9942
|
+
const manifest = JSON.parse(fs17.readFileSync(manifestPath, "utf8"));
|
|
8613
9943
|
return Boolean(manifest.dependencies?.[PACKAGE_NAME2] || manifest.devDependencies?.[PACKAGE_NAME2] || manifest.optionalDependencies?.[PACKAGE_NAME2] || manifest.peerDependencies?.[PACKAGE_NAME2]);
|
|
8614
9944
|
} catch {
|
|
8615
9945
|
return false;
|
|
@@ -8617,14 +9947,14 @@ function projectDependsOnSidecar2(projectRoot) {
|
|
|
8617
9947
|
}
|
|
8618
9948
|
function isFile2(filePath) {
|
|
8619
9949
|
try {
|
|
8620
|
-
return
|
|
9950
|
+
return fs17.statSync(filePath).isFile();
|
|
8621
9951
|
} catch {
|
|
8622
9952
|
return false;
|
|
8623
9953
|
}
|
|
8624
9954
|
}
|
|
8625
9955
|
function sameFile(first, second) {
|
|
8626
9956
|
try {
|
|
8627
|
-
return
|
|
9957
|
+
return fs17.realpathSync(first) === fs17.realpathSync(second);
|
|
8628
9958
|
} catch {
|
|
8629
9959
|
return false;
|
|
8630
9960
|
}
|