sidecarsync 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -3
- package/dist/cli.js +1676 -508
- package/package.json +6 -2
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;
|
|
@@ -229,6 +232,10 @@ import os2 from "node:os";
|
|
|
229
232
|
import path2 from "node:path";
|
|
230
233
|
import { spawnSync } from "node:child_process";
|
|
231
234
|
import { fileURLToPath } from "node:url";
|
|
235
|
+
function sidecarCliPath() {
|
|
236
|
+
const self = fileURLToPath(import.meta.url);
|
|
237
|
+
return self.endsWith(".ts") ? path2.join(path2.dirname(self), "..", "dist", "cli.js") : self;
|
|
238
|
+
}
|
|
232
239
|
function packageVersion() {
|
|
233
240
|
let current = path2.dirname(fileURLToPath(import.meta.url));
|
|
234
241
|
while (true) {
|
|
@@ -324,147 +331,6 @@ var init_install = __esm(() => {
|
|
|
324
331
|
INSTALL_SOURCES = new Set(["npm", "bun", "curl"]);
|
|
325
332
|
});
|
|
326
333
|
|
|
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
334
|
// ../../node_modules/.bun/smol-toml@1.7.0/node_modules/smol-toml/dist/date.js
|
|
469
335
|
var DATE_TIME_RE, TomlDate;
|
|
470
336
|
var init_date = __esm(() => {
|
|
@@ -1428,6 +1294,157 @@ var init_dist = __esm(() => {
|
|
|
1428
1294
|
*/
|
|
1429
1295
|
});
|
|
1430
1296
|
|
|
1297
|
+
// src/git.ts
|
|
1298
|
+
import fs3 from "node:fs";
|
|
1299
|
+
import path3 from "node:path";
|
|
1300
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
1301
|
+
function git(repo, args, options = {}) {
|
|
1302
|
+
return gitRaw(["-C", repo, ...args], options);
|
|
1303
|
+
}
|
|
1304
|
+
function gitBytes(repo, args, options = {}) {
|
|
1305
|
+
const check = options.check ?? true;
|
|
1306
|
+
const result = spawnSync2("git", ["-C", repo, ...args], {
|
|
1307
|
+
encoding: "buffer",
|
|
1308
|
+
maxBuffer: 100 * 1024 * 1024
|
|
1309
|
+
});
|
|
1310
|
+
const status = result.status ?? 1;
|
|
1311
|
+
const stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? "");
|
|
1312
|
+
const stderr = Buffer.isBuffer(result.stderr) ? result.stderr : Buffer.from(result.stderr ?? "");
|
|
1313
|
+
if (check && status !== 0) {
|
|
1314
|
+
throw new SidecarError(stderr.toString("utf8").trim() || stdout.toString("utf8").trim());
|
|
1315
|
+
}
|
|
1316
|
+
return { status, stdout, stderr };
|
|
1317
|
+
}
|
|
1318
|
+
function gitRaw(args, options = {}) {
|
|
1319
|
+
const check = options.check ?? true;
|
|
1320
|
+
const result = spawnSync2("git", args, {
|
|
1321
|
+
encoding: "utf8",
|
|
1322
|
+
input: options.input,
|
|
1323
|
+
maxBuffer: 100 * 1024 * 1024
|
|
1324
|
+
});
|
|
1325
|
+
const status = result.status ?? 1;
|
|
1326
|
+
const stdout = result.stdout ?? "";
|
|
1327
|
+
const stderr = result.stderr ?? "";
|
|
1328
|
+
if (check && status !== 0) {
|
|
1329
|
+
throw new SidecarError(stderr.trim() || stdout.trim());
|
|
1330
|
+
}
|
|
1331
|
+
return { status, stdout, stderr };
|
|
1332
|
+
}
|
|
1333
|
+
function fetch(repo, quiet, check = true) {
|
|
1334
|
+
const args = ["fetch", "--prune", "origin", "+refs/heads/*:refs/remotes/origin/*"];
|
|
1335
|
+
if (quiet)
|
|
1336
|
+
args.splice(1, 0, "--quiet");
|
|
1337
|
+
git(repo, args, { check });
|
|
1338
|
+
}
|
|
1339
|
+
function hasAnyCommit(repo) {
|
|
1340
|
+
return git(repo, ["rev-parse", "--verify", "HEAD"], { check: false }).status === 0;
|
|
1341
|
+
}
|
|
1342
|
+
function branchExists(repo, branch) {
|
|
1343
|
+
return git(repo, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], { check: false }).status === 0;
|
|
1344
|
+
}
|
|
1345
|
+
function remoteRefExists(repo, branch) {
|
|
1346
|
+
return git(repo, ["show-ref", "--verify", "--quiet", `refs/remotes/origin/${branch}`], {
|
|
1347
|
+
check: false
|
|
1348
|
+
}).status === 0;
|
|
1349
|
+
}
|
|
1350
|
+
function isAncestor(repo, maybeAncestor, descendant) {
|
|
1351
|
+
return git(repo, ["merge-base", "--is-ancestor", maybeAncestor, descendant], { check: false }).status === 0;
|
|
1352
|
+
}
|
|
1353
|
+
function treeOf(repo, ref) {
|
|
1354
|
+
return git(repo, ["rev-parse", `${ref}^{tree}`]).stdout.trim();
|
|
1355
|
+
}
|
|
1356
|
+
function emptyTree(repo) {
|
|
1357
|
+
return git(repo, ["mktree"], { input: "" }).stdout.trim();
|
|
1358
|
+
}
|
|
1359
|
+
function gitToplevel(cwd) {
|
|
1360
|
+
const root = gitToplevelOptional(cwd);
|
|
1361
|
+
if (!root)
|
|
1362
|
+
throw new SidecarError("not inside a Git repository");
|
|
1363
|
+
return root;
|
|
1364
|
+
}
|
|
1365
|
+
function gitToplevelOptional(cwd) {
|
|
1366
|
+
const result = gitRaw(["-C", cwd, "rev-parse", "--show-toplevel"], { check: false });
|
|
1367
|
+
if (result.status !== 0)
|
|
1368
|
+
return;
|
|
1369
|
+
return result.stdout.trim();
|
|
1370
|
+
}
|
|
1371
|
+
function gitCommonDir(root) {
|
|
1372
|
+
const commonDir = gitCommonDirOptional(root);
|
|
1373
|
+
if (!commonDir)
|
|
1374
|
+
throw new SidecarError("not inside a Git repository");
|
|
1375
|
+
return commonDir;
|
|
1376
|
+
}
|
|
1377
|
+
function gitExcludePath(root) {
|
|
1378
|
+
const commonDir = gitCommonDirOptional(root);
|
|
1379
|
+
return commonDir ? path3.join(commonDir, "info", "exclude") : undefined;
|
|
1380
|
+
}
|
|
1381
|
+
function isGitIgnored(root, relativePath) {
|
|
1382
|
+
return git(root, ["check-ignore", "-q", "--", relativePath], { check: false }).status === 0;
|
|
1383
|
+
}
|
|
1384
|
+
function isGitTracked(root, relativePath) {
|
|
1385
|
+
return git(root, ["ls-files", "--error-unmatch", "--", relativePath], { check: false }).status === 0;
|
|
1386
|
+
}
|
|
1387
|
+
function gitCommonDirOptional(root) {
|
|
1388
|
+
const result = gitRaw(["-C", root, "rev-parse", "--git-common-dir"], { check: false });
|
|
1389
|
+
if (result.status !== 0)
|
|
1390
|
+
return;
|
|
1391
|
+
return path3.resolve(root, result.stdout.trim());
|
|
1392
|
+
}
|
|
1393
|
+
function gitDir(repo) {
|
|
1394
|
+
const result = git(repo, ["rev-parse", "--git-dir"]).stdout.trim();
|
|
1395
|
+
return path3.isAbsolute(result) ? result : path3.resolve(repo, result);
|
|
1396
|
+
}
|
|
1397
|
+
function hasGitMetadata(repo) {
|
|
1398
|
+
return fs3.existsSync(path3.join(repo, ".git"));
|
|
1399
|
+
}
|
|
1400
|
+
function isDirty(repo) {
|
|
1401
|
+
return Boolean(git(repo, ["status", "--porcelain"]).stdout.trim());
|
|
1402
|
+
}
|
|
1403
|
+
function ensureClean(repo) {
|
|
1404
|
+
if (isDirty(repo))
|
|
1405
|
+
throw new SidecarError("sidecar checkout has uncommitted changes");
|
|
1406
|
+
}
|
|
1407
|
+
function sidecarIdentity() {
|
|
1408
|
+
return { name: currentUser(), email: `${slug(currentUser())}@${slug(currentHost())}.local` };
|
|
1409
|
+
}
|
|
1410
|
+
function ensureCommitIdentity(repo) {
|
|
1411
|
+
const { name, email } = sidecarIdentity();
|
|
1412
|
+
if (git(repo, ["config", "user.name"], { check: false }).status !== 0) {
|
|
1413
|
+
git(repo, ["config", "user.name", name]);
|
|
1414
|
+
}
|
|
1415
|
+
if (git(repo, ["config", "user.email"], { check: false }).status !== 0) {
|
|
1416
|
+
git(repo, ["config", "user.email", email]);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
function familyPrimaryRoot(root) {
|
|
1420
|
+
const primary = jjDefaultWorkspace(root) ?? gitMainWorktree(root);
|
|
1421
|
+
if (!primary)
|
|
1422
|
+
return;
|
|
1423
|
+
return realpathOr(primary) === realpathOr(root) ? undefined : primary;
|
|
1424
|
+
}
|
|
1425
|
+
function jjDefaultWorkspace(root) {
|
|
1426
|
+
const pointer = path3.join(root, ".jj", "repo");
|
|
1427
|
+
try {
|
|
1428
|
+
if (!fs3.statSync(pointer).isFile())
|
|
1429
|
+
return;
|
|
1430
|
+
const repo = path3.resolve(path3.dirname(pointer), fs3.readFileSync(pointer, "utf8").trim());
|
|
1431
|
+
const workspace = path3.dirname(path3.dirname(repo));
|
|
1432
|
+
return fs3.existsSync(path3.join(workspace, ".jj")) ? workspace : undefined;
|
|
1433
|
+
} catch {
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
function gitMainWorktree(root) {
|
|
1438
|
+
const result = git(root, ["worktree", "list", "--porcelain"], { check: false });
|
|
1439
|
+
if (result.status !== 0)
|
|
1440
|
+
return;
|
|
1441
|
+
const entry = result.stdout.split(/\r?\n/).find((line) => line.startsWith("worktree "));
|
|
1442
|
+
return entry?.slice("worktree ".length).trim() || undefined;
|
|
1443
|
+
}
|
|
1444
|
+
var init_git = __esm(() => {
|
|
1445
|
+
init_util();
|
|
1446
|
+
});
|
|
1447
|
+
|
|
1431
1448
|
// src/redaction.ts
|
|
1432
1449
|
function redactText(input, mode = DEFAULT_REDACTION_MODE) {
|
|
1433
1450
|
if (mode === "none")
|
|
@@ -1582,7 +1599,8 @@ function parseHealthRecord(text) {
|
|
|
1582
1599
|
lastFailureAt: typeof record.lastFailureAt === "string" ? record.lastFailureAt : undefined,
|
|
1583
1600
|
consecutiveFailures: typeof record.consecutiveFailures === "number" && Number.isFinite(record.consecutiveFailures) ? record.consecutiveFailures : 0,
|
|
1584
1601
|
stage: typeof record.stage === "string" ? record.stage : undefined,
|
|
1585
|
-
message: typeof record.message === "string" ? record.message : undefined
|
|
1602
|
+
message: typeof record.message === "string" ? record.message : undefined,
|
|
1603
|
+
review: record.review === "pending" || record.review === "attention" ? record.review : undefined
|
|
1586
1604
|
};
|
|
1587
1605
|
}
|
|
1588
1606
|
function serializeHealthRecord(record) {
|
|
@@ -1595,24 +1613,27 @@ function nextHealthRecord(previous, identity, outcome, now) {
|
|
|
1595
1613
|
...identity,
|
|
1596
1614
|
status: outcome.status,
|
|
1597
1615
|
updatedAt: now,
|
|
1598
|
-
lastSuccessAt: previous?.lastSuccessAt,
|
|
1599
|
-
lastFailureAt: previous?.lastFailureAt,
|
|
1616
|
+
lastSuccessAt: isoTimestamp(previous?.lastSuccessAt),
|
|
1617
|
+
lastFailureAt: isoTimestamp(previous?.lastFailureAt),
|
|
1600
1618
|
consecutiveFailures: 0
|
|
1601
1619
|
};
|
|
1602
1620
|
if (outcome.status === "ok")
|
|
1603
|
-
return { ...base, lastSuccessAt: now };
|
|
1621
|
+
return { ...base, lastSuccessAt: now, ...outcome.review ? { review: outcome.review } : {} };
|
|
1604
1622
|
return {
|
|
1605
1623
|
...base,
|
|
1606
1624
|
lastFailureAt: now,
|
|
1607
|
-
consecutiveFailures: (previous?.consecutiveFailures ?? 0) + 1,
|
|
1625
|
+
consecutiveFailures: Math.max(0, Math.floor(previous?.consecutiveFailures ?? 0)) + 1,
|
|
1608
1626
|
stage: outcome.stage,
|
|
1609
1627
|
message: redactText(outcome.message).trim().slice(-MESSAGE_LIMIT)
|
|
1610
1628
|
};
|
|
1611
1629
|
}
|
|
1630
|
+
function isoTimestamp(value) {
|
|
1631
|
+
return value && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/.test(value) ? value : undefined;
|
|
1632
|
+
}
|
|
1612
1633
|
function shouldPublishHealth(previous, next, now = Date.now()) {
|
|
1613
1634
|
if (!previous)
|
|
1614
1635
|
return true;
|
|
1615
|
-
if (next.status === "failed" || previous.status !== next.status)
|
|
1636
|
+
if (next.status === "failed" || previous.status !== next.status || previous.review !== next.review)
|
|
1616
1637
|
return true;
|
|
1617
1638
|
const last = Date.parse(previous.updatedAt);
|
|
1618
1639
|
if (!Number.isFinite(last))
|
|
@@ -3538,6 +3559,22 @@ function ensureDistinctCheckouts(peers) {
|
|
|
3538
3559
|
function sameRemoteKey(remote) {
|
|
3539
3560
|
return remote.trim().replace(/\/+$/, "").replace(/\.git$/, "");
|
|
3540
3561
|
}
|
|
3562
|
+
function reviewRequired(config) {
|
|
3563
|
+
return config.review === "required";
|
|
3564
|
+
}
|
|
3565
|
+
function privateFailures() {
|
|
3566
|
+
return failuresArePrivate;
|
|
3567
|
+
}
|
|
3568
|
+
function keepFailuresPrivate() {
|
|
3569
|
+
failuresArePrivate = true;
|
|
3570
|
+
}
|
|
3571
|
+
function notePeerPolicy(config) {
|
|
3572
|
+
if (reviewRequired(config))
|
|
3573
|
+
keepFailuresPrivate();
|
|
3574
|
+
}
|
|
3575
|
+
function reviewTimeoutSeconds(config) {
|
|
3576
|
+
return config.reviewTimeout ?? DEFAULT_REVIEW_TIMEOUT_SECONDS;
|
|
3577
|
+
}
|
|
3541
3578
|
function findConfigRoot(start) {
|
|
3542
3579
|
const root = findConfigRootOptional(start);
|
|
3543
3580
|
if (root)
|
|
@@ -3556,7 +3593,11 @@ function findConfigRootOptional(start) {
|
|
|
3556
3593
|
}
|
|
3557
3594
|
}
|
|
3558
3595
|
function writeConfig(configPath, config) {
|
|
3559
|
-
|
|
3596
|
+
notePeerPolicy(config);
|
|
3597
|
+
fs5.writeFileSync(configPath, configText(config), "utf8");
|
|
3598
|
+
}
|
|
3599
|
+
function configText(config) {
|
|
3600
|
+
return [
|
|
3560
3601
|
`version = ${config.version}`,
|
|
3561
3602
|
`remote = ${JSON.stringify(config.remote)}`,
|
|
3562
3603
|
`path = ${JSON.stringify(config.path)}`,
|
|
@@ -3566,10 +3607,11 @@ function writeConfig(configPath, config) {
|
|
|
3566
3607
|
`resolve = ${JSON.stringify(config.resolve ?? DEFAULT_RESOLVE)}`,
|
|
3567
3608
|
...config.debounce === undefined ? [] : [`debounce = ${config.debounce}`],
|
|
3568
3609
|
...config.interval === undefined ? [] : [`interval = ${config.interval}`],
|
|
3610
|
+
...reviewRequired(config) ? [`review = "required"`] : [],
|
|
3611
|
+
...config.reviewTimeout === undefined ? [] : [`review-timeout = ${config.reviewTimeout}`],
|
|
3569
3612
|
""
|
|
3570
3613
|
].join(`
|
|
3571
3614
|
`);
|
|
3572
|
-
fs5.writeFileSync(configPath, text, "utf8");
|
|
3573
3615
|
}
|
|
3574
3616
|
function readConfig(configPath, options = {}) {
|
|
3575
3617
|
let values;
|
|
@@ -3580,10 +3622,22 @@ function readConfig(configPath, options = {}) {
|
|
|
3580
3622
|
}
|
|
3581
3623
|
values = parsed;
|
|
3582
3624
|
} catch (error) {
|
|
3625
|
+
if (error.code !== "ENOENT")
|
|
3626
|
+
keepFailuresPrivate();
|
|
3583
3627
|
if (error instanceof SidecarError)
|
|
3584
3628
|
throw error;
|
|
3585
3629
|
throw new SidecarError(`${configPath} is not valid TOML: ${error instanceof Error ? error.message : String(error)}`);
|
|
3586
3630
|
}
|
|
3631
|
+
if (values.review !== undefined && values.review !== "off")
|
|
3632
|
+
keepFailuresPrivate();
|
|
3633
|
+
const unknown = Object.keys(values).filter((key) => !CONFIG_KEYS.has(key));
|
|
3634
|
+
if (unknown.length) {
|
|
3635
|
+
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\``);
|
|
3636
|
+
}
|
|
3637
|
+
const version = numberConfigValue(configPath, values, "version", CONFIG_VERSION);
|
|
3638
|
+
if (version !== CONFIG_VERSION) {
|
|
3639
|
+
throw new SidecarError(`${configPath}: config version ${version} is not supported by this sidecar (expected ${CONFIG_VERSION}); update it with \`sidecar update\``);
|
|
3640
|
+
}
|
|
3587
3641
|
const remote = optionalStringConfigValue(configPath, values, "remote");
|
|
3588
3642
|
if (!remote)
|
|
3589
3643
|
throw new SidecarError(`${configPath} is missing remote`);
|
|
@@ -3592,7 +3646,7 @@ function readConfig(configPath, options = {}) {
|
|
|
3592
3646
|
const config = {
|
|
3593
3647
|
peer,
|
|
3594
3648
|
remote,
|
|
3595
|
-
version
|
|
3649
|
+
version,
|
|
3596
3650
|
path: stringConfigValue(configPath, values, "path", DEFAULT_PATH),
|
|
3597
3651
|
branch: stringConfigValue(configPath, values, "branch", DEFAULT_BRANCH),
|
|
3598
3652
|
inbox: stringConfigValue(configPath, values, "inbox", DEFAULT_INBOX),
|
|
@@ -3600,6 +3654,8 @@ function readConfig(configPath, options = {}) {
|
|
|
3600
3654
|
resolve: resolveModeConfigValue(stringConfigValue(configPath, values, "resolve", DEFAULT_RESOLVE), configPath),
|
|
3601
3655
|
debounce: durationConfigValue(values.debounce, `${configPath} debounce`),
|
|
3602
3656
|
interval: durationConfigValue(values.interval, `${configPath} interval`),
|
|
3657
|
+
review: reviewModeConfigValue(stringConfigValue(configPath, values, "review", "off"), configPath),
|
|
3658
|
+
reviewTimeout: durationConfigValue(values["review-timeout"], `${configPath} review-timeout`),
|
|
3603
3659
|
rules: options.loadRules === false ? undefined : readRules(rulesPath),
|
|
3604
3660
|
rulesPath
|
|
3605
3661
|
};
|
|
@@ -3616,6 +3672,11 @@ function redactionModeConfigValue(value, source) {
|
|
|
3616
3672
|
return value;
|
|
3617
3673
|
throw new SidecarError(`${source}: invalid redaction mode ${JSON.stringify(value)}; expected one of ${REDACTION_MODES.join(", ")}`);
|
|
3618
3674
|
}
|
|
3675
|
+
function reviewModeConfigValue(value, source) {
|
|
3676
|
+
if (REVIEW_MODES.includes(value))
|
|
3677
|
+
return value;
|
|
3678
|
+
throw new SidecarError(`${source}: invalid review mode ${JSON.stringify(value)}; expected one of ${REVIEW_MODES.join(", ")}`);
|
|
3679
|
+
}
|
|
3619
3680
|
function durationConfigValue(value, source) {
|
|
3620
3681
|
if (value === undefined)
|
|
3621
3682
|
return;
|
|
@@ -3736,6 +3797,8 @@ function inboxPrefix(config) {
|
|
|
3736
3797
|
return inboxBranchPrefix(config.inbox);
|
|
3737
3798
|
}
|
|
3738
3799
|
function remoteBranchName(remoteBranch) {
|
|
3800
|
+
if (remoteBranch.startsWith(PUBLIC_REFS))
|
|
3801
|
+
return remoteBranch.slice(PUBLIC_REFS.length);
|
|
3739
3802
|
return remoteBranch.startsWith("origin/") ? remoteBranch.slice("origin/".length) : remoteBranch;
|
|
3740
3803
|
}
|
|
3741
3804
|
function matchesInboxPrefix(prefix, branch) {
|
|
@@ -3749,7 +3812,7 @@ function inboxBranchPrefix(template) {
|
|
|
3749
3812
|
const slashIndex = staticPrefix.lastIndexOf("/");
|
|
3750
3813
|
return slashIndex === -1 ? staticPrefix : staticPrefix.slice(0, slashIndex + 1);
|
|
3751
3814
|
}
|
|
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";
|
|
3815
|
+
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", 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
3816
|
var init_config = __esm(() => {
|
|
3754
3817
|
init_dist();
|
|
3755
3818
|
init_util();
|
|
@@ -3761,6 +3824,21 @@ var init_config = __esm(() => {
|
|
|
3761
3824
|
PEER_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
3762
3825
|
RESERVED_PEER_SUFFIXES = new Set(["swp", "swo", "swx", "bak", "orig", "rej", "tmp", "old", "example", "sample", "lock"]);
|
|
3763
3826
|
RESOLVE_MODES = ["fork", "lww"];
|
|
3827
|
+
REVIEW_MODES = ["off", "required"];
|
|
3828
|
+
DEFAULT_REVIEW_TIMEOUT_SECONDS = 60 * 60;
|
|
3829
|
+
CONFIG_KEYS = new Set([
|
|
3830
|
+
"version",
|
|
3831
|
+
"remote",
|
|
3832
|
+
"path",
|
|
3833
|
+
"branch",
|
|
3834
|
+
"inbox",
|
|
3835
|
+
"redaction",
|
|
3836
|
+
"resolve",
|
|
3837
|
+
"debounce",
|
|
3838
|
+
"interval",
|
|
3839
|
+
"review",
|
|
3840
|
+
"review-timeout"
|
|
3841
|
+
]);
|
|
3764
3842
|
});
|
|
3765
3843
|
|
|
3766
3844
|
// src/state.ts
|
|
@@ -3803,7 +3881,8 @@ function readSettings() {
|
|
|
3803
3881
|
daemonEnabled: typeof record.daemonEnabled === "boolean" ? record.daemonEnabled : true,
|
|
3804
3882
|
autoUpdate: typeof record.autoUpdate === "boolean" ? record.autoUpdate : true,
|
|
3805
3883
|
lastUpdateCheckAt: typeof record.lastUpdateCheckAt === "string" ? record.lastUpdateCheckAt : undefined,
|
|
3806
|
-
installSource: INSTALL_SOURCES.has(record.installSource) ? record.installSource : undefined
|
|
3884
|
+
installSource: INSTALL_SOURCES.has(record.installSource) ? record.installSource : undefined,
|
|
3885
|
+
reviewer: typeof record.reviewer === "string" && record.reviewer.trim() ? record.reviewer : undefined
|
|
3807
3886
|
};
|
|
3808
3887
|
} catch (error) {
|
|
3809
3888
|
logSidecarEvent("failure", {
|
|
@@ -3823,6 +3902,8 @@ function writeSettings(settings) {
|
|
|
3823
3902
|
record.lastUpdateCheckAt = settings.lastUpdateCheckAt;
|
|
3824
3903
|
if (settings.installSource)
|
|
3825
3904
|
record.installSource = settings.installSource;
|
|
3905
|
+
if (settings.reviewer)
|
|
3906
|
+
record.reviewer = settings.reviewer;
|
|
3826
3907
|
fs6.writeFileSync(settingsPath(), `${JSON.stringify(record, null, 2)}
|
|
3827
3908
|
`, "utf8");
|
|
3828
3909
|
}
|
|
@@ -4073,8 +4154,21 @@ function acquireSyncLockOrThrow(root, peer) {
|
|
|
4073
4154
|
return release;
|
|
4074
4155
|
throw new SidecarError("another sidecar sync is already running; try again once it finishes");
|
|
4075
4156
|
}
|
|
4157
|
+
function acquireSyncLockWaiting(root, peer) {
|
|
4158
|
+
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
4159
|
+
const deadline = Date.now() + SYNC_LOCK_WAIT_MS;
|
|
4160
|
+
while (true) {
|
|
4161
|
+
const release = acquireSyncLock(root, peer);
|
|
4162
|
+
if (release)
|
|
4163
|
+
return release;
|
|
4164
|
+
if (Date.now() >= deadline) {
|
|
4165
|
+
throw new SidecarError("another sidecar sync is still running; try again once it finishes");
|
|
4166
|
+
}
|
|
4167
|
+
Atomics.wait(sleeper, 0, 0, 250);
|
|
4168
|
+
}
|
|
4169
|
+
}
|
|
4076
4170
|
function withSyncLock(root, peer, onBusy, fn) {
|
|
4077
|
-
const releaseLock = onBusy === "skip" ? acquireSyncLock(root, peer) : acquireSyncLockOrThrow(root, peer);
|
|
4171
|
+
const releaseLock = onBusy === "skip" ? acquireSyncLock(root, peer) : onBusy === "wait" ? acquireSyncLockWaiting(root, peer) : acquireSyncLockOrThrow(root, peer);
|
|
4078
4172
|
if (!releaseLock) {
|
|
4079
4173
|
console.log("another sidecar sync is already running; skipping this soft sync");
|
|
4080
4174
|
return false;
|
|
@@ -4106,7 +4200,7 @@ function syncLockIsStale(lockDir) {
|
|
|
4106
4200
|
return error.code !== "EPERM";
|
|
4107
4201
|
}
|
|
4108
4202
|
}
|
|
4109
|
-
var STATE_DIR_ENV = "SIDECAR_STATE_DIR", DEFAULT_SETTINGS, LOG_ROTATE_BYTES;
|
|
4203
|
+
var STATE_DIR_ENV = "SIDECAR_STATE_DIR", DEFAULT_SETTINGS, LOG_ROTATE_BYTES, SYNC_LOCK_WAIT_MS;
|
|
4110
4204
|
var init_state = __esm(() => {
|
|
4111
4205
|
init_util();
|
|
4112
4206
|
init_git();
|
|
@@ -4115,6 +4209,7 @@ var init_state = __esm(() => {
|
|
|
4115
4209
|
init_redaction();
|
|
4116
4210
|
DEFAULT_SETTINGS = { daemonEnabled: true, autoUpdate: true };
|
|
4117
4211
|
LOG_ROTATE_BYTES = 5 * 1024 * 1024;
|
|
4212
|
+
SYNC_LOCK_WAIT_MS = 10 * 60 * 1000;
|
|
4118
4213
|
});
|
|
4119
4214
|
|
|
4120
4215
|
// src/service.ts
|
|
@@ -4405,22 +4500,405 @@ ${plistValue(item, indent + 2)}`).join("")}${pad}</dict>
|
|
|
4405
4500
|
return `${pad}<string></string>
|
|
4406
4501
|
`;
|
|
4407
4502
|
}
|
|
4408
|
-
function escapeXml(value) {
|
|
4409
|
-
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
4503
|
+
function escapeXml(value) {
|
|
4504
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
4505
|
+
}
|
|
4506
|
+
var SKIP_SERVICE_ENV = "SIDECAR_SKIP_SERVICE", DAEMON_LABEL = "com.anteprojector.sidecar";
|
|
4507
|
+
var init_service = __esm(() => {
|
|
4508
|
+
init_util();
|
|
4509
|
+
init_install();
|
|
4510
|
+
init_state();
|
|
4511
|
+
});
|
|
4512
|
+
|
|
4513
|
+
// src/review.ts
|
|
4514
|
+
import crypto4 from "node:crypto";
|
|
4515
|
+
import fs8 from "node:fs";
|
|
4516
|
+
import path8 from "node:path";
|
|
4517
|
+
import { spawn as spawn2, spawnSync as spawnSync4 } from "node:child_process";
|
|
4518
|
+
function newId() {
|
|
4519
|
+
return crypto4.randomBytes(4).toString("hex");
|
|
4520
|
+
}
|
|
4521
|
+
function reviewsDir() {
|
|
4522
|
+
return path8.join(sidecarStateDir(), "reviews");
|
|
4523
|
+
}
|
|
4524
|
+
function reviewDir(id) {
|
|
4525
|
+
return path8.join(reviewsDir(), id);
|
|
4526
|
+
}
|
|
4527
|
+
function writePrivateFile(filePath, text) {
|
|
4528
|
+
const temporary = `${filePath}.${crypto4.randomUUID()}.tmp`;
|
|
4529
|
+
try {
|
|
4530
|
+
fs8.writeFileSync(temporary, text, { encoding: "utf8", mode: 384 });
|
|
4531
|
+
fs8.renameSync(temporary, filePath);
|
|
4532
|
+
} finally {
|
|
4533
|
+
fs8.rmSync(temporary, { force: true });
|
|
4534
|
+
}
|
|
4535
|
+
}
|
|
4536
|
+
function writePrivateJson(filePath, value) {
|
|
4537
|
+
writePrivateFile(filePath, `${JSON.stringify(value, null, 2)}
|
|
4538
|
+
`);
|
|
4539
|
+
}
|
|
4540
|
+
function readJson(filePath) {
|
|
4541
|
+
try {
|
|
4542
|
+
return JSON.parse(fs8.readFileSync(filePath, "utf8"));
|
|
4543
|
+
} catch {
|
|
4544
|
+
return;
|
|
4545
|
+
}
|
|
4546
|
+
}
|
|
4547
|
+
function readReview(id) {
|
|
4548
|
+
keepFailuresPrivate();
|
|
4549
|
+
if (!ID_PATTERN.test(id))
|
|
4550
|
+
return;
|
|
4551
|
+
const dir = reviewDir(id);
|
|
4552
|
+
const record = readJson(path8.join(dir, "review.json"));
|
|
4553
|
+
if (!record || record.id !== id)
|
|
4554
|
+
return;
|
|
4555
|
+
const verdict = readJson(path8.join(dir, "verdict.json"));
|
|
4556
|
+
const run = readJson(runPath(dir));
|
|
4557
|
+
return { record, verdict, run, state: reviewState(record, verdict, run), dir };
|
|
4558
|
+
}
|
|
4559
|
+
function runPath(dir) {
|
|
4560
|
+
return path8.join(dir, "run.json");
|
|
4561
|
+
}
|
|
4562
|
+
function reviewFamily(sidecarPath) {
|
|
4563
|
+
return realpathOr(gitCommonDir(sidecarPath));
|
|
4564
|
+
}
|
|
4565
|
+
function listReviews(sidecarPath) {
|
|
4566
|
+
let ids;
|
|
4567
|
+
try {
|
|
4568
|
+
ids = fs8.readdirSync(reviewsDir());
|
|
4569
|
+
} catch {
|
|
4570
|
+
return [];
|
|
4571
|
+
}
|
|
4572
|
+
const family = sidecarPath === undefined ? undefined : reviewFamily(sidecarPath);
|
|
4573
|
+
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));
|
|
4574
|
+
}
|
|
4575
|
+
function findReview(idOrPrefix) {
|
|
4576
|
+
const exact = readReview(idOrPrefix);
|
|
4577
|
+
if (exact)
|
|
4578
|
+
return exact;
|
|
4579
|
+
const matches = listReviews().filter((review) => review.record.id.startsWith(idOrPrefix));
|
|
4580
|
+
if (matches.length === 1)
|
|
4581
|
+
return matches[0];
|
|
4582
|
+
if (matches.length > 1)
|
|
4583
|
+
throw new SidecarError(`review id ${JSON.stringify(idOrPrefix)} is ambiguous; use more of it`);
|
|
4584
|
+
throw new SidecarError(`no review ${JSON.stringify(idOrPrefix)} on this machine`);
|
|
4585
|
+
}
|
|
4586
|
+
function isOpen(review) {
|
|
4587
|
+
return review.state !== "published" && review.state !== "superseded";
|
|
4588
|
+
}
|
|
4589
|
+
function assertOpen(review) {
|
|
4590
|
+
if (!isOpen(review))
|
|
4591
|
+
throw new SidecarError(`review ${review.record.id} is already ${review.state}`);
|
|
4592
|
+
}
|
|
4593
|
+
function openReview(sidecarPath) {
|
|
4594
|
+
return listReviews(sidecarPath).find(isOpen);
|
|
4595
|
+
}
|
|
4596
|
+
function reviewState(record, verdict, run) {
|
|
4597
|
+
if (record.publishedAt)
|
|
4598
|
+
return "published";
|
|
4599
|
+
if (record.supersededAt)
|
|
4600
|
+
return "superseded";
|
|
4601
|
+
if (verdict)
|
|
4602
|
+
return verdict.verdict;
|
|
4603
|
+
if (run?.finishedAt && run.exitCode !== 0)
|
|
4604
|
+
return "failed";
|
|
4605
|
+
if (run && !run.finishedAt && !pidAlive(run.pid))
|
|
4606
|
+
return "failed";
|
|
4607
|
+
if (!run && record.launchedAt && (Date.now() - Date.parse(record.launchedAt)) / 1000 > LAUNCH_GRACE_SECONDS)
|
|
4608
|
+
return "failed";
|
|
4609
|
+
return "pending";
|
|
4610
|
+
}
|
|
4611
|
+
function pidAlive(pid) {
|
|
4612
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
4613
|
+
return false;
|
|
4614
|
+
try {
|
|
4615
|
+
process.kill(pid, 0);
|
|
4616
|
+
return true;
|
|
4617
|
+
} catch (error) {
|
|
4618
|
+
return error.code === "EPERM";
|
|
4619
|
+
}
|
|
4620
|
+
}
|
|
4621
|
+
function reviewAttention(review, now = Date.now()) {
|
|
4622
|
+
if (review.state === "rejected")
|
|
4623
|
+
return "rejected";
|
|
4624
|
+
if (review.state === "failed") {
|
|
4625
|
+
const run = review.run;
|
|
4626
|
+
if (!run)
|
|
4627
|
+
return "reviewer never started";
|
|
4628
|
+
if (run.finishedAt) {
|
|
4629
|
+
return run.signal ? `reviewer stopped by ${run.signal} without a verdict` : `reviewer exited ${run.exitCode} without a verdict`;
|
|
4630
|
+
}
|
|
4631
|
+
return "reviewer stopped without a verdict";
|
|
4632
|
+
}
|
|
4633
|
+
if (review.state !== "pending")
|
|
4634
|
+
return;
|
|
4635
|
+
if (!review.record.launchedAt && !readSettings().reviewer)
|
|
4636
|
+
return "no reviewer is configured on this machine";
|
|
4637
|
+
const age = (now - Date.parse(review.record.createdAt)) / 1000;
|
|
4638
|
+
if (age > review.record.timeoutSeconds)
|
|
4639
|
+
return `undecided for over ${formatDuration(review.record.timeoutSeconds)}`;
|
|
4640
|
+
return;
|
|
4641
|
+
}
|
|
4642
|
+
function formatDuration(seconds) {
|
|
4643
|
+
if (seconds >= 3600 && seconds % 3600 === 0)
|
|
4644
|
+
return `${seconds / 3600}h`;
|
|
4645
|
+
if (seconds >= 60 && seconds % 60 === 0)
|
|
4646
|
+
return `${seconds / 60}m`;
|
|
4647
|
+
return `${Math.round(seconds)}s`;
|
|
4648
|
+
}
|
|
4649
|
+
function peerReviewGuidelinesFileName(name) {
|
|
4650
|
+
return name === DEFAULT_PEER ? ".sidecar-review" : `.sidecar-review.${name}`;
|
|
4651
|
+
}
|
|
4652
|
+
function peerReviewGuidelinesPath(root, name) {
|
|
4653
|
+
return path8.join(root, peerReviewGuidelinesFileName(name));
|
|
4654
|
+
}
|
|
4655
|
+
function readGuidelines(root, peer) {
|
|
4656
|
+
try {
|
|
4657
|
+
const text = fs8.readFileSync(peerReviewGuidelinesPath(root, peer), "utf8").trim();
|
|
4658
|
+
return text || undefined;
|
|
4659
|
+
} catch (error) {
|
|
4660
|
+
if (error.code === "ENOENT")
|
|
4661
|
+
return;
|
|
4662
|
+
throw new SidecarError(`could not read ${peerReviewGuidelinesPath(root, peer)}: ${error instanceof Error ? error.message : String(error)}`);
|
|
4663
|
+
}
|
|
4664
|
+
}
|
|
4665
|
+
function createReview(options) {
|
|
4666
|
+
const { root, config, sidecarPath, base, head } = options;
|
|
4667
|
+
const guidelines = readGuidelines(root, config.peer);
|
|
4668
|
+
const id = newId();
|
|
4669
|
+
const dir = reviewDir(id);
|
|
4670
|
+
fs8.mkdirSync(reviewsDir(), { recursive: true, mode: 448 });
|
|
4671
|
+
fs8.mkdirSync(dir, { mode: 448 });
|
|
4672
|
+
const fields = git(sidecarPath, ["diff-tree", ...PACKET_DIFF_OPTIONS, "--name-status", "-z", base, head]).stdout.split("\x00").filter(Boolean);
|
|
4673
|
+
const files = [];
|
|
4674
|
+
for (let index = 0;index + 1 < fields.length; index += 2)
|
|
4675
|
+
files.push({ status: fields[index], path: fields[index + 1] });
|
|
4676
|
+
writeOpaqueArtifacts(sidecarPath, dir, base, head, files);
|
|
4677
|
+
const diff = git(sidecarPath, ["diff-tree", ...PACKET_DIFF_OPTIONS, "-p", "--no-color", "--no-ext-diff", "--no-textconv", base, head]).stdout;
|
|
4678
|
+
const record = {
|
|
4679
|
+
schema: REVIEW_SCHEMA,
|
|
4680
|
+
id,
|
|
4681
|
+
family: reviewFamily(sidecarPath),
|
|
4682
|
+
root,
|
|
4683
|
+
peer: config.peer,
|
|
4684
|
+
base,
|
|
4685
|
+
head,
|
|
4686
|
+
tree: treeOf(sidecarPath, head),
|
|
4687
|
+
files,
|
|
4688
|
+
createdAt: nowIso(),
|
|
4689
|
+
timeoutSeconds: reviewTimeoutSeconds(config)
|
|
4690
|
+
};
|
|
4691
|
+
writePrivateFile(path8.join(dir, "diff.patch"), diff);
|
|
4692
|
+
if (guidelines)
|
|
4693
|
+
writePrivateFile(path8.join(dir, "guidelines.md"), `${guidelines}
|
|
4694
|
+
`);
|
|
4695
|
+
writePrivateFile(path8.join(dir, "instructions.md"), reviewInstructions(record, config, guidelines));
|
|
4696
|
+
writePrivateJson(path8.join(dir, "review.json"), record);
|
|
4697
|
+
logSidecarEvent("review-open", { id, root, peer: config.peer, files: files.length });
|
|
4698
|
+
pruneClosedReviews(sidecarPath);
|
|
4699
|
+
return { record, state: "pending", dir };
|
|
4700
|
+
}
|
|
4701
|
+
function writeOpaqueArtifacts(repo, dir, from, head, files) {
|
|
4702
|
+
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)));
|
|
4703
|
+
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)));
|
|
4704
|
+
const utf8 = new TextDecoder("utf-8", { fatal: true });
|
|
4705
|
+
const isText = (blob) => {
|
|
4706
|
+
try {
|
|
4707
|
+
utf8.decode(blob);
|
|
4708
|
+
return true;
|
|
4709
|
+
} catch {
|
|
4710
|
+
return false;
|
|
4711
|
+
}
|
|
4712
|
+
};
|
|
4713
|
+
let count = 0;
|
|
4714
|
+
for (const file of files) {
|
|
4715
|
+
if (file.status === "D" || !blobs.has(file.path))
|
|
4716
|
+
continue;
|
|
4717
|
+
const blob = gitBytes(repo, ["cat-file", "blob", `${head}:${file.path}`]).stdout;
|
|
4718
|
+
if (!binary.has(file.path) && isText(blob))
|
|
4719
|
+
continue;
|
|
4720
|
+
count += 1;
|
|
4721
|
+
file.artifact = path8.join("opaque", String(count).padStart(4, "0"));
|
|
4722
|
+
fs8.mkdirSync(path8.join(dir, "opaque"), { recursive: true, mode: 448 });
|
|
4723
|
+
fs8.writeFileSync(path8.join(dir, file.artifact), blob, { mode: 384 });
|
|
4724
|
+
}
|
|
4725
|
+
}
|
|
4726
|
+
function updateRecord(review, change) {
|
|
4727
|
+
const record = { ...review.record, ...change };
|
|
4728
|
+
writePrivateJson(path8.join(review.dir, "review.json"), record);
|
|
4729
|
+
review.record = record;
|
|
4730
|
+
}
|
|
4731
|
+
function markPublishing(review, inbox, commit) {
|
|
4732
|
+
updateRecord(review, { publishing: { inbox, commit } });
|
|
4733
|
+
}
|
|
4734
|
+
function markPublished(review, commit) {
|
|
4735
|
+
updateRecord(review, { publishedAt: nowIso(), publishedCommit: commit });
|
|
4736
|
+
logSidecarEvent("review-published", { id: review.record.id, commit });
|
|
4737
|
+
}
|
|
4738
|
+
function markSuperseded(review) {
|
|
4739
|
+
updateRecord(review, { supersededAt: nowIso() });
|
|
4740
|
+
logSidecarEvent("review-superseded", { id: review.record.id });
|
|
4741
|
+
}
|
|
4742
|
+
function pruneClosedReviews(sidecarPath) {
|
|
4743
|
+
const closed = listReviews(sidecarPath).filter((review) => !isOpen(review));
|
|
4744
|
+
for (const review of closed.slice(KEEP_CLOSED_REVIEWS)) {
|
|
4745
|
+
fs8.rmSync(review.dir, { recursive: true, force: true });
|
|
4746
|
+
}
|
|
4747
|
+
}
|
|
4748
|
+
function pruneOrphanedReviews() {
|
|
4749
|
+
for (const review of listReviews()) {
|
|
4750
|
+
if (!fs8.existsSync(review.record.family))
|
|
4751
|
+
fs8.rmSync(review.dir, { recursive: true, force: true });
|
|
4752
|
+
}
|
|
4753
|
+
}
|
|
4754
|
+
function opaqueSection(record) {
|
|
4755
|
+
const opaque = record.files.filter((file) => file.artifact);
|
|
4756
|
+
if (!opaque.length)
|
|
4757
|
+
return "";
|
|
4758
|
+
return `
|
|
4759
|
+
These files' content does not appear faithfully in \`diff.patch\` (binary, not
|
|
4760
|
+
UTF-8, or diffs turned off). Their complete new content, as it would be
|
|
4761
|
+
published, is in the packet.
|
|
4762
|
+
Inspect each one; if you cannot, do not approve:
|
|
4763
|
+
|
|
4764
|
+
${opaque.map((file) => `- \`${file.artifact}\` — ${file.path}`).join(`
|
|
4765
|
+
`)}
|
|
4766
|
+
`;
|
|
4767
|
+
}
|
|
4768
|
+
function reviewInstructions(record, config, guidelines) {
|
|
4769
|
+
return `# Sidecar publish review ${record.id}
|
|
4770
|
+
|
|
4771
|
+
A sidecar is a scratchpad repository that syncs automatically to a shared Git
|
|
4772
|
+
remote (${redactText(config.remote)}). This machine wants to publish the change in
|
|
4773
|
+
\`diff.patch\`. Everyone with access to that remote can read what you approve,
|
|
4774
|
+
and it stays in the remote's history.
|
|
4775
|
+
|
|
4776
|
+
Decide whether anything in the change should not leave this machine. This is
|
|
4777
|
+
not a code review: ignore quality, style, and correctness.
|
|
4778
|
+
|
|
4779
|
+
Secret redaction has already run. Values it caught appear as placeholders such
|
|
4780
|
+
as \`<API_KEY>\`, \`<TOKEN>\`, or \`<SECRET>\`; they are not leaks. Look for what
|
|
4781
|
+
pattern matching misses:
|
|
4782
|
+
|
|
4783
|
+
- credentials in unusual formats, or ones split across lines
|
|
4784
|
+
- personal data about real people: contact details, health, finances, private conversations
|
|
4785
|
+
- confidential material: customer names and data, unreleased plans, internal-only details
|
|
4786
|
+
- anything the repository's guidelines below rule out
|
|
4787
|
+
|
|
4788
|
+
## Files
|
|
4789
|
+
|
|
4790
|
+
All in this directory (\`$SIDECAR_REVIEW_DIR\`):
|
|
4791
|
+
|
|
4792
|
+
- \`diff.patch\` — the change exactly as it would be published
|
|
4793
|
+
- \`review.json\` — the repository, peer, and changed paths
|
|
4794
|
+
${guidelines ? "- `guidelines.md` — this repository's guidelines, repeated below\n" : ""}${opaqueSection(record)}
|
|
4795
|
+
## Guidelines
|
|
4796
|
+
|
|
4797
|
+
${guidelines ?? "This repository has no review guidelines (`.sidecar-review` beside its `.sidecar`)."}
|
|
4798
|
+
|
|
4799
|
+
## Decide
|
|
4800
|
+
|
|
4801
|
+
Approve, and the change is published on the next sync:
|
|
4802
|
+
|
|
4803
|
+
sidecar review approve ${record.id}
|
|
4804
|
+
|
|
4805
|
+
Reject, saying which file and what kind of content. Do not repeat the
|
|
4806
|
+
sensitive value itself:
|
|
4807
|
+
|
|
4808
|
+
sidecar review reject ${record.id} --reason "<file: what kind of content>"
|
|
4809
|
+
|
|
4810
|
+
If you are unsure, or a person should weigh in, do neither: leave the review
|
|
4811
|
+
open and raise it with them. Nothing is published until someone approves, and
|
|
4812
|
+
they decide with the same two commands.
|
|
4813
|
+
`;
|
|
4814
|
+
}
|
|
4815
|
+
function configuredReviewer() {
|
|
4816
|
+
const command = readSettings().reviewer;
|
|
4817
|
+
if (!command)
|
|
4818
|
+
throw new SidecarError("no reviewer is configured; set one with `sidecar review reviewer <command>`");
|
|
4819
|
+
return command;
|
|
4820
|
+
}
|
|
4821
|
+
function launchReviewer(review) {
|
|
4822
|
+
updateRecord(review, { launchedAt: nowIso() });
|
|
4823
|
+
const child = spawn2(process.execPath, [sidecarCliPath(), "review", "run", review.record.id], {
|
|
4824
|
+
cwd: review.dir,
|
|
4825
|
+
detached: true,
|
|
4826
|
+
stdio: "ignore",
|
|
4827
|
+
env: { ...process.env, [SKIP_LOCAL_EXEC_ENV]: "1" }
|
|
4828
|
+
});
|
|
4829
|
+
child.unref();
|
|
4830
|
+
logSidecarEvent("review-launch", { id: review.record.id });
|
|
4831
|
+
}
|
|
4832
|
+
function runReviewer(idOrPrefix) {
|
|
4833
|
+
const review = findReview(idOrPrefix);
|
|
4834
|
+
const { id } = review.record;
|
|
4835
|
+
assertOpen(review);
|
|
4836
|
+
if (review.run)
|
|
4837
|
+
throw new SidecarError(`review ${id}'s reviewer already ran; \`sidecar review retry ${id}\` opens a new review of the same change`);
|
|
4838
|
+
const command = configuredReviewer();
|
|
4839
|
+
const file = runPath(review.dir);
|
|
4840
|
+
const run = { pid: process.pid, command, startedAt: nowIso() };
|
|
4841
|
+
writePrivateJson(file, run);
|
|
4842
|
+
const log = fs8.openSync(path8.join(review.dir, "reviewer.log"), "a", 384);
|
|
4843
|
+
let result;
|
|
4844
|
+
try {
|
|
4845
|
+
result = spawnSync4("/bin/sh", ["-c", command], {
|
|
4846
|
+
cwd: review.dir,
|
|
4847
|
+
stdio: ["ignore", log, log],
|
|
4848
|
+
env: reviewerEnv(review)
|
|
4849
|
+
});
|
|
4850
|
+
} finally {
|
|
4851
|
+
fs8.closeSync(log);
|
|
4852
|
+
}
|
|
4853
|
+
const finished = { ...run, finishedAt: nowIso(), exitCode: result.status, signal: result.signal };
|
|
4854
|
+
writePrivateJson(file, finished);
|
|
4855
|
+
logSidecarEvent("review-run", { id, exitCode: result.status, signal: result.signal });
|
|
4856
|
+
return 0;
|
|
4857
|
+
}
|
|
4858
|
+
function reviewerEnv(review) {
|
|
4859
|
+
const env = { ...process.env };
|
|
4860
|
+
for (const name of [PEER_ENV, SOFT_SYNC_ENV, LOCAL_SYNC_ENV, SKIP_LOCAL_EXEC_ENV, GLOBAL_EXEC_ENV])
|
|
4861
|
+
delete env[name];
|
|
4862
|
+
const { record } = review;
|
|
4863
|
+
return {
|
|
4864
|
+
...env,
|
|
4865
|
+
SIDECAR_REVIEW_ID: record.id,
|
|
4866
|
+
SIDECAR_REVIEW_DIR: review.dir,
|
|
4867
|
+
SIDECAR_REVIEW_PROMPT: path8.join(review.dir, "instructions.md"),
|
|
4868
|
+
SIDECAR_REVIEW_DIFF: path8.join(review.dir, "diff.patch"),
|
|
4869
|
+
SIDECAR_REVIEW_ROOT: record.root,
|
|
4870
|
+
SIDECAR_REVIEW_PEER: record.peer,
|
|
4871
|
+
SIDECAR_REVIEW_CONFIG: peerConfigPath(record.root, record.peer)
|
|
4872
|
+
};
|
|
4873
|
+
}
|
|
4874
|
+
function decideReview(review, verdict, reason) {
|
|
4875
|
+
assertOpen(review);
|
|
4876
|
+
if (review.record.publishing && verdict === "rejected") {
|
|
4877
|
+
throw new SidecarError(`review ${review.record.id} is already being published; it can no longer be rejected`);
|
|
4878
|
+
}
|
|
4879
|
+
const record = { verdict, ...reason ? { reason } : {}, decidedAt: nowIso() };
|
|
4880
|
+
writePrivateJson(path8.join(review.dir, "verdict.json"), record);
|
|
4881
|
+
review.verdict = record;
|
|
4882
|
+
review.state = verdict;
|
|
4883
|
+
logSidecarEvent("review-decision", { id: review.record.id, verdict });
|
|
4410
4884
|
}
|
|
4411
|
-
var
|
|
4412
|
-
var
|
|
4885
|
+
var REVIEW_SCHEMA = 1, KEEP_CLOSED_REVIEWS = 10, ID_PATTERN, LAUNCH_GRACE_SECONDS = 60, PACKET_DIFF_OPTIONS;
|
|
4886
|
+
var init_review = __esm(() => {
|
|
4413
4887
|
init_util();
|
|
4888
|
+
init_git();
|
|
4414
4889
|
init_install();
|
|
4890
|
+
init_config();
|
|
4415
4891
|
init_state();
|
|
4892
|
+
init_redaction();
|
|
4893
|
+
ID_PATTERN = /^[0-9a-f]+$/;
|
|
4894
|
+
PACKET_DIFF_OPTIONS = ["-r", "--no-renames", "--ignore-submodules=none"];
|
|
4416
4895
|
});
|
|
4417
4896
|
|
|
4418
4897
|
// src/sync.ts
|
|
4419
|
-
import
|
|
4420
|
-
import
|
|
4898
|
+
import crypto5 from "node:crypto";
|
|
4899
|
+
import fs9 from "node:fs";
|
|
4421
4900
|
import os5 from "node:os";
|
|
4422
|
-
import
|
|
4423
|
-
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
4901
|
+
import path9 from "node:path";
|
|
4424
4902
|
function syncProject(root, config, options) {
|
|
4425
4903
|
const stage = (name) => options.onStage?.(name);
|
|
4426
4904
|
stage("checkout");
|
|
@@ -4428,6 +4906,7 @@ function syncProject(root, config, options) {
|
|
|
4428
4906
|
if (!options.remote && !hasAnyCommit(sidecarPath)) {
|
|
4429
4907
|
throw new SidecarError(`local sync requires an initialized sidecar checkout at ${sidecarPath}; run \`sidecar sync\` first`);
|
|
4430
4908
|
}
|
|
4909
|
+
bindReviewPolicy(sidecarPath, config);
|
|
4431
4910
|
const inbox = expandInbox(config, sidecarPath);
|
|
4432
4911
|
ensureCommitIdentity(sidecarPath);
|
|
4433
4912
|
ensureRedactionFilter(sidecarPath, config.redaction, config);
|
|
@@ -4444,7 +4923,9 @@ function syncProject(root, config, options) {
|
|
|
4444
4923
|
settleCheckouts(sidecarPath, config, inbox, siblings);
|
|
4445
4924
|
}
|
|
4446
4925
|
if (!options.remote)
|
|
4447
|
-
return;
|
|
4926
|
+
return {};
|
|
4927
|
+
if (reviewRequired(config))
|
|
4928
|
+
return publishReviewed(root, sidecarPath, config, inbox, siblings, stage);
|
|
4448
4929
|
stage("push-inbox");
|
|
4449
4930
|
syncBranchBeforePush(sidecarPath, inbox, config);
|
|
4450
4931
|
pushBranch(sidecarPath, inbox);
|
|
@@ -4452,6 +4933,288 @@ function syncProject(root, config, options) {
|
|
|
4452
4933
|
mergeInboxBranches(sidecarPath, config, { forkFiles: true, push: true, remote: true });
|
|
4453
4934
|
stage("settle-remote");
|
|
4454
4935
|
settleCheckouts(sidecarPath, config, inbox, siblings);
|
|
4936
|
+
return {};
|
|
4937
|
+
}
|
|
4938
|
+
function withPublicGit(repo, fn) {
|
|
4939
|
+
assertRedactionFilterIntact(repo);
|
|
4940
|
+
const { name, email } = sidecarIdentity();
|
|
4941
|
+
const overrides = [
|
|
4942
|
+
["core.hooksPath", os5.devNull],
|
|
4943
|
+
["core.attributesFile", os5.devNull],
|
|
4944
|
+
["core.autocrlf", "false"],
|
|
4945
|
+
["commit.gpgSign", "false"],
|
|
4946
|
+
["tag.gpgSign", "false"],
|
|
4947
|
+
["i18n.commitEncoding", "UTF-8"],
|
|
4948
|
+
["rerere.enabled", "false"],
|
|
4949
|
+
["rerere.autoUpdate", "false"],
|
|
4950
|
+
["merge.renormalize", "false"],
|
|
4951
|
+
["merge.default", "text"],
|
|
4952
|
+
["push.pushOption", ""],
|
|
4953
|
+
["push.followTags", "false"],
|
|
4954
|
+
...neutralizedDrivers(repo)
|
|
4955
|
+
];
|
|
4956
|
+
const env = {
|
|
4957
|
+
GIT_AUTHOR_NAME: name,
|
|
4958
|
+
GIT_AUTHOR_EMAIL: email,
|
|
4959
|
+
GIT_COMMITTER_NAME: name,
|
|
4960
|
+
GIT_COMMITTER_EMAIL: email,
|
|
4961
|
+
GIT_NO_REPLACE_OBJECTS: "1",
|
|
4962
|
+
GIT_ATTR_SOURCE: emptyTree(repo),
|
|
4963
|
+
GIT_CONFIG_PARAMETERS: undefined,
|
|
4964
|
+
GIT_CONFIG_COUNT: String(overrides.length)
|
|
4965
|
+
};
|
|
4966
|
+
overrides.forEach(([key, value], index) => {
|
|
4967
|
+
env[`GIT_CONFIG_KEY_${index}`] = key;
|
|
4968
|
+
env[`GIT_CONFIG_VALUE_${index}`] = value;
|
|
4969
|
+
});
|
|
4970
|
+
const saved = Object.fromEntries(Object.keys(env).map((key) => [key, process.env[key]]));
|
|
4971
|
+
const apply = (values) => {
|
|
4972
|
+
for (const [key, value] of Object.entries(values)) {
|
|
4973
|
+
if (value === undefined)
|
|
4974
|
+
delete process.env[key];
|
|
4975
|
+
else
|
|
4976
|
+
process.env[key] = value;
|
|
4977
|
+
}
|
|
4978
|
+
};
|
|
4979
|
+
apply(env);
|
|
4980
|
+
try {
|
|
4981
|
+
return fn();
|
|
4982
|
+
} finally {
|
|
4983
|
+
apply(saved);
|
|
4984
|
+
}
|
|
4985
|
+
}
|
|
4986
|
+
function neutralizedDrivers(repo) {
|
|
4987
|
+
const keys = git(repo, ["config", "--name-only", "--get-regexp", "^(merge|filter)\\..+\\.(driver|clean|smudge|process)$"], { check: false }).stdout.split(`
|
|
4988
|
+
`).filter(Boolean);
|
|
4989
|
+
return keys.flatMap((key) => {
|
|
4990
|
+
const [section, ...rest] = key.split(".");
|
|
4991
|
+
const driver = rest.slice(0, -1).join(".");
|
|
4992
|
+
if (section === "merge")
|
|
4993
|
+
return [[`merge.${driver}.driver`, "git merge-file --marker-size=%L %A %O %B"]];
|
|
4994
|
+
if (driver === REDACTION_FILTER_NAME)
|
|
4995
|
+
return [];
|
|
4996
|
+
return [[`filter.${driver}.clean`, "cat"], [`filter.${driver}.smudge`, "cat"], [`filter.${driver}.process`, ""], [`filter.${driver}.required`, "false"]];
|
|
4997
|
+
});
|
|
4998
|
+
}
|
|
4999
|
+
function assertRedactionFilterIntact(repo) {
|
|
5000
|
+
if (git(repo, ["config", "--get-all", `filter.${REDACTION_FILTER_NAME}.process`], { check: false }).status === 0) {
|
|
5001
|
+
throw new SidecarError(`filter.${REDACTION_FILTER_NAME}.process is configured; remove it — a review-gated peer publishes only through sidecar's own redaction filter`);
|
|
5002
|
+
}
|
|
5003
|
+
}
|
|
5004
|
+
function assertPublishDestination(repo) {
|
|
5005
|
+
const pushUrl = git(repo, ["config", "--get-all", "remote.origin.pushurl"], { check: false }).stdout.trim();
|
|
5006
|
+
if (!pushUrl)
|
|
5007
|
+
return;
|
|
5008
|
+
const url = git(repo, ["config", "--get", "remote.origin.url"], { check: false }).stdout.trim();
|
|
5009
|
+
if (pushUrl !== url) {
|
|
5010
|
+
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");
|
|
5011
|
+
}
|
|
5012
|
+
}
|
|
5013
|
+
function fetchPublic(repo) {
|
|
5014
|
+
git(repo, ["fetch", "--quiet", "--prune", "--no-tags", "origin", "+refs/heads/*:refs/remotes/origin/*", `+refs/heads/*:${PUBLIC_REFS}*`]);
|
|
5015
|
+
}
|
|
5016
|
+
function publicRef(branch) {
|
|
5017
|
+
return `${PUBLIC_REFS}${branch}`;
|
|
5018
|
+
}
|
|
5019
|
+
function refreshPublicRefs(repo) {
|
|
5020
|
+
withPublicGit(repo, () => fetchPublic(repo));
|
|
5021
|
+
}
|
|
5022
|
+
function publicTip(repo, branch) {
|
|
5023
|
+
return git(repo, ["rev-parse", "--verify", "--quiet", publicRef(branch)], { check: false }).stdout.trim() || undefined;
|
|
5024
|
+
}
|
|
5025
|
+
function isPublic(repo, commit) {
|
|
5026
|
+
return Boolean(git(repo, ["for-each-ref", "--contains", commit, "--format=%(refname)", PUBLIC_REFS]).stdout.trim());
|
|
5027
|
+
}
|
|
5028
|
+
function publishReviewed(root, sidecarPath, config, inbox, siblings, stage) {
|
|
5029
|
+
stage("fetch");
|
|
5030
|
+
withPublicGit(sidecarPath, () => {
|
|
5031
|
+
fetchPublic(sidecarPath);
|
|
5032
|
+
if (!publicTip(sidecarPath, config.branch))
|
|
5033
|
+
bootstrapReviewedMain(sidecarPath, config);
|
|
5034
|
+
stage("publish");
|
|
5035
|
+
const open = openReview(sidecarPath);
|
|
5036
|
+
if (open?.state === "approved")
|
|
5037
|
+
publishApprovedReview(sidecarPath, config, inbox, open);
|
|
5038
|
+
});
|
|
5039
|
+
stage("merge");
|
|
5040
|
+
withPublicGit(sidecarPath, () => mergeFetchedInboxes(sidecarPath, config, { forkFiles: true }));
|
|
5041
|
+
stage("merge-local");
|
|
5042
|
+
mergeInboxBranches(sidecarPath, config, { forkFiles: true, push: false, remote: false });
|
|
5043
|
+
stage("settle-remote");
|
|
5044
|
+
settleCheckouts(sidecarPath, config, inbox, siblings);
|
|
5045
|
+
stage("review");
|
|
5046
|
+
const gate = withPublicGit(sidecarPath, () => gateUnpublished(root, sidecarPath, config));
|
|
5047
|
+
if (gate?.launch)
|
|
5048
|
+
launchReviewer(gate.review);
|
|
5049
|
+
return gate ? { held: { review: gate.review, attention: reviewAttention(gate.review) } } : {};
|
|
5050
|
+
}
|
|
5051
|
+
function gateUnpublished(root, sidecarPath, config) {
|
|
5052
|
+
const head = git(sidecarPath, ["rev-parse", `refs/heads/${config.branch}`]).stdout.trim();
|
|
5053
|
+
const headTree = treeOf(sidecarPath, head);
|
|
5054
|
+
const publicMain = publicTip(sidecarPath, config.branch);
|
|
5055
|
+
const base = publicMain && git(sidecarPath, ["merge-base", head, publicMain], { check: false }).stdout.trim();
|
|
5056
|
+
if (!base)
|
|
5057
|
+
throw new SidecarError(`local ${config.branch} shares no history with the remote's; nothing is reviewed or published until it does`);
|
|
5058
|
+
const open = openReview(sidecarPath);
|
|
5059
|
+
const hasReviewer = Boolean(readSettings().reviewer);
|
|
5060
|
+
if (treeOf(sidecarPath, base) === headTree) {
|
|
5061
|
+
if (open && open.state !== "approved")
|
|
5062
|
+
markSuperseded(open);
|
|
5063
|
+
return;
|
|
5064
|
+
}
|
|
5065
|
+
if (open?.state === "pending" || open?.state === "approved") {
|
|
5066
|
+
return { review: open, launch: open.state === "pending" && !open.record.launchedAt && hasReviewer };
|
|
5067
|
+
}
|
|
5068
|
+
if (open) {
|
|
5069
|
+
if (open.record.tree === headTree)
|
|
5070
|
+
return { review: open, launch: false };
|
|
5071
|
+
markSuperseded(open);
|
|
5072
|
+
}
|
|
5073
|
+
const review = createReview({ root, config, sidecarPath, base, head });
|
|
5074
|
+
console.log(`opened review ${paint("brand", review.record.id)} for ${review.record.files.length} changed file(s)`);
|
|
5075
|
+
return { review, launch: hasReviewer };
|
|
5076
|
+
}
|
|
5077
|
+
function publishApprovedReview(sidecarPath, config, inbox, review) {
|
|
5078
|
+
const { record } = review;
|
|
5079
|
+
const target = record.publishing ?? { inbox, commit: cutPublishCommit(sidecarPath, config, inbox, record) };
|
|
5080
|
+
const protectRef = publishingRef(record.id);
|
|
5081
|
+
if (!isPublic(sidecarPath, target.commit)) {
|
|
5082
|
+
const parents = git(sidecarPath, ["rev-list", "--parents", "-n", "1", target.commit]).stdout.trim().split(" ").slice(1);
|
|
5083
|
+
if (!parents.every((parent) => isPublic(sidecarPath, parent))) {
|
|
5084
|
+
markSuperseded(review);
|
|
5085
|
+
git(sidecarPath, ["update-ref", "-d", protectRef], { check: false });
|
|
5086
|
+
console.log(`review ${paint("brand", record.id)} was measured from a commit the remote does not have; reviewing again`);
|
|
5087
|
+
return;
|
|
5088
|
+
}
|
|
5089
|
+
assertPublishDestination(sidecarPath);
|
|
5090
|
+
if (!record.publishing) {
|
|
5091
|
+
git(sidecarPath, ["update-ref", protectRef, target.commit]);
|
|
5092
|
+
markPublishing(review, target.inbox, target.commit);
|
|
5093
|
+
}
|
|
5094
|
+
const push = git(sidecarPath, ["push", "--no-follow-tags", "origin", `${target.commit}:refs/heads/${target.inbox}`], { check: false });
|
|
5095
|
+
if (push.status !== 0) {
|
|
5096
|
+
throw new SidecarError(push.stderr.trim() || `could not publish review ${record.id} to ${target.inbox}`);
|
|
5097
|
+
}
|
|
5098
|
+
fetchPublic(sidecarPath);
|
|
5099
|
+
console.log(`published review ${paint("brand", record.id)} to ${paint("brand", target.inbox)}`);
|
|
5100
|
+
}
|
|
5101
|
+
linkPublished(sidecarPath, config, review, target.commit);
|
|
5102
|
+
markPublished(review, target.commit);
|
|
5103
|
+
git(sidecarPath, ["update-ref", "-d", protectRef], { check: false });
|
|
5104
|
+
}
|
|
5105
|
+
function publishingRef(id) {
|
|
5106
|
+
return `refs/sidecar-publishing/${id}`;
|
|
5107
|
+
}
|
|
5108
|
+
function retryReview(review) {
|
|
5109
|
+
const { record } = review;
|
|
5110
|
+
if (!isOpen(review))
|
|
5111
|
+
throw new SidecarError(`review ${record.id} is already ${review.state}`);
|
|
5112
|
+
if (record.publishing)
|
|
5113
|
+
throw new SidecarError(`review ${record.id} is already being published`);
|
|
5114
|
+
if (review.state === "approved")
|
|
5115
|
+
throw new SidecarError(`review ${record.id} is approved and publishes on the next sync`);
|
|
5116
|
+
const config = loadPeer(record.root, record.peer).config;
|
|
5117
|
+
const sidecarPath = requireSidecarCheckout(record.root, config);
|
|
5118
|
+
markSuperseded(review);
|
|
5119
|
+
const fresh = withPublicGit(sidecarPath, () => createReview({ root: record.root, config, sidecarPath, base: record.base, head: record.head }));
|
|
5120
|
+
launchReviewer(fresh);
|
|
5121
|
+
return fresh;
|
|
5122
|
+
}
|
|
5123
|
+
function cutPublishCommit(sidecarPath, config, inbox, record) {
|
|
5124
|
+
const previous = publicTip(sidecarPath, inbox);
|
|
5125
|
+
const parents = [record.base, ...previous && !isAncestor(sidecarPath, previous, record.base) ? [previous] : []];
|
|
5126
|
+
const body = [
|
|
5127
|
+
"sidecar publish",
|
|
5128
|
+
"",
|
|
5129
|
+
`source: ${currentMachine()}`,
|
|
5130
|
+
`inbox: ${inbox}`,
|
|
5131
|
+
`review: ${record.id}`,
|
|
5132
|
+
...publishedWriteTrailers(sidecarPath, config, record)
|
|
5133
|
+
];
|
|
5134
|
+
return git(sidecarPath, [
|
|
5135
|
+
"commit-tree",
|
|
5136
|
+
record.tree,
|
|
5137
|
+
...parents.flatMap((parent) => ["-p", parent]),
|
|
5138
|
+
"-m",
|
|
5139
|
+
body.join(`
|
|
5140
|
+
`)
|
|
5141
|
+
]).stdout.trim();
|
|
5142
|
+
}
|
|
5143
|
+
function publishedWriteTrailers(repo, config, record) {
|
|
5144
|
+
const defaults = { resolve: config.resolve, redaction: config.redaction };
|
|
5145
|
+
const paths = record.files.map((file) => file.path).filter((filePath) => resolveFileRules(config.rules, filePath, defaults).resolve === "lww");
|
|
5146
|
+
if (paths.length > WRITTEN_TRAILER_LIMIT)
|
|
5147
|
+
return [];
|
|
5148
|
+
const now = Math.floor(Date.now() / 1000);
|
|
5149
|
+
return paths.map((filePath) => {
|
|
5150
|
+
const { time } = lastWriteEvent(repo, record.head, filePath);
|
|
5151
|
+
return lwwWrittenTrailer(filePath, { time: Math.min(Math.max(0, time), now), source: `review-${record.id}` });
|
|
5152
|
+
});
|
|
5153
|
+
}
|
|
5154
|
+
function linkPublished(sidecarPath, config, review, published) {
|
|
5155
|
+
const main = `refs/heads/${config.branch}`;
|
|
5156
|
+
if (isAncestor(sidecarPath, published, main))
|
|
5157
|
+
return;
|
|
5158
|
+
if (!isAncestor(sidecarPath, review.record.head, main)) {
|
|
5159
|
+
logSidecarEvent("review-link-skip", { sidecarPath, id: review.record.id });
|
|
5160
|
+
return;
|
|
5161
|
+
}
|
|
5162
|
+
withScratchWorktree(sidecarPath, config, "HEAD", (worktree) => {
|
|
5163
|
+
git(worktree, ["switch", config.branch]);
|
|
5164
|
+
git(worktree, ["merge", "--no-ff", "-s", "ours", "-m", `sidecar: link published review ${review.record.id}`, published]);
|
|
5165
|
+
});
|
|
5166
|
+
}
|
|
5167
|
+
function mergePublishedInboxes(sidecarPath, config, options) {
|
|
5168
|
+
return withPublicGit(sidecarPath, () => {
|
|
5169
|
+
fetchPublic(sidecarPath);
|
|
5170
|
+
return mergeFetchedInboxes(sidecarPath, config, options);
|
|
5171
|
+
});
|
|
5172
|
+
}
|
|
5173
|
+
function mergeFetchedInboxes(sidecarPath, config, options) {
|
|
5174
|
+
if (!publicTip(sidecarPath, config.branch))
|
|
5175
|
+
return 0;
|
|
5176
|
+
const publicMain = publicRef(config.branch);
|
|
5177
|
+
if (publishedInboxBranches(sidecarPath, config).every((ref) => isAncestor(sidecarPath, ref, publicMain))) {
|
|
5178
|
+
console.log("no published inbox branches to merge");
|
|
5179
|
+
return 0;
|
|
5180
|
+
}
|
|
5181
|
+
assertPublishDestination(sidecarPath);
|
|
5182
|
+
return withScratchWorktree(sidecarPath, config, publicMain, (worktree) => {
|
|
5183
|
+
assertRedactionFilterIntact(worktree);
|
|
5184
|
+
const maxAttempts = 3;
|
|
5185
|
+
for (let attempt = 1;; attempt += 1) {
|
|
5186
|
+
if (attempt > 1) {
|
|
5187
|
+
fetchPublic(sidecarPath);
|
|
5188
|
+
git(worktree, ["reset", "--hard", publicMain]);
|
|
5189
|
+
}
|
|
5190
|
+
const pending = publishedInboxBranches(worktree, config).filter((ref) => !isAncestor(worktree, ref, "HEAD"));
|
|
5191
|
+
if (!pending.length) {
|
|
5192
|
+
console.log("no published inbox branches to merge");
|
|
5193
|
+
return 0;
|
|
5194
|
+
}
|
|
5195
|
+
const merged = [];
|
|
5196
|
+
for (const ref of pending) {
|
|
5197
|
+
console.log(`merging ${paint("brand", remoteBranchName(ref))}`);
|
|
5198
|
+
if (mergeInboxBranch(worktree, config, ref, options))
|
|
5199
|
+
merged.push(ref);
|
|
5200
|
+
}
|
|
5201
|
+
const push = git(worktree, ["push", "--no-follow-tags", "origin", `HEAD:refs/heads/${config.branch}`], { check: false });
|
|
5202
|
+
if (push.status !== 0) {
|
|
5203
|
+
if (attempt >= maxAttempts)
|
|
5204
|
+
throw new SidecarError(push.stderr.trim() || `could not push ${config.branch}`);
|
|
5205
|
+
console.log(`push of ${config.branch} was rejected; refetching and retrying`);
|
|
5206
|
+
continue;
|
|
5207
|
+
}
|
|
5208
|
+
fetchPublic(sidecarPath);
|
|
5209
|
+
console.log(`pushed ${config.branch}`);
|
|
5210
|
+
console.log(`merged ${merged.length} inbox branch(es)`);
|
|
5211
|
+
return merged.length;
|
|
5212
|
+
}
|
|
5213
|
+
});
|
|
5214
|
+
}
|
|
5215
|
+
function publishedInboxBranches(repo, config) {
|
|
5216
|
+
const prefix = inboxPrefix(config);
|
|
5217
|
+
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
5218
|
}
|
|
4456
5219
|
function settleCheckouts(sidecarPath, config, inbox, siblings) {
|
|
4457
5220
|
refreshInboxFromMain(sidecarPath, config, inbox);
|
|
@@ -4465,7 +5228,7 @@ function settleCheckouts(sidecarPath, config, inbox, siblings) {
|
|
|
4465
5228
|
} catch (error) {
|
|
4466
5229
|
logSidecarEvent("settle-skip", {
|
|
4467
5230
|
sidecarPath: sibling,
|
|
4468
|
-
message: error instanceof Error ? error.message : String(error)
|
|
5231
|
+
...reviewRequired(config) ? {} : { message: error instanceof Error ? error.message : String(error) }
|
|
4469
5232
|
});
|
|
4470
5233
|
}
|
|
4471
5234
|
}
|
|
@@ -4500,33 +5263,44 @@ function reportSyncHealth(root, config, outcome) {
|
|
|
4500
5263
|
const sidecarPath = resolveSidecarPath(root, config);
|
|
4501
5264
|
if (!hasGitMetadata(sidecarPath))
|
|
4502
5265
|
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
|
-
});
|
|
5266
|
+
withPublicGit(sidecarPath, () => publishHealth(root, config, sidecarPath, outcome));
|
|
4522
5267
|
} catch (error) {
|
|
4523
5268
|
logSidecarEvent("failure", {
|
|
4524
5269
|
command: "health",
|
|
4525
5270
|
root,
|
|
4526
|
-
message: `could not publish health: ${error instanceof Error ? error.message : String(error)}`
|
|
5271
|
+
message: reviewRequired(config) ? "could not publish health" : `could not publish health: ${error instanceof Error ? error.message : String(error)}`
|
|
4527
5272
|
});
|
|
4528
5273
|
}
|
|
4529
5274
|
}
|
|
5275
|
+
function publishHealth(root, config, sidecarPath, outcome) {
|
|
5276
|
+
const branch = healthBranchFor(sidecarPath);
|
|
5277
|
+
const gated = reviewRequired(config);
|
|
5278
|
+
if (gated && outcome.status === "failed") {
|
|
5279
|
+
try {
|
|
5280
|
+
fetchPublic(sidecarPath);
|
|
5281
|
+
} catch {}
|
|
5282
|
+
}
|
|
5283
|
+
const previous = readHealthRecordAt(sidecarPath, gated ? publicRef(branch) : `origin/${branch}`);
|
|
5284
|
+
const identity = {
|
|
5285
|
+
machine: currentMachine(),
|
|
5286
|
+
root,
|
|
5287
|
+
peer: config.peer === DEFAULT_PEER ? undefined : config.peer,
|
|
5288
|
+
inbox: expandInbox(config, sidecarPath),
|
|
5289
|
+
version: packageVersion()
|
|
5290
|
+
};
|
|
5291
|
+
const record = nextHealthRecord(previous, identity, outcome, nowIso());
|
|
5292
|
+
if (!shouldPublishHealth(previous, record))
|
|
5293
|
+
return;
|
|
5294
|
+
if (gated)
|
|
5295
|
+
assertPublishDestination(sidecarPath);
|
|
5296
|
+
publishHealthRecord(sidecarPath, branch, record);
|
|
5297
|
+
logSidecarEvent("health", {
|
|
5298
|
+
branch,
|
|
5299
|
+
status: record.status,
|
|
5300
|
+
stage: record.stage,
|
|
5301
|
+
consecutiveFailures: record.consecutiveFailures
|
|
5302
|
+
});
|
|
5303
|
+
}
|
|
4530
5304
|
function publishHealthRecord(sidecarPath, branch, record) {
|
|
4531
5305
|
const blob = git(sidecarPath, ["hash-object", "-w", "--stdin"], {
|
|
4532
5306
|
input: serializeHealthRecord(record)
|
|
@@ -4535,17 +5309,8 @@ function publishHealthRecord(sidecarPath, branch, record) {
|
|
|
4535
5309
|
input: `100644 blob ${blob} ${HEALTH_FILE}
|
|
4536
5310
|
`
|
|
4537
5311
|
}).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}`]);
|
|
5312
|
+
const commit = git(sidecarPath, ["commit-tree", tree, "-m", `health: ${record.status} — ${record.machine}`]).stdout.trim();
|
|
5313
|
+
git(sidecarPath, ["push", "--no-follow-tags", "--force", "origin", `${commit}:refs/heads/${branch}`]);
|
|
4549
5314
|
}
|
|
4550
5315
|
function readHealthRecordAt(sidecarPath, ref) {
|
|
4551
5316
|
const result = git(sidecarPath, ["show", `${ref}:${HEALTH_FILE}`], { check: false });
|
|
@@ -4572,9 +5337,9 @@ function mergeInboxBranches(sidecarPath, config, options) {
|
|
|
4572
5337
|
ensureCommitIdentity(sidecarPath);
|
|
4573
5338
|
if (options.remote)
|
|
4574
5339
|
fetch(sidecarPath, false);
|
|
4575
|
-
if (!hasPendingInboxWork(sidecarPath, config)) {
|
|
5340
|
+
if (!hasPendingInboxWork(sidecarPath, config) && mainContainsRemote(sidecarPath, config)) {
|
|
4576
5341
|
if (options.push && !mainMatchesRemote(sidecarPath, config)) {
|
|
4577
|
-
const push = git(sidecarPath, ["push", "origin", `refs/heads/${config.branch}:refs/heads/${config.branch}`], {
|
|
5342
|
+
const push = git(sidecarPath, ["push", "--no-follow-tags", "origin", `refs/heads/${config.branch}:refs/heads/${config.branch}`], {
|
|
4578
5343
|
check: false
|
|
4579
5344
|
});
|
|
4580
5345
|
if (push.status === 0) {
|
|
@@ -4593,18 +5358,21 @@ function mergeInboxBranches(sidecarPath, config, options) {
|
|
|
4593
5358
|
if (git(sidecarPath, ["branch", "--show-current"]).stdout.trim() === config.branch) {
|
|
4594
5359
|
ensureInboxBranch(sidecarPath, config, expandInbox(config, sidecarPath));
|
|
4595
5360
|
}
|
|
4596
|
-
|
|
4597
|
-
|
|
5361
|
+
return withScratchWorktree(sidecarPath, config, "HEAD", (worktree) => mergeInboxBranchesAt(worktree, config, options));
|
|
5362
|
+
}
|
|
5363
|
+
function withScratchWorktree(sidecarPath, config, start, fn) {
|
|
5364
|
+
const scratch = path9.join(os5.tmpdir(), `sidecar-merge-${crypto5.createHash("sha1").update(sidecarPath).digest("hex").slice(0, 12)}`);
|
|
5365
|
+
const worktree = path9.join(scratch, "checkout");
|
|
4598
5366
|
git(sidecarPath, ["worktree", "remove", "--force", worktree], { check: false });
|
|
4599
|
-
|
|
5367
|
+
fs9.rmSync(scratch, { recursive: true, force: true });
|
|
4600
5368
|
git(sidecarPath, ["worktree", "prune", "--expire", "now"], { check: false });
|
|
4601
5369
|
try {
|
|
4602
|
-
git(sidecarPath, ["worktree", "add", "--detach", worktree]);
|
|
5370
|
+
git(sidecarPath, ["worktree", "add", "--detach", worktree, start]);
|
|
4603
5371
|
ensureRedactionFilter(worktree, config.redaction, config);
|
|
4604
|
-
return
|
|
5372
|
+
return fn(worktree);
|
|
4605
5373
|
} finally {
|
|
4606
5374
|
git(sidecarPath, ["worktree", "remove", "--force", worktree], { check: false });
|
|
4607
|
-
|
|
5375
|
+
fs9.rmSync(scratch, { recursive: true, force: true });
|
|
4608
5376
|
}
|
|
4609
5377
|
}
|
|
4610
5378
|
function mainMatchesRemote(repo, config) {
|
|
@@ -4615,6 +5383,11 @@ function mainMatchesRemote(repo, config) {
|
|
|
4615
5383
|
const remote = refs.get(remoteRef);
|
|
4616
5384
|
return local !== undefined && local === remote;
|
|
4617
5385
|
}
|
|
5386
|
+
function mainContainsRemote(repo, config) {
|
|
5387
|
+
if (!remoteRefExists(repo, config.branch) || !branchExists(repo, config.branch))
|
|
5388
|
+
return true;
|
|
5389
|
+
return isAncestor(repo, `origin/${config.branch}`, `refs/heads/${config.branch}`);
|
|
5390
|
+
}
|
|
4618
5391
|
function hasPendingInboxWork(repo, config) {
|
|
4619
5392
|
const main = `refs/heads/${config.branch}`;
|
|
4620
5393
|
return pendingInboxBranches(repo, config).some((branch) => !isAncestor(repo, branch, main));
|
|
@@ -4638,7 +5411,7 @@ function mergeInboxBranchesAt(sidecarPath, config, options) {
|
|
|
4638
5411
|
merged.push(remoteBranch);
|
|
4639
5412
|
}
|
|
4640
5413
|
if (options.push) {
|
|
4641
|
-
const push = git(sidecarPath, ["push", "-u", "origin", `HEAD:refs/heads/${config.branch}`], { check: false });
|
|
5414
|
+
const push = git(sidecarPath, ["push", "--no-follow-tags", "-u", "origin", `HEAD:refs/heads/${config.branch}`], { check: false });
|
|
4642
5415
|
if (push.status !== 0) {
|
|
4643
5416
|
if (attempt >= maxAttempts) {
|
|
4644
5417
|
throw new SidecarError(push.stderr.trim() || `could not push ${config.branch}`);
|
|
@@ -4709,10 +5482,10 @@ function familySidecarCheckout(root, config) {
|
|
|
4709
5482
|
} catch {
|
|
4710
5483
|
return;
|
|
4711
5484
|
}
|
|
4712
|
-
if (primaryConfig
|
|
5485
|
+
if (!sharesFamilyCheckout(primaryConfig, config))
|
|
4713
5486
|
return;
|
|
4714
5487
|
const primaryPath = resolveSidecarPath(primary, primaryConfig);
|
|
4715
|
-
if (
|
|
5488
|
+
if (path9.resolve(primaryPath) === path9.resolve(primary))
|
|
4716
5489
|
return;
|
|
4717
5490
|
if (!hasGitMetadata(primaryPath))
|
|
4718
5491
|
cloneOrUpdate(primary, primaryConfig, true);
|
|
@@ -4732,7 +5505,7 @@ function checkoutIsUnlinkedFromFamily(root, config, sidecarPath) {
|
|
|
4732
5505
|
if (isStandalone(config))
|
|
4733
5506
|
return false;
|
|
4734
5507
|
try {
|
|
4735
|
-
if (!
|
|
5508
|
+
if (!fs9.statSync(path9.join(sidecarPath, ".git")).isDirectory())
|
|
4736
5509
|
return false;
|
|
4737
5510
|
} catch {
|
|
4738
5511
|
return false;
|
|
@@ -4741,21 +5514,34 @@ function checkoutIsUnlinkedFromFamily(root, config, sidecarPath) {
|
|
|
4741
5514
|
if (!primary)
|
|
4742
5515
|
return false;
|
|
4743
5516
|
try {
|
|
4744
|
-
|
|
4745
|
-
return primaryConfig.remote === config.remote;
|
|
5517
|
+
return sharesFamilyCheckout(readConfig(peerConfigPath(primary, config.peer)), config);
|
|
4746
5518
|
} catch {
|
|
4747
5519
|
return false;
|
|
4748
5520
|
}
|
|
4749
5521
|
}
|
|
5522
|
+
function sharesFamilyCheckout(primaryConfig, config) {
|
|
5523
|
+
return primaryConfig.remote === config.remote && reviewRequired(primaryConfig) === reviewRequired(config);
|
|
5524
|
+
}
|
|
5525
|
+
function bindReviewPolicy(sidecarPath, config) {
|
|
5526
|
+
const bound = git(sidecarPath, ["config", "--local", "--get", REVIEW_POLICY_KEY], { check: false }).stdout.trim();
|
|
5527
|
+
if (reviewRequired(config)) {
|
|
5528
|
+
if (bound !== "required")
|
|
5529
|
+
git(sidecarPath, ["config", "--local", REVIEW_POLICY_KEY, "required"]);
|
|
5530
|
+
return;
|
|
5531
|
+
}
|
|
5532
|
+
if (bound === "required") {
|
|
5533
|
+
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}\``);
|
|
5534
|
+
}
|
|
5535
|
+
}
|
|
4750
5536
|
function cloneOrUpdate(root, config, bootstrapMain, options) {
|
|
4751
5537
|
const sidecarPath = resolveSidecarPath(root, config);
|
|
4752
|
-
if (
|
|
4753
|
-
if (
|
|
5538
|
+
if (fs9.existsSync(sidecarPath) && !hasGitMetadata(sidecarPath)) {
|
|
5539
|
+
if (fs9.readdirSync(sidecarPath).length) {
|
|
4754
5540
|
throw new SidecarError(`${sidecarPath} exists and is not an empty Git repo`);
|
|
4755
5541
|
}
|
|
4756
|
-
|
|
5542
|
+
fs9.rmdirSync(sidecarPath);
|
|
4757
5543
|
}
|
|
4758
|
-
if (!
|
|
5544
|
+
if (!fs9.existsSync(sidecarPath)) {
|
|
4759
5545
|
const primaryPath = familySidecarCheckout(root, config);
|
|
4760
5546
|
if (primaryPath)
|
|
4761
5547
|
git(primaryPath, ["worktree", "add", "--detach", sidecarPath]);
|
|
@@ -4776,7 +5562,7 @@ function cloneOrUpdate(root, config, bootstrapMain, options) {
|
|
|
4776
5562
|
throw new SidecarError(`${sidecarPath} is not usable as a sidecar checkout`);
|
|
4777
5563
|
}
|
|
4778
5564
|
if (options?.checkoutId) {
|
|
4779
|
-
|
|
5565
|
+
fs9.writeFileSync(path9.join(gitDir(sidecarPath), "sidecar-id"), `${options.checkoutId}
|
|
4780
5566
|
`, {
|
|
4781
5567
|
encoding: "utf8",
|
|
4782
5568
|
mode: 384
|
|
@@ -4784,6 +5570,7 @@ function cloneOrUpdate(root, config, bootstrapMain, options) {
|
|
|
4784
5570
|
}
|
|
4785
5571
|
ensureCommitIdentity(sidecarPath);
|
|
4786
5572
|
ensureRedactionFilter(sidecarPath, config.redaction, config);
|
|
5573
|
+
bindReviewPolicy(sidecarPath, config);
|
|
4787
5574
|
if (bootstrapMain)
|
|
4788
5575
|
bootstrapMainBranch(sidecarPath, config);
|
|
4789
5576
|
const inbox = expandInbox(config, sidecarPath);
|
|
@@ -4793,6 +5580,10 @@ function cloneOrUpdate(root, config, bootstrapMain, options) {
|
|
|
4793
5580
|
function bootstrapMainBranch(repo, config) {
|
|
4794
5581
|
if (remoteRefExists(repo, config.branch))
|
|
4795
5582
|
return;
|
|
5583
|
+
if (reviewRequired(config)) {
|
|
5584
|
+
withPublicGit(repo, () => bootstrapReviewedMain(repo, config));
|
|
5585
|
+
return;
|
|
5586
|
+
}
|
|
4796
5587
|
if (hasAnyCommit(repo)) {
|
|
4797
5588
|
const current = git(repo, ["branch", "--show-current"]).stdout.trim();
|
|
4798
5589
|
if (current !== config.branch) {
|
|
@@ -4811,16 +5602,31 @@ function bootstrapMainBranch(repo, config) {
|
|
|
4811
5602
|
pushBranch(repo, config.branch);
|
|
4812
5603
|
return;
|
|
4813
5604
|
}
|
|
4814
|
-
|
|
5605
|
+
fs9.writeFileSync(path9.join(repo, "README.md"), `# Sidecar
|
|
4815
5606
|
|
|
4816
5607
|
Scratch space for a code repository: plans, notes, and agent context.
|
|
4817
5608
|
This is a plain git repo you own — read it, edit it, clone it anywhere.
|
|
4818
|
-
Kept in sync by [sidecar](https://github.com/
|
|
5609
|
+
Kept in sync by [sidecar](https://github.com/endograph/sidecar).
|
|
4819
5610
|
`, "utf8");
|
|
4820
5611
|
git(repo, ["add", "README.md"]);
|
|
4821
5612
|
git(repo, ["commit", "-m", "Initialize sidecar"]);
|
|
4822
5613
|
pushBranch(repo, config.branch);
|
|
4823
5614
|
}
|
|
5615
|
+
function bootstrapReviewedMain(repo, config) {
|
|
5616
|
+
assertPublishDestination(repo);
|
|
5617
|
+
const root = git(repo, ["commit-tree", emptyTree(repo), "-m", "Initialize sidecar"]).stdout.trim();
|
|
5618
|
+
git(repo, ["push", "--no-follow-tags", "origin", `${root}:refs/heads/${config.branch}`]);
|
|
5619
|
+
fetchPublic(repo);
|
|
5620
|
+
console.log(`started ${config.branch} on the remote empty; this checkout's content is published through review`);
|
|
5621
|
+
const ref = `refs/heads/${config.branch}`;
|
|
5622
|
+
if (!hasAnyCommit(repo)) {
|
|
5623
|
+
git(repo, ["update-ref", ref, root]);
|
|
5624
|
+
return;
|
|
5625
|
+
}
|
|
5626
|
+
const local = git(repo, ["rev-parse", "--verify", "--quiet", ref], { check: false }).stdout.trim() || git(repo, ["rev-parse", "HEAD"]).stdout.trim();
|
|
5627
|
+
const link = git(repo, ["commit-tree", treeOf(repo, local), "-p", local, "-p", root, "-m", "sidecar: link the remote's initial main"]).stdout.trim();
|
|
5628
|
+
git(repo, ["update-ref", ref, link]);
|
|
5629
|
+
}
|
|
4824
5630
|
function ensureMainBranch(repo, config) {
|
|
4825
5631
|
if (branchExists(repo, config.branch)) {
|
|
4826
5632
|
git(repo, ["switch", config.branch]);
|
|
@@ -4842,6 +5648,16 @@ function ensureMainBranch(repo, config) {
|
|
|
4842
5648
|
git(repo, ["merge", "--ff-only", remoteBranch]);
|
|
4843
5649
|
return;
|
|
4844
5650
|
}
|
|
5651
|
+
if (reviewRequired(config)) {
|
|
5652
|
+
if (git(repo, ["merge-base", "HEAD", remoteBranch], { check: false }).status !== 0) {
|
|
5653
|
+
const link = git(repo, ["commit-tree", treeOf(repo, "HEAD"), "-p", "HEAD", "-p", remoteBranch, "-m", `sidecar: link the remote's new ${config.branch}`]).stdout.trim();
|
|
5654
|
+
git(repo, ["merge", "--ff-only", link]);
|
|
5655
|
+
console.log(`${remoteBranch} was started over; local ${config.branch} is joined to it and reviewed from there`);
|
|
5656
|
+
return;
|
|
5657
|
+
}
|
|
5658
|
+
mergeInboxBranch(repo, config, remoteBranch, { forkFiles: true });
|
|
5659
|
+
return;
|
|
5660
|
+
}
|
|
4845
5661
|
const tip = git(repo, ["rev-parse", "--short", "HEAD"]).stdout.trim();
|
|
4846
5662
|
const discarded = `refs/sidecar-discarded/${config.branch}/${utcTimestamp()}-${tip}`;
|
|
4847
5663
|
git(repo, ["update-ref", discarded, "HEAD"], { check: false });
|
|
@@ -4889,29 +5705,29 @@ function ensureSidecarCheckout(root, config) {
|
|
|
4889
5705
|
return requireSidecarCheckout(root, config);
|
|
4890
5706
|
}
|
|
4891
5707
|
function snapshot(repo, mainRoot, inbox, message = "sidecar snapshot", redactionMode = DEFAULT_REDACTION_MODE, policy) {
|
|
4892
|
-
if (
|
|
5708
|
+
if (fs9.existsSync(path9.join(gitDir(repo), "MERGE_HEAD"))) {
|
|
4893
5709
|
throw new SidecarError("cannot snapshot an unfinished merge; resolve or abort it before syncing");
|
|
4894
5710
|
}
|
|
4895
5711
|
const rewired = ensureRedactionFilter(repo, redactionMode, policy);
|
|
4896
|
-
const revisionPath =
|
|
5712
|
+
const revisionPath = path9.join(gitDir(repo), "sidecar-redaction-revision");
|
|
4897
5713
|
const revision = `${REDACTION_FILTER_REVISION}:${redactionMode}:${rulesFingerprint(policy?.rules)}`;
|
|
4898
5714
|
let appliedRevision = "";
|
|
4899
5715
|
try {
|
|
4900
|
-
appliedRevision =
|
|
5716
|
+
appliedRevision = fs9.readFileSync(revisionPath, "utf8");
|
|
4901
5717
|
} catch {}
|
|
4902
5718
|
git(repo, ["add", "-A"]);
|
|
4903
5719
|
if ((rewired || appliedRevision !== revision) && hasAnyCommit(repo)) {
|
|
4904
5720
|
git(repo, ["add", "--renormalize", "."]);
|
|
4905
5721
|
}
|
|
4906
5722
|
if (git(repo, ["diff", "--cached", "--quiet"], { check: false }).status === 0) {
|
|
4907
|
-
|
|
5723
|
+
fs9.writeFileSync(revisionPath, revision, "utf8");
|
|
4908
5724
|
console.log("no sidecar changes to snapshot");
|
|
4909
5725
|
return false;
|
|
4910
5726
|
}
|
|
4911
5727
|
const staged = git(repo, ["diff", "--cached", "--name-only", "-z", "--diff-filter=d"]).stdout.split("\x00").filter(Boolean);
|
|
4912
|
-
const source =
|
|
5728
|
+
const source = currentMachine();
|
|
4913
5729
|
const body = [message, "", `source: ${source}`];
|
|
4914
|
-
if (
|
|
5730
|
+
if (path9.resolve(repo) !== path9.resolve(mainRoot)) {
|
|
4915
5731
|
const mainHead = git(mainRoot, ["rev-parse", "--short", "HEAD"], { check: false });
|
|
4916
5732
|
body.push(`main-head: ${mainHead.status === 0 ? mainHead.stdout.trim() : "unborn"}`);
|
|
4917
5733
|
}
|
|
@@ -4919,7 +5735,7 @@ function snapshot(repo, mainRoot, inbox, message = "sidecar snapshot", redaction
|
|
|
4919
5735
|
body.push(...writtenTrailers(repo, staged));
|
|
4920
5736
|
git(repo, ["commit", "-m", body.join(`
|
|
4921
5737
|
`)]);
|
|
4922
|
-
|
|
5738
|
+
fs9.writeFileSync(revisionPath, revision, "utf8");
|
|
4923
5739
|
console.log(`committed sidecar snapshot to ${paint("brand", inbox)}`);
|
|
4924
5740
|
reportRedactions(repo, staged, redactionMode, policy?.rules);
|
|
4925
5741
|
return true;
|
|
@@ -4933,7 +5749,7 @@ function writtenTrailers(repo, staged) {
|
|
|
4933
5749
|
`))
|
|
4934
5750
|
continue;
|
|
4935
5751
|
try {
|
|
4936
|
-
const seconds = Math.floor(
|
|
5752
|
+
const seconds = Math.floor(fs9.lstatSync(path9.join(repo, relPath)).mtimeMs / 1000);
|
|
4937
5753
|
if (seconds > 0)
|
|
4938
5754
|
trailers.push(`${WRITTEN_TRAILER} ${seconds} ${relPath}`);
|
|
4939
5755
|
} catch {}
|
|
@@ -4944,7 +5760,7 @@ function reportRedactions(repo, staged, mode, rules) {
|
|
|
4944
5760
|
let files = 0;
|
|
4945
5761
|
let items = 0;
|
|
4946
5762
|
for (const relPath of staged) {
|
|
4947
|
-
const delta = fileRedactionDelta(
|
|
5763
|
+
const delta = fileRedactionDelta(path9.join(repo, relPath), mode, { rules, relativePath: relPath });
|
|
4948
5764
|
if (!delta)
|
|
4949
5765
|
continue;
|
|
4950
5766
|
files += 1;
|
|
@@ -4958,7 +5774,7 @@ function reportRedactions(repo, staged, mode, rules) {
|
|
|
4958
5774
|
function fileRedactionDelta(filePath, mode, policy) {
|
|
4959
5775
|
let data;
|
|
4960
5776
|
try {
|
|
4961
|
-
data =
|
|
5777
|
+
data = fs9.readFileSync(filePath);
|
|
4962
5778
|
} catch {
|
|
4963
5779
|
return;
|
|
4964
5780
|
}
|
|
@@ -4973,14 +5789,14 @@ function fileRedactionDelta(filePath, mode, policy) {
|
|
|
4973
5789
|
return { text, redacted, items };
|
|
4974
5790
|
}
|
|
4975
5791
|
function checkoutRedactionPolicy(repo) {
|
|
4976
|
-
const policyPath =
|
|
5792
|
+
const policyPath = path9.join(gitDir(repo), REDACTION_POLICY_FILE);
|
|
4977
5793
|
let bound;
|
|
4978
5794
|
try {
|
|
4979
|
-
bound = JSON.parse(
|
|
5795
|
+
bound = JSON.parse(fs9.readFileSync(policyPath, "utf8"));
|
|
4980
5796
|
} catch {
|
|
4981
5797
|
throw new SidecarError("missing or invalid checkout redaction policy; run `sidecar sync`");
|
|
4982
5798
|
}
|
|
4983
|
-
if (!bound || typeof bound !== "object" || typeof bound.fingerprint !== "string" || bound.rulesPath !== undefined && (typeof bound.rulesPath !== "string" || !
|
|
5799
|
+
if (!bound || typeof bound !== "object" || typeof bound.fingerprint !== "string" || bound.rulesPath !== undefined && (typeof bound.rulesPath !== "string" || !path9.isAbsolute(bound.rulesPath))) {
|
|
4984
5800
|
throw new SidecarError("invalid checkout redaction policy; run `sidecar sync`");
|
|
4985
5801
|
}
|
|
4986
5802
|
const mode = redactionModeConfigValue(bound.mode, "checkout redaction mode");
|
|
@@ -4995,27 +5811,28 @@ function ensureRedactionFilter(repo, mode = DEFAULT_REDACTION_MODE, policy) {
|
|
|
4995
5811
|
if (policy?.rules?.length && !policy.rulesPath) {
|
|
4996
5812
|
throw new SidecarError("path redaction rules require an explicit host rules file");
|
|
4997
5813
|
}
|
|
4998
|
-
const rulesPath = policy?.rulesPath ?
|
|
5814
|
+
const rulesPath = policy?.rulesPath ? path9.resolve(policy.rulesPath) : undefined;
|
|
4999
5815
|
if (rulesPath && rulesFingerprint(readRules(rulesPath)) !== fingerprint) {
|
|
5000
5816
|
throw new SidecarError("sidecar rules changed during this operation; run `sidecar sync` to apply them");
|
|
5001
5817
|
}
|
|
5002
|
-
const policyPath =
|
|
5818
|
+
const policyPath = path9.join(gitDir(repo), REDACTION_POLICY_FILE);
|
|
5003
5819
|
const bound = JSON.stringify({ mode, rulesPath, fingerprint });
|
|
5004
5820
|
let previous = "";
|
|
5005
5821
|
try {
|
|
5006
|
-
previous =
|
|
5822
|
+
previous = fs9.readFileSync(policyPath, "utf8");
|
|
5007
5823
|
} catch {}
|
|
5008
5824
|
const policyChanged = previous !== bound;
|
|
5009
5825
|
if (policyChanged)
|
|
5010
|
-
|
|
5011
|
-
const command = `${filterCommandQuote(process.execPath)} ${filterCommandQuote(
|
|
5826
|
+
fs9.writeFileSync(policyPath, bound, { encoding: "utf8", mode: 384 });
|
|
5827
|
+
const command = `${filterCommandQuote(process.execPath)} ${filterCommandQuote(sidecarCliPath())} redact --checkout-policy --path %f`;
|
|
5012
5828
|
const wanted = [
|
|
5013
5829
|
[`filter.${REDACTION_FILTER_NAME}.clean`, command],
|
|
5014
5830
|
[`filter.${REDACTION_FILTER_NAME}.revision`, REDACTION_FILTER_REVISION],
|
|
5015
5831
|
[`filter.${REDACTION_FILTER_NAME}.smudge`, "cat"],
|
|
5016
5832
|
[`filter.${REDACTION_FILTER_NAME}.required`, "true"]
|
|
5017
5833
|
];
|
|
5018
|
-
const
|
|
5834
|
+
const processKey = `filter.${REDACTION_FILTER_NAME}.process`;
|
|
5835
|
+
const attributesPath = path9.join(gitCommonDir(repo), "info", "attributes");
|
|
5019
5836
|
const line = `* filter=${REDACTION_FILTER_NAME}`;
|
|
5020
5837
|
const configured = git(repo, ["config", "--get-regexp", `^filter\\.${REDACTION_FILTER_NAME}\\.`], {
|
|
5021
5838
|
check: false
|
|
@@ -5025,10 +5842,10 @@ function ensureRedactionFilter(repo, mode = DEFAULT_REDACTION_MODE, policy) {
|
|
|
5025
5842
|
const space = entry.indexOf(" ");
|
|
5026
5843
|
return [entry.slice(0, space), entry.slice(space + 1)];
|
|
5027
5844
|
}));
|
|
5028
|
-
const configOk = wanted.every(([key, value]) => current.get(key) === value);
|
|
5845
|
+
const configOk = wanted.every(([key, value]) => current.get(key) === value) && !current.has(processKey);
|
|
5029
5846
|
let attributes = "";
|
|
5030
5847
|
try {
|
|
5031
|
-
attributes =
|
|
5848
|
+
attributes = fs9.readFileSync(attributesPath, "utf8");
|
|
5032
5849
|
} catch {}
|
|
5033
5850
|
const attributesOk = attributes.split(/\r?\n/).includes(line);
|
|
5034
5851
|
if (configOk && attributesOk)
|
|
@@ -5036,9 +5853,10 @@ function ensureRedactionFilter(repo, mode = DEFAULT_REDACTION_MODE, policy) {
|
|
|
5036
5853
|
for (const [key, value] of wanted) {
|
|
5037
5854
|
git(repo, ["config", key, value]);
|
|
5038
5855
|
}
|
|
5856
|
+
git(repo, ["config", "--unset-all", processKey], { check: false });
|
|
5039
5857
|
if (!attributesOk) {
|
|
5040
|
-
|
|
5041
|
-
|
|
5858
|
+
fs9.mkdirSync(path9.dirname(attributesPath), { recursive: true });
|
|
5859
|
+
fs9.appendFileSync(attributesPath, attributes && !attributes.endsWith(`
|
|
5042
5860
|
`) ? `
|
|
5043
5861
|
${line}
|
|
5044
5862
|
` : `${line}
|
|
@@ -5048,13 +5866,13 @@ ${line}
|
|
|
5048
5866
|
}
|
|
5049
5867
|
function removeRedactionFilter(repo) {
|
|
5050
5868
|
git(repo, ["config", "--remove-section", `filter.${REDACTION_FILTER_NAME}`], { check: false });
|
|
5051
|
-
|
|
5052
|
-
|
|
5053
|
-
const attributesPath =
|
|
5869
|
+
fs9.rmSync(path9.join(gitDir(repo), "sidecar-redaction-revision"), { force: true });
|
|
5870
|
+
fs9.rmSync(path9.join(gitDir(repo), REDACTION_POLICY_FILE), { force: true });
|
|
5871
|
+
const attributesPath = path9.join(gitCommonDir(repo), "info", "attributes");
|
|
5054
5872
|
const line = `* filter=${REDACTION_FILTER_NAME}`;
|
|
5055
5873
|
let contents;
|
|
5056
5874
|
try {
|
|
5057
|
-
contents =
|
|
5875
|
+
contents = fs9.readFileSync(attributesPath, "utf8");
|
|
5058
5876
|
} catch {
|
|
5059
5877
|
return;
|
|
5060
5878
|
}
|
|
@@ -5063,17 +5881,13 @@ function removeRedactionFilter(repo) {
|
|
|
5063
5881
|
if (kept.length === lines.length)
|
|
5064
5882
|
return;
|
|
5065
5883
|
if (kept.every((entry) => !entry.trim())) {
|
|
5066
|
-
|
|
5884
|
+
fs9.rmSync(attributesPath, { force: true });
|
|
5067
5885
|
} else {
|
|
5068
|
-
|
|
5886
|
+
fs9.writeFileSync(attributesPath, `${kept.join(`
|
|
5069
5887
|
`).replace(/\s+$/g, "")}
|
|
5070
5888
|
`, "utf8");
|
|
5071
5889
|
}
|
|
5072
5890
|
}
|
|
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
5891
|
function filterCommandQuote(value) {
|
|
5078
5892
|
return `"${value.replace(/([\\"$`])/g, "\\$1")}"`;
|
|
5079
5893
|
}
|
|
@@ -5120,7 +5934,7 @@ function refreshInboxFromMain(repo, config, inbox) {
|
|
|
5120
5934
|
}
|
|
5121
5935
|
}
|
|
5122
5936
|
function pushBranch(repo, branch) {
|
|
5123
|
-
git(repo, ["push", "-u", "origin", `HEAD:refs/heads/${branch}`]);
|
|
5937
|
+
git(repo, ["push", "--no-follow-tags", "-u", "origin", `HEAD:refs/heads/${branch}`]);
|
|
5124
5938
|
console.log(`pushed ${paint("brand", branch)}`);
|
|
5125
5939
|
}
|
|
5126
5940
|
function forkConflicts(repo, remoteBranch, selectedPaths) {
|
|
@@ -5163,14 +5977,14 @@ function forkConflicts(repo, remoteBranch, selectedPaths) {
|
|
|
5163
5977
|
continue;
|
|
5164
5978
|
const { oid, mode } = stages[stage];
|
|
5165
5979
|
const outPath = forkPath(conflictPath, label, oid);
|
|
5166
|
-
const fullOut =
|
|
5167
|
-
|
|
5168
|
-
|
|
5980
|
+
const fullOut = path9.join(repo, outPath);
|
|
5981
|
+
fs9.mkdirSync(path9.dirname(fullOut), { recursive: true });
|
|
5982
|
+
fs9.rmSync(fullOut, { force: true });
|
|
5169
5983
|
if (mode === "120000")
|
|
5170
|
-
|
|
5984
|
+
fs9.symlinkSync(blob.toString("utf8"), fullOut);
|
|
5171
5985
|
else {
|
|
5172
|
-
|
|
5173
|
-
|
|
5986
|
+
fs9.writeFileSync(fullOut, blob);
|
|
5987
|
+
fs9.chmodSync(fullOut, mode === "100755" ? 493 : 420);
|
|
5174
5988
|
}
|
|
5175
5989
|
git(repo, ["add", "--", `:(literal)${outPath}`]);
|
|
5176
5990
|
versions.push({
|
|
@@ -5178,18 +5992,18 @@ function forkConflicts(repo, remoteBranch, selectedPaths) {
|
|
|
5178
5992
|
label,
|
|
5179
5993
|
oid,
|
|
5180
5994
|
path: outPath,
|
|
5181
|
-
sha256:
|
|
5995
|
+
sha256: crypto5.createHash("sha256").update(blob).digest("hex")
|
|
5182
5996
|
});
|
|
5183
5997
|
}
|
|
5184
5998
|
git(repo, ["rm", "-f", "--", `:(literal)${conflictPath}`]);
|
|
5185
5999
|
manifest.paths.push({ path: conflictPath, versions });
|
|
5186
6000
|
}
|
|
5187
|
-
const manifestDir =
|
|
5188
|
-
|
|
5189
|
-
const manifestPath =
|
|
5190
|
-
|
|
6001
|
+
const manifestDir = path9.join(repo, ".sidecar-conflicts");
|
|
6002
|
+
fs9.mkdirSync(manifestDir, { recursive: true });
|
|
6003
|
+
const manifestPath = path9.join(manifestDir, `${timestamp}-${manifestLabel}-fork-files.json`);
|
|
6004
|
+
fs9.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}
|
|
5191
6005
|
`, "utf8");
|
|
5192
|
-
git(repo, ["add", "--", `:(literal)${
|
|
6006
|
+
git(repo, ["add", "--", `:(literal)${path9.relative(repo, manifestPath)}`]);
|
|
5193
6007
|
if (Object.keys(selectConflictPaths(unmergedPaths(repo), selectedPaths)).length) {
|
|
5194
6008
|
throw new SidecarError("fork-files did not clear all unmerged paths");
|
|
5195
6009
|
}
|
|
@@ -5209,7 +6023,8 @@ function resolveLastWriterWins(repo, canonicalBranch, remoteBranch, selectedPath
|
|
|
5209
6023
|
const oursChanged = oursWrite.source !== baseWrite.source;
|
|
5210
6024
|
const theirsChanged = theirsWrite.source !== baseWrite.source;
|
|
5211
6025
|
const incoming = oursChanged !== theirsChanged ? theirsChanged : theirsAt > oursAt || theirsAt === oursAt && entryKey(theirs) > entryKey(ours);
|
|
5212
|
-
|
|
6026
|
+
const conflict = oursChanged && theirsChanged && entryKey(ours) !== entryKey(theirs);
|
|
6027
|
+
return { filePath, ours, theirs, incoming, conflict, winner: incoming ? theirs : ours, write: incoming ? theirsWrite : oursWrite };
|
|
5213
6028
|
});
|
|
5214
6029
|
const selected = new Set(selections.map((entry) => entry.filePath));
|
|
5215
6030
|
const indexed = new Set(git(repo, ["ls-files", "-z"]).stdout.split("\x00").filter(Boolean));
|
|
@@ -5218,7 +6033,7 @@ function resolveLastWriterWins(repo, canonicalBranch, remoteBranch, selectedPath
|
|
|
5218
6033
|
...selections.filter((entry) => entry.winner).map((entry) => entry.filePath)
|
|
5219
6034
|
]);
|
|
5220
6035
|
for (const filePath of present) {
|
|
5221
|
-
for (let parent =
|
|
6036
|
+
for (let parent = path9.posix.dirname(filePath);parent !== "."; parent = path9.posix.dirname(parent)) {
|
|
5222
6037
|
if (present.has(parent))
|
|
5223
6038
|
throw new SidecarError(`last-writer-wins selected incompatible file and directory paths: ${parent}, ${filePath}`);
|
|
5224
6039
|
}
|
|
@@ -5229,30 +6044,33 @@ function resolveLastWriterWins(repo, canonicalBranch, remoteBranch, selectedPath
|
|
|
5229
6044
|
}
|
|
5230
6045
|
}
|
|
5231
6046
|
const written = [];
|
|
5232
|
-
for (const { filePath, ours, theirs, incoming, winner, write } of selections) {
|
|
6047
|
+
for (const { filePath, ours, theirs, incoming, conflict, winner, write } of selections) {
|
|
5233
6048
|
if (winner) {
|
|
5234
6049
|
git(repo, ["restore", `--source=${incoming ? remoteBranch : "HEAD"}`, "--staged", "--worktree", "--", `:(literal)${filePath}`]);
|
|
5235
6050
|
git(repo, ["add", "--renormalize", "--", `:(literal)${filePath}`]);
|
|
5236
6051
|
}
|
|
5237
|
-
|
|
5238
|
-
|
|
5239
|
-
|
|
5240
|
-
|
|
5241
|
-
|
|
5242
|
-
|
|
5243
|
-
|
|
6052
|
+
if (conflict)
|
|
6053
|
+
manifest.paths.push({
|
|
6054
|
+
path: filePath,
|
|
6055
|
+
kept: incoming ? branch : canonicalBranch,
|
|
6056
|
+
kept_at: write.time,
|
|
6057
|
+
dropped: incoming ? canonicalBranch : branch,
|
|
6058
|
+
dropped_oid: (incoming ? ours : theirs)?.oid ?? null
|
|
6059
|
+
});
|
|
5244
6060
|
written.push(lwwWrittenTrailer(filePath, write));
|
|
5245
6061
|
}
|
|
5246
|
-
|
|
5247
|
-
|
|
5248
|
-
|
|
5249
|
-
|
|
6062
|
+
if (manifest.paths.length) {
|
|
6063
|
+
const manifestDir = path9.join(repo, ".sidecar-conflicts");
|
|
6064
|
+
fs9.mkdirSync(manifestDir, { recursive: true });
|
|
6065
|
+
const manifestPath = path9.join(manifestDir, `${timestamp}-${fileLabel(branch)}-lww.json`);
|
|
6066
|
+
fs9.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}
|
|
5250
6067
|
`, "utf8");
|
|
5251
|
-
|
|
6068
|
+
git(repo, ["add", "--", `:(literal)${path9.relative(repo, manifestPath)}`]);
|
|
6069
|
+
}
|
|
5252
6070
|
if (Object.keys(selectConflictPaths(unmergedPaths(repo), selectedPaths)).length) {
|
|
5253
6071
|
throw new SidecarError("last-writer-wins did not clear all unmerged paths");
|
|
5254
6072
|
}
|
|
5255
|
-
console.log(`selected ${
|
|
6073
|
+
console.log(`selected ${selections.length} complete file version(s) by last writer`);
|
|
5256
6074
|
return written;
|
|
5257
6075
|
}
|
|
5258
6076
|
function entryKey(entry) {
|
|
@@ -5275,7 +6093,7 @@ function treeEntry(repo, ref, filePath) {
|
|
|
5275
6093
|
return;
|
|
5276
6094
|
}
|
|
5277
6095
|
function lwwTrailerPrefix(filePath) {
|
|
5278
|
-
return `sidecar-lww-${
|
|
6096
|
+
return `sidecar-lww-${crypto5.createHash("sha256").update(filePath).digest("hex")}:`;
|
|
5279
6097
|
}
|
|
5280
6098
|
function lwwWrittenTrailer(filePath, write) {
|
|
5281
6099
|
return `${lwwTrailerPrefix(filePath)} ${write.time} ${write.source || "-"}`;
|
|
@@ -5322,11 +6140,11 @@ function recordedLwwEvent(body, prefix) {
|
|
|
5322
6140
|
return Number.isInteger(time) && time >= 0 && source ? { time, source: source === "-" ? "" : source } : undefined;
|
|
5323
6141
|
}
|
|
5324
6142
|
function forkPath(conflictPath, label, oid) {
|
|
5325
|
-
const parsed =
|
|
6143
|
+
const parsed = path9.parse(conflictPath);
|
|
5326
6144
|
const shortOid = oid ? oid.slice(0, 7) : "missing";
|
|
5327
6145
|
const safeLabel = fileLabel(label);
|
|
5328
6146
|
const forkName = parsed.ext ? `${parsed.name}.conflict.${safeLabel}.${shortOid}${parsed.ext}` : `${parsed.name}.conflict.${safeLabel}.${shortOid}`;
|
|
5329
|
-
return
|
|
6147
|
+
return path9.join(parsed.dir, forkName);
|
|
5330
6148
|
}
|
|
5331
6149
|
function fileLabel(value) {
|
|
5332
6150
|
return slug(value).replaceAll("/", "-");
|
|
@@ -5385,7 +6203,7 @@ function pendingInboxBranches(repo, config) {
|
|
|
5385
6203
|
});
|
|
5386
6204
|
return [...local, ...remote].sort();
|
|
5387
6205
|
}
|
|
5388
|
-
var
|
|
6206
|
+
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";
|
|
5389
6207
|
var init_sync = __esm(() => {
|
|
5390
6208
|
init_color();
|
|
5391
6209
|
init_util();
|
|
@@ -5393,13 +6211,14 @@ var init_sync = __esm(() => {
|
|
|
5393
6211
|
init_install();
|
|
5394
6212
|
init_config();
|
|
5395
6213
|
init_state();
|
|
6214
|
+
init_review();
|
|
5396
6215
|
init_rules();
|
|
5397
6216
|
init_health();
|
|
5398
6217
|
init_redaction();
|
|
5399
6218
|
});
|
|
5400
6219
|
|
|
5401
6220
|
// src/ui.ts
|
|
5402
|
-
import
|
|
6221
|
+
import fs10 from "node:fs";
|
|
5403
6222
|
function announcePeer(peer, peers) {
|
|
5404
6223
|
const index = peers.indexOf(peer);
|
|
5405
6224
|
if (index > 0)
|
|
@@ -5461,13 +6280,13 @@ function promptYesNoDefaultNo(question) {
|
|
|
5461
6280
|
return answer === "y" || answer === "yes";
|
|
5462
6281
|
}
|
|
5463
6282
|
function promptLine(prompt) {
|
|
5464
|
-
|
|
5465
|
-
const fd =
|
|
6283
|
+
fs10.writeSync(1, prompt);
|
|
6284
|
+
const fd = fs10.openSync(process.platform === "win32" ? "CONIN$" : "/dev/tty", "r");
|
|
5466
6285
|
try {
|
|
5467
6286
|
const chunks = [];
|
|
5468
6287
|
const buffer = Buffer.alloc(1);
|
|
5469
6288
|
while (true) {
|
|
5470
|
-
const bytesRead =
|
|
6289
|
+
const bytesRead = fs10.readSync(fd, buffer, 0, 1, null);
|
|
5471
6290
|
if (bytesRead === 0)
|
|
5472
6291
|
break;
|
|
5473
6292
|
const char = buffer.toString("utf8", 0, bytesRead);
|
|
@@ -5478,7 +6297,7 @@ function promptLine(prompt) {
|
|
|
5478
6297
|
}
|
|
5479
6298
|
return chunks.join("").trim();
|
|
5480
6299
|
} finally {
|
|
5481
|
-
|
|
6300
|
+
fs10.closeSync(fd);
|
|
5482
6301
|
}
|
|
5483
6302
|
}
|
|
5484
6303
|
var init_ui = __esm(() => {
|
|
@@ -5487,9 +6306,9 @@ var init_ui = __esm(() => {
|
|
|
5487
6306
|
});
|
|
5488
6307
|
|
|
5489
6308
|
// src/cmd-init.ts
|
|
5490
|
-
import
|
|
5491
|
-
import
|
|
5492
|
-
import { spawnSync as
|
|
6309
|
+
import fs11 from "node:fs";
|
|
6310
|
+
import path10 from "node:path";
|
|
6311
|
+
import { spawnSync as spawnSync5 } from "node:child_process";
|
|
5493
6312
|
function cmdDeinit(args) {
|
|
5494
6313
|
const parsed = parseOptions(args, { boolean: new Set(["--yes", "-y"]), value: new Set(["--peer"]) });
|
|
5495
6314
|
if (parsed.positional.length)
|
|
@@ -5511,7 +6330,7 @@ function cmdDeinit(args) {
|
|
|
5511
6330
|
const configPath = peerConfigPath(root, name);
|
|
5512
6331
|
const leftovers = [];
|
|
5513
6332
|
let config;
|
|
5514
|
-
if (
|
|
6333
|
+
if (fs11.existsSync(configPath)) {
|
|
5515
6334
|
try {
|
|
5516
6335
|
config = readConfig(configPath);
|
|
5517
6336
|
} catch {
|
|
@@ -5544,12 +6363,14 @@ function cmdDeinit(args) {
|
|
|
5544
6363
|
removeCheckout(checkoutPath);
|
|
5545
6364
|
const ignoreEntry = ignoreEntryForSidecarPath(root, config.path);
|
|
5546
6365
|
if (ignoreEntry) {
|
|
5547
|
-
removeIgnoreEntry(
|
|
6366
|
+
removeIgnoreEntry(path10.join(root, ".gitignore"), ignoreEntry);
|
|
5548
6367
|
removeZedInclusion(root, ignoreEntry);
|
|
5549
6368
|
}
|
|
5550
6369
|
}
|
|
5551
|
-
|
|
5552
|
-
|
|
6370
|
+
fs11.rmSync(configPath, { force: true });
|
|
6371
|
+
fs11.rmSync(peerRulesPath(root, name), { force: true });
|
|
6372
|
+
fs11.rmSync(peerReviewGuidelinesPath(root, name), { force: true });
|
|
6373
|
+
pruneOrphanedReviews();
|
|
5553
6374
|
unregisterInstance(configPath);
|
|
5554
6375
|
});
|
|
5555
6376
|
console.log(`removed sidecar from ${paint("repo", root)}${name === DEFAULT_PEER ? "" : ` (peer ${paint("brand", name)})`}`);
|
|
@@ -5564,9 +6385,9 @@ function cmdDeinit(args) {
|
|
|
5564
6385
|
function assertDeinitTarget(root, checkoutPath) {
|
|
5565
6386
|
const target = realpathOr(checkoutPath);
|
|
5566
6387
|
const host = realpathOr(root);
|
|
5567
|
-
const hostFromTarget =
|
|
5568
|
-
const parts =
|
|
5569
|
-
const containsHost = !
|
|
6388
|
+
const hostFromTarget = path10.relative(target, host);
|
|
6389
|
+
const parts = path10.relative(host, target).split(path10.sep);
|
|
6390
|
+
const containsHost = !path10.isAbsolute(hostFromTarget) && hostFromTarget.split(path10.sep)[0] !== "..";
|
|
5570
6391
|
if (containsHost || parts.includes(".git") || parts.includes(".jj")) {
|
|
5571
6392
|
throw new SidecarError(`refusing to delete ${checkoutPath}, which contains the host repo or its VCS metadata`);
|
|
5572
6393
|
}
|
|
@@ -5587,19 +6408,19 @@ function releaseStandaloneCheckout(root, config) {
|
|
|
5587
6408
|
}
|
|
5588
6409
|
function removeCheckout(checkoutPath) {
|
|
5589
6410
|
try {
|
|
5590
|
-
if (
|
|
6411
|
+
if (fs11.statSync(path10.join(checkoutPath, ".git")).isFile()) {
|
|
5591
6412
|
git(checkoutPath, ["worktree", "remove", "--force", checkoutPath], { check: false });
|
|
5592
6413
|
}
|
|
5593
6414
|
} catch {}
|
|
5594
|
-
|
|
6415
|
+
fs11.rmSync(checkoutPath, { recursive: true, force: true });
|
|
5595
6416
|
}
|
|
5596
6417
|
function cmdInit(args) {
|
|
5597
6418
|
const parsed = parseOptions(args, {
|
|
5598
6419
|
boolean: new Set(["--no-clone", "--no-bootstrap-main", "--local-install", "--ignored"]),
|
|
5599
|
-
value: new Set(["--path", "--branch", "--inbox", "--redaction", "--resolve", "--debounce", "--interval", "--peer"])
|
|
6420
|
+
value: new Set(["--path", "--branch", "--inbox", "--redaction", "--resolve", "--review", "--review-timeout", "--debounce", "--interval", "--peer"])
|
|
5600
6421
|
});
|
|
5601
6422
|
if (parsed.positional.length > 1) {
|
|
5602
|
-
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]");
|
|
6423
|
+
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]");
|
|
5603
6424
|
}
|
|
5604
6425
|
const remote = parsed.positional[0];
|
|
5605
6426
|
const requestedPeer = parsed.values.get("--peer");
|
|
@@ -5639,13 +6460,15 @@ function cmdInit(args) {
|
|
|
5639
6460
|
}
|
|
5640
6461
|
function configurePeer(root, name, remote, parsed) {
|
|
5641
6462
|
const configPath = peerConfigPath(root, name);
|
|
5642
|
-
let reuse = !remote &&
|
|
5643
|
-
|
|
6463
|
+
let reuse = !remote && fs11.existsSync(configPath);
|
|
6464
|
+
let built;
|
|
6465
|
+
if (remote && fs11.existsSync(configPath)) {
|
|
5644
6466
|
const existing = readConfig(configPath);
|
|
5645
|
-
|
|
5646
|
-
reuse =
|
|
6467
|
+
built = buildInitConfig(root, name, remote, parsed, existing);
|
|
6468
|
+
reuse = configText(built) === configText(existing) || !promptOverwriteConfig(configPath, existing.remote, remote);
|
|
5647
6469
|
}
|
|
5648
|
-
const config = reuse ? readConfig(configPath) : buildInitConfig(root, name, remote, parsed);
|
|
6470
|
+
const config = reuse ? readConfig(configPath) : built ?? buildInitConfig(root, name, remote, parsed);
|
|
6471
|
+
notePeerPolicy(config);
|
|
5649
6472
|
if (!reuse) {
|
|
5650
6473
|
config.rulesPath = peerRulesPath(root, name);
|
|
5651
6474
|
config.rules = readRules(config.rulesPath);
|
|
@@ -5654,7 +6477,7 @@ function configurePeer(root, name, remote, parsed) {
|
|
|
5654
6477
|
if (isStandalone(config)) {
|
|
5655
6478
|
throw new SidecarError("--ignored cannot apply to a standalone sidecar: its .sidecar is part of the tree it syncs");
|
|
5656
6479
|
}
|
|
5657
|
-
for (const file of [peerFileName(name), peerRulesFileName(name)]) {
|
|
6480
|
+
for (const file of [peerFileName(name), peerRulesFileName(name), peerReviewGuidelinesFileName(name)]) {
|
|
5658
6481
|
if (isGitTracked(root, file)) {
|
|
5659
6482
|
throw new SidecarError(`${file} is tracked by git; \`git rm --cached ${file}\` before ignoring it`);
|
|
5660
6483
|
}
|
|
@@ -5687,9 +6510,11 @@ function excludePeerFile(root, name) {
|
|
|
5687
6510
|
throw new SidecarError(`--ignored needs a git exclude file, and ${root} has none; ignore ${configFile} in your own VCS config`);
|
|
5688
6511
|
}
|
|
5689
6512
|
const rulesFile = peerRulesFileName(name);
|
|
6513
|
+
const guidelinesFile = peerReviewGuidelinesFileName(name);
|
|
5690
6514
|
ensureIgnoreLine(exclude, `/${configFile}`);
|
|
5691
6515
|
ensureIgnoreLine(exclude, `/${rulesFile}`);
|
|
5692
|
-
|
|
6516
|
+
ensureIgnoreLine(exclude, `/${guidelinesFile}`);
|
|
6517
|
+
console.log(`ignored ${configFile}, ${rulesFile}, and ${guidelinesFile} via .git/info/exclude`);
|
|
5693
6518
|
}
|
|
5694
6519
|
function initRoot(remote, parsed) {
|
|
5695
6520
|
const cwd = process.cwd();
|
|
@@ -5704,9 +6529,9 @@ function initRoot(remote, parsed) {
|
|
|
5704
6529
|
console.log(`initialized ${paint("repo", cwd)} as a Git repository`);
|
|
5705
6530
|
return cwd;
|
|
5706
6531
|
}
|
|
5707
|
-
function buildInitConfig(root, name, remote, parsed) {
|
|
6532
|
+
function buildInitConfig(root, name, remote, parsed, existing) {
|
|
5708
6533
|
const defaultPath = name === DEFAULT_PEER ? DEFAULT_PATH : name;
|
|
5709
|
-
const rawPath = parsed.values.has("--path") ? getValue(parsed, "--path", defaultPath) : promptSidecarPath(root, name, defaultPath);
|
|
6534
|
+
const rawPath = parsed.values.has("--path") ? getValue(parsed, "--path", defaultPath) : existing?.path ?? promptSidecarPath(root, name, defaultPath);
|
|
5710
6535
|
const sidecarPath = pathIsRepoRoot(root, rawPath) ? "." : rawPath;
|
|
5711
6536
|
const standalone = isStandalonePath(sidecarPath);
|
|
5712
6537
|
if (standalone && name !== DEFAULT_PEER) {
|
|
@@ -5717,12 +6542,14 @@ function buildInitConfig(root, name, remote, parsed) {
|
|
|
5717
6542
|
remote: remote ?? (standalone ? standaloneRemote(root) : promptRemote(root)),
|
|
5718
6543
|
version: 1,
|
|
5719
6544
|
path: sidecarPath,
|
|
5720
|
-
branch: getValue(parsed, "--branch", DEFAULT_BRANCH),
|
|
5721
|
-
inbox: getValue(parsed, "--inbox", DEFAULT_INBOX),
|
|
5722
|
-
redaction: parsed.values.has("--redaction") ? redactionModeConfigValue(getValue(parsed, "--redaction", DEFAULT_REDACTION_MODE), "--redaction") : promptRedactionMode(),
|
|
5723
|
-
resolve: parsed.values.has("--resolve") ? resolveModeConfigValue(getValue(parsed, "--resolve", DEFAULT_RESOLVE), "--resolve") : DEFAULT_RESOLVE,
|
|
5724
|
-
debounce: durationConfigValue(parsed.values.get("--debounce"), "--debounce"),
|
|
5725
|
-
interval: durationConfigValue(parsed.values.get("--interval"), "--interval")
|
|
6545
|
+
branch: getValue(parsed, "--branch", existing?.branch ?? DEFAULT_BRANCH),
|
|
6546
|
+
inbox: getValue(parsed, "--inbox", existing?.inbox ?? DEFAULT_INBOX),
|
|
6547
|
+
redaction: parsed.values.has("--redaction") ? redactionModeConfigValue(getValue(parsed, "--redaction", DEFAULT_REDACTION_MODE), "--redaction") : existing?.redaction ?? promptRedactionMode(),
|
|
6548
|
+
resolve: parsed.values.has("--resolve") ? resolveModeConfigValue(getValue(parsed, "--resolve", DEFAULT_RESOLVE), "--resolve") : existing?.resolve ?? DEFAULT_RESOLVE,
|
|
6549
|
+
debounce: durationConfigValue(parsed.values.get("--debounce"), "--debounce") ?? existing?.debounce,
|
|
6550
|
+
interval: durationConfigValue(parsed.values.get("--interval"), "--interval") ?? existing?.interval,
|
|
6551
|
+
review: reviewModeConfigValue(getValue(parsed, "--review", existing?.review ?? "off"), "--review"),
|
|
6552
|
+
reviewTimeout: durationConfigValue(parsed.values.get("--review-timeout"), "--review-timeout") ?? existing?.reviewTimeout
|
|
5726
6553
|
};
|
|
5727
6554
|
}
|
|
5728
6555
|
function printCheckoutVisibility(root, config) {
|
|
@@ -5739,7 +6566,7 @@ function printCheckoutVisibility(root, config) {
|
|
|
5739
6566
|
console.log(`sidecar path outside repo; not updating .gitignore`);
|
|
5740
6567
|
return;
|
|
5741
6568
|
}
|
|
5742
|
-
ensureIgnoreEntry(exclude ??
|
|
6569
|
+
ensureIgnoreEntry(exclude ?? path10.join(root, ".gitignore"), ignoreEntry);
|
|
5743
6570
|
const name = ignoreEntry.replace(/\/+$/, "");
|
|
5744
6571
|
console.log(`ignored ${name}/ via ${exclude ? ".git/info/exclude" : ".gitignore"}`);
|
|
5745
6572
|
if (hasZedInclusion(root, ignoreEntry)) {
|
|
@@ -5753,8 +6580,8 @@ function printCheckoutVisibility(root, config) {
|
|
|
5753
6580
|
}
|
|
5754
6581
|
}
|
|
5755
6582
|
function offerLocalInstall(root, standalone, forced) {
|
|
5756
|
-
const manifestPath =
|
|
5757
|
-
if (!
|
|
6583
|
+
const manifestPath = path10.join(root, "package.json");
|
|
6584
|
+
if (!fs11.existsSync(manifestPath)) {
|
|
5758
6585
|
if (forced)
|
|
5759
6586
|
throw new SidecarError("--local-install requires a package.json");
|
|
5760
6587
|
return;
|
|
@@ -5764,7 +6591,7 @@ function offerLocalInstall(root, standalone, forced) {
|
|
|
5764
6591
|
let source;
|
|
5765
6592
|
let manifest;
|
|
5766
6593
|
try {
|
|
5767
|
-
source =
|
|
6594
|
+
source = fs11.readFileSync(manifestPath, "utf8");
|
|
5768
6595
|
const parsed = JSON.parse(source);
|
|
5769
6596
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
5770
6597
|
throw new Error("not an object");
|
|
@@ -5790,7 +6617,7 @@ function offerLocalInstall(root, standalone, forced) {
|
|
|
5790
6617
|
manifest.pnpm = pnpm;
|
|
5791
6618
|
}
|
|
5792
6619
|
const indent = /^([ \t]+)"/m.exec(source)?.[1] ?? " ";
|
|
5793
|
-
|
|
6620
|
+
fs11.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, indent)}
|
|
5794
6621
|
`);
|
|
5795
6622
|
console.log(`added ${paint("brand", PACKAGE_NAME)} to devDependencies; run your package manager's install to pin it`);
|
|
5796
6623
|
if (managers.has("bun")) {
|
|
@@ -5818,7 +6645,7 @@ function detectPackageManagers(root) {
|
|
|
5818
6645
|
["package-lock.json", "npm"],
|
|
5819
6646
|
["yarn.lock", "yarn"]
|
|
5820
6647
|
];
|
|
5821
|
-
return new Set(lockfiles.filter(([file]) =>
|
|
6648
|
+
return new Set(lockfiles.filter(([file]) => fs11.existsSync(path10.join(root, file))).map(([, manager]) => manager));
|
|
5822
6649
|
}
|
|
5823
6650
|
function ensureDaemonSetup(globalSidecar) {
|
|
5824
6651
|
if (process.env[SKIP_SERVICE_ENV] === "1")
|
|
@@ -5828,7 +6655,7 @@ function ensureDaemonSetup(globalSidecar) {
|
|
|
5828
6655
|
const service = daemonServiceStatus();
|
|
5829
6656
|
if (!service.available || service.installed && service.running)
|
|
5830
6657
|
return;
|
|
5831
|
-
const result =
|
|
6658
|
+
const result = spawnSync5(globalSidecar, ["daemon", "enable"], {
|
|
5832
6659
|
encoding: "utf8",
|
|
5833
6660
|
env: {
|
|
5834
6661
|
...process.env,
|
|
@@ -5880,7 +6707,7 @@ function warnIfGlobalPredatesPeers(executable, peers) {
|
|
|
5880
6707
|
console.error(`sidecar: warning: the global sidecar (${version ? `v${version}` : "unknown version"}) predates peers; its daemon syncs only .sidecar until it is updated`);
|
|
5881
6708
|
}
|
|
5882
6709
|
function registerInstallWithGlobalSidecar(executable, root) {
|
|
5883
|
-
const result =
|
|
6710
|
+
const result = spawnSync5(executable, ["register-install"], {
|
|
5884
6711
|
cwd: root,
|
|
5885
6712
|
encoding: "utf8",
|
|
5886
6713
|
env: {
|
|
@@ -5897,7 +6724,7 @@ function installGlobalSidecar() {
|
|
|
5897
6724
|
const bun = findExecutableOnPath(process.platform === "win32" ? "bun.exe" : "bun");
|
|
5898
6725
|
const command = bun ? [bun, "add", "-g", PACKAGE_SPEC] : ["npm", "install", "-g", PACKAGE_SPEC];
|
|
5899
6726
|
console.log(`running ${command.join(" ")}`);
|
|
5900
|
-
const result =
|
|
6727
|
+
const result = spawnSync5(command[0], command.slice(1), { stdio: "inherit" });
|
|
5901
6728
|
if (result.status !== 0) {
|
|
5902
6729
|
throw new SidecarError(`global sidecar install failed; run \`${command.join(" ")}\` manually`);
|
|
5903
6730
|
}
|
|
@@ -5916,7 +6743,7 @@ function cmdClone(args) {
|
|
|
5916
6743
|
const { root, config } = peer;
|
|
5917
6744
|
if (parsed.flags.has("--if-missing")) {
|
|
5918
6745
|
const sidecarPath = resolveSidecarPath(root, config);
|
|
5919
|
-
if (
|
|
6746
|
+
if (fs11.existsSync(sidecarPath) && hasGitMetadata(sidecarPath))
|
|
5920
6747
|
continue;
|
|
5921
6748
|
}
|
|
5922
6749
|
cloneOrUpdate(root, config, !parsed.flags.has("--no-bootstrap-main"));
|
|
@@ -6004,7 +6831,7 @@ function createRemoteWithGh(root) {
|
|
|
6004
6831
|
const origin = git(root, ["remote", "get-url", "origin"], { check: false }).stdout.trim() || undefined;
|
|
6005
6832
|
const parsedOrigin = origin ? parseGitHubRemote(origin) : undefined;
|
|
6006
6833
|
const owner = parsedOrigin?.owner ?? ghLogin(gh);
|
|
6007
|
-
const baseName = parsedOrigin?.repo ??
|
|
6834
|
+
const baseName = parsedOrigin?.repo ?? path10.basename(root);
|
|
6008
6835
|
const suggested = owner ? `${owner}/${baseName}-sidecar` : `${baseName}-sidecar`;
|
|
6009
6836
|
const answer = promptLine(`repository to create ${paint("quiet", `[${suggested}]`)}: `) || suggested;
|
|
6010
6837
|
const fullName = answer.includes("/") ? answer : owner ? `${owner}/${answer}` : undefined;
|
|
@@ -6012,7 +6839,7 @@ function createRemoteWithGh(root) {
|
|
|
6012
6839
|
throw new SidecarError("could not determine the repository owner; enter it as owner/name");
|
|
6013
6840
|
}
|
|
6014
6841
|
console.log(`running gh repo create ${fullName} --private`);
|
|
6015
|
-
const create =
|
|
6842
|
+
const create = spawnSync5(gh, ["repo", "create", fullName, "--private"], { stdio: "inherit" });
|
|
6016
6843
|
if (create.status !== 0) {
|
|
6017
6844
|
throw new SidecarError("gh repo create failed; create the repo yourself and rerun `sidecar init <remote>`");
|
|
6018
6845
|
}
|
|
@@ -6026,14 +6853,14 @@ function parseGitHubRemote(url) {
|
|
|
6026
6853
|
return { owner: match[1], repo: match[2] };
|
|
6027
6854
|
}
|
|
6028
6855
|
function ghLogin(gh) {
|
|
6029
|
-
const result =
|
|
6856
|
+
const result = spawnSync5(gh, ["api", "user", "-q", ".login"], { encoding: "utf8" });
|
|
6030
6857
|
if (result.status !== 0)
|
|
6031
6858
|
return;
|
|
6032
6859
|
const login = result.stdout.trim();
|
|
6033
6860
|
return login || undefined;
|
|
6034
6861
|
}
|
|
6035
6862
|
function ghGitProtocol(gh) {
|
|
6036
|
-
const result =
|
|
6863
|
+
const result = spawnSync5(gh, ["config", "get", "git_protocol"], { encoding: "utf8" });
|
|
6037
6864
|
if (result.status !== 0)
|
|
6038
6865
|
return "https";
|
|
6039
6866
|
return result.stdout.trim() || "https";
|
|
@@ -6053,38 +6880,38 @@ function removeIgnoreEntry(ignorePath, sidecarPath) {
|
|
|
6053
6880
|
removeIgnoreLine(ignorePath, `/${sidecarPath.replace(/^\/+|\/+$/g, "")}/`);
|
|
6054
6881
|
}
|
|
6055
6882
|
function ensureIgnoreLine(ignorePath, entry) {
|
|
6056
|
-
const lines =
|
|
6883
|
+
const lines = fs11.existsSync(ignorePath) ? fs11.readFileSync(ignorePath, "utf8").split(/\r?\n/) : [];
|
|
6057
6884
|
if (lines.includes(entry))
|
|
6058
6885
|
return;
|
|
6059
6886
|
while (lines.length && lines[lines.length - 1] === "")
|
|
6060
6887
|
lines.pop();
|
|
6061
6888
|
lines.push(entry);
|
|
6062
|
-
|
|
6063
|
-
|
|
6889
|
+
fs11.mkdirSync(path10.dirname(ignorePath), { recursive: true });
|
|
6890
|
+
fs11.writeFileSync(ignorePath, `${lines.join(`
|
|
6064
6891
|
`).replace(/\s+$/g, "")}
|
|
6065
6892
|
`, "utf8");
|
|
6066
6893
|
}
|
|
6067
6894
|
function removeIgnoreLine(ignorePath, entry) {
|
|
6068
|
-
if (!
|
|
6895
|
+
if (!fs11.existsSync(ignorePath))
|
|
6069
6896
|
return;
|
|
6070
|
-
const lines =
|
|
6897
|
+
const lines = fs11.readFileSync(ignorePath, "utf8").split(/\r?\n/);
|
|
6071
6898
|
const kept = lines.filter((line) => line !== entry);
|
|
6072
6899
|
if (kept.length === lines.length)
|
|
6073
6900
|
return;
|
|
6074
6901
|
if (kept.every((line) => !line.trim())) {
|
|
6075
|
-
|
|
6902
|
+
fs11.rmSync(ignorePath);
|
|
6076
6903
|
} else {
|
|
6077
|
-
|
|
6904
|
+
fs11.writeFileSync(ignorePath, `${kept.join(`
|
|
6078
6905
|
`).replace(/\s+$/g, "")}
|
|
6079
6906
|
`, "utf8");
|
|
6080
6907
|
}
|
|
6081
6908
|
}
|
|
6082
6909
|
function hasZedInclusion(root, sidecarPath) {
|
|
6083
|
-
const settingsPath2 =
|
|
6084
|
-
if (!
|
|
6910
|
+
const settingsPath2 = path10.join(root, ".zed", "settings.json");
|
|
6911
|
+
if (!fs11.existsSync(settingsPath2))
|
|
6085
6912
|
return false;
|
|
6086
6913
|
try {
|
|
6087
|
-
const parsed = JSON.parse(
|
|
6914
|
+
const parsed = JSON.parse(fs11.readFileSync(settingsPath2, "utf8"));
|
|
6088
6915
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
6089
6916
|
return false;
|
|
6090
6917
|
const inclusions = parsed.file_scan_inclusions;
|
|
@@ -6098,11 +6925,11 @@ function zedInclusionGlob(sidecarPath) {
|
|
|
6098
6925
|
}
|
|
6099
6926
|
function ensureZedInclusion(root, sidecarPath) {
|
|
6100
6927
|
const glob = zedInclusionGlob(sidecarPath);
|
|
6101
|
-
const settingsPath2 =
|
|
6928
|
+
const settingsPath2 = path10.join(root, ".zed", "settings.json");
|
|
6102
6929
|
let settings = {};
|
|
6103
|
-
if (
|
|
6930
|
+
if (fs11.existsSync(settingsPath2)) {
|
|
6104
6931
|
try {
|
|
6105
|
-
const parsed = JSON.parse(
|
|
6932
|
+
const parsed = JSON.parse(fs11.readFileSync(settingsPath2, "utf8"));
|
|
6106
6933
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
6107
6934
|
return false;
|
|
6108
6935
|
settings = parsed;
|
|
@@ -6114,18 +6941,18 @@ function ensureZedInclusion(root, sidecarPath) {
|
|
|
6114
6941
|
if (!inclusions.includes(glob)) {
|
|
6115
6942
|
inclusions.push(glob);
|
|
6116
6943
|
settings.file_scan_inclusions = inclusions;
|
|
6117
|
-
|
|
6118
|
-
|
|
6944
|
+
fs11.mkdirSync(path10.dirname(settingsPath2), { recursive: true });
|
|
6945
|
+
fs11.writeFileSync(settingsPath2, `${JSON.stringify(settings, null, 2)}
|
|
6119
6946
|
`, "utf8");
|
|
6120
6947
|
}
|
|
6121
6948
|
return true;
|
|
6122
6949
|
}
|
|
6123
6950
|
function removeZedInclusion(root, sidecarPath) {
|
|
6124
|
-
const settingsPath2 =
|
|
6125
|
-
if (!
|
|
6951
|
+
const settingsPath2 = path10.join(root, ".zed", "settings.json");
|
|
6952
|
+
if (!fs11.existsSync(settingsPath2))
|
|
6126
6953
|
return;
|
|
6127
6954
|
try {
|
|
6128
|
-
const settings = JSON.parse(
|
|
6955
|
+
const settings = JSON.parse(fs11.readFileSync(settingsPath2, "utf8"));
|
|
6129
6956
|
if (!settings || typeof settings !== "object" || Array.isArray(settings))
|
|
6130
6957
|
return;
|
|
6131
6958
|
const inclusions = settings.file_scan_inclusions;
|
|
@@ -6140,17 +6967,17 @@ function removeZedInclusion(root, sidecarPath) {
|
|
|
6140
6967
|
} else {
|
|
6141
6968
|
delete settings.file_scan_inclusions;
|
|
6142
6969
|
}
|
|
6143
|
-
|
|
6970
|
+
fs11.writeFileSync(settingsPath2, `${JSON.stringify(settings, null, 2)}
|
|
6144
6971
|
`, "utf8");
|
|
6145
6972
|
} catch {
|
|
6146
6973
|
console.error(`sidecar: warning: could not safely remove the Zed inclusion from ${settingsPath2}`);
|
|
6147
6974
|
}
|
|
6148
6975
|
}
|
|
6149
6976
|
function ignoreEntryForSidecarPath(root, sidecarPath) {
|
|
6150
|
-
const resolvedRoot =
|
|
6151
|
-
const resolvedSidecarPath =
|
|
6152
|
-
const relative =
|
|
6153
|
-
if (!relative || relative.startsWith("..") ||
|
|
6977
|
+
const resolvedRoot = path10.resolve(root);
|
|
6978
|
+
const resolvedSidecarPath = path10.resolve(root, sidecarPath);
|
|
6979
|
+
const relative = path10.relative(resolvedRoot, resolvedSidecarPath);
|
|
6980
|
+
if (!relative || relative.startsWith("..") || path10.isAbsolute(relative))
|
|
6154
6981
|
return;
|
|
6155
6982
|
return relative;
|
|
6156
6983
|
}
|
|
@@ -6166,11 +6993,12 @@ var init_cmd_init = __esm(() => {
|
|
|
6166
6993
|
init_ui();
|
|
6167
6994
|
init_redaction();
|
|
6168
6995
|
init_rules();
|
|
6996
|
+
init_review();
|
|
6169
6997
|
});
|
|
6170
6998
|
|
|
6171
6999
|
// src/cmd-refresh.ts
|
|
6172
|
-
import
|
|
6173
|
-
import
|
|
7000
|
+
import fs12 from "node:fs";
|
|
7001
|
+
import path11 from "node:path";
|
|
6174
7002
|
function worktreeHoldingBranch(repo, branch) {
|
|
6175
7003
|
const result = git(repo, ["worktree", "list", "--porcelain"], { check: false });
|
|
6176
7004
|
if (result.status !== 0)
|
|
@@ -6198,13 +7026,53 @@ function unpushedCommits(sidecarPath) {
|
|
|
6198
7026
|
});
|
|
6199
7027
|
return counted.status === 0 ? Number(counted.stdout.trim()) || 0 : 0;
|
|
6200
7028
|
}
|
|
6201
|
-
function
|
|
7029
|
+
function heldWork(sidecarPath, config, inbox) {
|
|
7030
|
+
const gated = reviewRequired(config);
|
|
7031
|
+
const unpushed = gated ? 0 : unpushedCommits(sidecarPath);
|
|
7032
|
+
const unpublished = gated && !gatedWorkIsPublished(sidecarPath, config, inbox);
|
|
7033
|
+
const dirtyFiles = git(sidecarPath, ["status", "--porcelain"], { check: false }).stdout.split(`
|
|
7034
|
+
`).filter(Boolean).length;
|
|
7035
|
+
return [
|
|
7036
|
+
unpushed ? `${unpushed} commit(s) the remote has not seen` : "",
|
|
7037
|
+
unpublished ? "changes not yet published through review" : "",
|
|
7038
|
+
dirtyFiles ? `${dirtyFiles} uncommitted file(s)` : ""
|
|
7039
|
+
].filter(Boolean);
|
|
7040
|
+
}
|
|
7041
|
+
function gatedWorkIsPublished(sidecarPath, config, inbox) {
|
|
7042
|
+
if (!hasAnyCommit(sidecarPath))
|
|
7043
|
+
return true;
|
|
7044
|
+
const tree = (ref) => {
|
|
7045
|
+
const result = git(sidecarPath, ["rev-parse", "--verify", "--quiet", `${ref}^{tree}`], { check: false });
|
|
7046
|
+
return result.status === 0 ? result.stdout.trim() : undefined;
|
|
7047
|
+
};
|
|
7048
|
+
const main = `refs/heads/${config.branch}`;
|
|
7049
|
+
const publicMain = tree(`${PUBLIC_REFS}${config.branch}`);
|
|
7050
|
+
const mainTree = tree(main);
|
|
7051
|
+
const mainPublished = mainTree === undefined || mainTree === publicMain;
|
|
7052
|
+
const published = (ref, branch) => {
|
|
7053
|
+
const own = tree(ref);
|
|
7054
|
+
return own === publicMain || own === tree(`${PUBLIC_REFS}${branch}`) || mainPublished && isAncestor(sidecarPath, ref, main);
|
|
7055
|
+
};
|
|
7056
|
+
if (!published("HEAD", inbox))
|
|
7057
|
+
return false;
|
|
7058
|
+
if (!ownsStore(sidecarPath))
|
|
7059
|
+
return true;
|
|
7060
|
+
if (!mainPublished)
|
|
7061
|
+
return false;
|
|
7062
|
+
const prefix = inboxPrefix(config);
|
|
7063
|
+
return git(sidecarPath, ["for-each-ref", "--format=%(refname)", "refs/heads/"]).stdout.split(`
|
|
7064
|
+
`).filter((ref) => ref && matchesInboxPrefix(prefix, ref.slice("refs/heads/".length))).every((ref) => published(ref, ref.slice("refs/heads/".length)));
|
|
7065
|
+
}
|
|
7066
|
+
function ownsStore(sidecarPath) {
|
|
6202
7067
|
try {
|
|
6203
|
-
|
|
6204
|
-
return [];
|
|
7068
|
+
return fs12.statSync(path11.join(sidecarPath, ".git")).isDirectory();
|
|
6205
7069
|
} catch {
|
|
6206
|
-
return
|
|
7070
|
+
return false;
|
|
6207
7071
|
}
|
|
7072
|
+
}
|
|
7073
|
+
function dependentWorktrees(sidecarPath) {
|
|
7074
|
+
if (!ownsStore(sidecarPath))
|
|
7075
|
+
return [];
|
|
6208
7076
|
const result = git(sidecarPath, ["worktree", "list", "--porcelain"], { check: false });
|
|
6209
7077
|
if (result.status !== 0)
|
|
6210
7078
|
return [];
|
|
@@ -6215,11 +7083,11 @@ function existingCheckoutId(sidecarPath) {
|
|
|
6215
7083
|
const candidates = [];
|
|
6216
7084
|
const reported = git(sidecarPath, ["rev-parse", "--git-dir"], { check: false });
|
|
6217
7085
|
if (reported.status === 0)
|
|
6218
|
-
candidates.push(
|
|
6219
|
-
candidates.push(
|
|
7086
|
+
candidates.push(path11.resolve(sidecarPath, reported.stdout.trim()));
|
|
7087
|
+
candidates.push(path11.join(sidecarPath, ".git"));
|
|
6220
7088
|
for (const candidate of candidates) {
|
|
6221
7089
|
try {
|
|
6222
|
-
const id = slug(
|
|
7090
|
+
const id = slug(fs12.readFileSync(path11.join(candidate, "sidecar-id"), "utf8"));
|
|
6223
7091
|
if (id)
|
|
6224
7092
|
return id;
|
|
6225
7093
|
} catch {}
|
|
@@ -6231,12 +7099,20 @@ function refreshCheckout(root, config) {
|
|
|
6231
7099
|
if (isStandalone(config)) {
|
|
6232
7100
|
throw new SidecarError("refusing to delete a standalone sidecar, which is the repo itself");
|
|
6233
7101
|
}
|
|
6234
|
-
const relative =
|
|
6235
|
-
if (!relative || relative.startsWith("..") ||
|
|
7102
|
+
const relative = path11.relative(root, sidecarPath);
|
|
7103
|
+
if (!relative || relative.startsWith("..") || path11.isAbsolute(relative)) {
|
|
6236
7104
|
throw new SidecarError(`refusing to delete ${sidecarPath}, which is not inside ${root}`);
|
|
6237
7105
|
}
|
|
6238
7106
|
const checkoutId = existingCheckoutId(sidecarPath);
|
|
6239
|
-
|
|
7107
|
+
if (ownsStore(sidecarPath)) {
|
|
7108
|
+
let open;
|
|
7109
|
+
try {
|
|
7110
|
+
open = openReview(sidecarPath);
|
|
7111
|
+
} catch {}
|
|
7112
|
+
if (open)
|
|
7113
|
+
markSuperseded(open);
|
|
7114
|
+
}
|
|
7115
|
+
fs12.rmSync(sidecarPath, { recursive: true, force: true });
|
|
6240
7116
|
const family = familySidecarCheckout(root, config);
|
|
6241
7117
|
if (family) {
|
|
6242
7118
|
git(family, ["worktree", "prune", "--expire", "now"], { check: false });
|
|
@@ -6297,16 +7173,17 @@ function refreshPeer({ root, config, name }, parsed) {
|
|
|
6297
7173
|
let inbox;
|
|
6298
7174
|
if (readable) {
|
|
6299
7175
|
inbox = expandInbox(config, sidecarPath);
|
|
6300
|
-
|
|
6301
|
-
|
|
6302
|
-
|
|
6303
|
-
|
|
6304
|
-
|
|
6305
|
-
|
|
6306
|
-
|
|
6307
|
-
|
|
6308
|
-
|
|
6309
|
-
|
|
7176
|
+
if (reviewRequired(config)) {
|
|
7177
|
+
try {
|
|
7178
|
+
refreshPublicRefs(sidecarPath);
|
|
7179
|
+
} catch {}
|
|
7180
|
+
} else {
|
|
7181
|
+
fetch(sidecarPath, true, false);
|
|
7182
|
+
}
|
|
7183
|
+
const held = heldWork(sidecarPath, config, inbox);
|
|
7184
|
+
if (held.length && !force) {
|
|
7185
|
+
const publish = reviewRequired(config) ? "approve its review (`sidecar review`) and sync to publish them" : "run `sidecar sync` to push them";
|
|
7186
|
+
throw new SidecarError(`this checkout still holds ${held.join(" and ")}; ${publish}, then refresh — or \`sidecar refresh --force\` to discard them`);
|
|
6310
7187
|
}
|
|
6311
7188
|
}
|
|
6312
7189
|
if (standalone) {
|
|
@@ -6333,7 +7210,7 @@ function refreshPeer({ root, config, name }, parsed) {
|
|
|
6333
7210
|
}
|
|
6334
7211
|
let declined;
|
|
6335
7212
|
withSyncLock(root, name, "throw", () => {
|
|
6336
|
-
if (readable && !force &&
|
|
7213
|
+
if (readable && !force && inbox && heldWork(sidecarPath, config, inbox).length) {
|
|
6337
7214
|
throw new SidecarError("the sidecar checkout changed while waiting for confirmation; rerun refresh");
|
|
6338
7215
|
}
|
|
6339
7216
|
if (standalone)
|
|
@@ -6355,10 +7232,11 @@ var init_cmd_refresh = __esm(() => {
|
|
|
6355
7232
|
init_sync();
|
|
6356
7233
|
init_ui();
|
|
6357
7234
|
init_rules();
|
|
7235
|
+
init_review();
|
|
6358
7236
|
});
|
|
6359
7237
|
|
|
6360
7238
|
// src/cmd-status.ts
|
|
6361
|
-
import
|
|
7239
|
+
import fs13 from "node:fs";
|
|
6362
7240
|
function statusLine(label, value, role) {
|
|
6363
7241
|
labelLine(STATUS_LABEL_WIDTH, label, value, role);
|
|
6364
7242
|
}
|
|
@@ -6391,6 +7269,8 @@ function printPeerStatus({ root, config, configPath }) {
|
|
|
6391
7269
|
statusLine("main branch", config.branch);
|
|
6392
7270
|
statusLine("inbox branch", inbox);
|
|
6393
7271
|
statusLine("conflicts", config.resolve === "lww" ? "last writer wins" : "fork files");
|
|
7272
|
+
if (reviewRequired(config))
|
|
7273
|
+
printReviewLine(sidecarPath);
|
|
6394
7274
|
if (!checkoutPresent) {
|
|
6395
7275
|
statusLine("checkout", "missing — run `sidecar init`", "bad");
|
|
6396
7276
|
printDaemonLine();
|
|
@@ -6421,6 +7301,36 @@ function printPeerStatus({ root, config, configPath }) {
|
|
|
6421
7301
|
statusLine("pending inbox", "none", "quiet");
|
|
6422
7302
|
}
|
|
6423
7303
|
}
|
|
7304
|
+
function printReviewLine(sidecarPath) {
|
|
7305
|
+
const review = hasGitMetadata(sidecarPath) ? openReview(sidecarPath) : undefined;
|
|
7306
|
+
const reviewer = readSettings().reviewer;
|
|
7307
|
+
if (!review) {
|
|
7308
|
+
if (reviewer)
|
|
7309
|
+
statusLine("review", `required — nothing waiting (reviewer: ${reviewer})`, "quiet");
|
|
7310
|
+
else
|
|
7311
|
+
statusLine("review", "required — no reviewer configured; reviews wait for `sidecar review approve`", "attn");
|
|
7312
|
+
return;
|
|
7313
|
+
}
|
|
7314
|
+
const attention = reviewAttention(review);
|
|
7315
|
+
const age = formatRelativeTime(review.record.createdAt) ?? review.record.createdAt;
|
|
7316
|
+
const summary = `${review.state} ${review.record.id}, opened ${age}, ${review.record.files.length} file(s)`;
|
|
7317
|
+
statusLine("review", attention ? `${summary} — needs attention: ${attention}` : summary, attention ? "attn" : undefined);
|
|
7318
|
+
if (review.verdict?.reason)
|
|
7319
|
+
console.log(` ${paint("quiet", review.verdict.reason)}`);
|
|
7320
|
+
console.log(` ${paint("quiet", `sidecar review show ${review.record.id}`)}`);
|
|
7321
|
+
}
|
|
7322
|
+
function openReviewPayload(sidecarPath) {
|
|
7323
|
+
const review = openReview(sidecarPath);
|
|
7324
|
+
if (!review)
|
|
7325
|
+
return;
|
|
7326
|
+
return {
|
|
7327
|
+
id: review.record.id,
|
|
7328
|
+
state: review.state,
|
|
7329
|
+
attention: reviewAttention(review),
|
|
7330
|
+
createdAt: review.record.createdAt,
|
|
7331
|
+
files: review.record.files.length
|
|
7332
|
+
};
|
|
7333
|
+
}
|
|
6424
7334
|
function statusPayload({ root, name, config, configPath }) {
|
|
6425
7335
|
const sidecarPath = resolveSidecarPath(root, config);
|
|
6426
7336
|
const checkoutPresent = hasGitMetadata(sidecarPath);
|
|
@@ -6441,13 +7351,15 @@ function statusPayload({ root, name, config, configPath }) {
|
|
|
6441
7351
|
familyLinked: checkoutPresent ? !checkoutIsUnlinkedFromFamily(root, config, sidecarPath) : undefined,
|
|
6442
7352
|
daemon: daemonHealth().text,
|
|
6443
7353
|
lastSyncAt: readInstances().find((instance) => instance.configPath === configPath)?.lastSyncAt,
|
|
6444
|
-
pendingInbox: checkoutPresent ? pendingStatusInboxBranches(sidecarPath, config) : undefined
|
|
7354
|
+
pendingInbox: checkoutPresent ? pendingStatusInboxBranches(sidecarPath, config) : undefined,
|
|
7355
|
+
review: reviewRequired(config) ? { required: true, open: checkoutPresent ? openReviewPayload(sidecarPath) : undefined } : undefined
|
|
6445
7356
|
};
|
|
6446
7357
|
}
|
|
6447
7358
|
function pendingStatusInboxBranches(sidecarPath, config) {
|
|
6448
7359
|
fetch(sidecarPath, true, false);
|
|
6449
7360
|
const base = remoteRefExists(sidecarPath, config.branch) ? `origin/${config.branch}` : branchExists(sidecarPath, config.branch) ? config.branch : "HEAD";
|
|
6450
|
-
|
|
7361
|
+
const candidates = reviewRequired(config) ? pendingInboxBranches(sidecarPath, config).filter((branch) => branch.startsWith("origin/")) : pendingInboxBranches(sidecarPath, config);
|
|
7362
|
+
return candidates.filter((remoteBranch) => !isAncestor(sidecarPath, remoteBranch, base));
|
|
6451
7363
|
}
|
|
6452
7364
|
function daemonHealth() {
|
|
6453
7365
|
if (!shouldUseGlobalRegistry()) {
|
|
@@ -6549,6 +7461,10 @@ function healthStatusLine(state, record) {
|
|
|
6549
7461
|
const age = formatRelativeTime(record.updatedAt) ?? record.updatedAt;
|
|
6550
7462
|
return { text: `stale — last reported ${age}`, role: "attn" };
|
|
6551
7463
|
}
|
|
7464
|
+
if (record.review === "attention")
|
|
7465
|
+
return { text: "ok — publishing held, review needs attention", role: "attn" };
|
|
7466
|
+
if (record.review === "pending")
|
|
7467
|
+
return { text: "ok — publishing held for review", role: "ok" };
|
|
6552
7468
|
return { text: "ok", role: "ok" };
|
|
6553
7469
|
}
|
|
6554
7470
|
function cmdInstances(args) {
|
|
@@ -6602,16 +7518,16 @@ function cmdTail(args) {
|
|
|
6602
7518
|
throw new SidecarError("--lines requires a positive integer");
|
|
6603
7519
|
}
|
|
6604
7520
|
const filePath = sidecarLogPath();
|
|
6605
|
-
if (!
|
|
7521
|
+
if (!fs13.existsSync(filePath)) {
|
|
6606
7522
|
if (parsed.flags.has("-f") || parsed.flags.has("--follow")) {
|
|
6607
7523
|
followLog(filePath, 0);
|
|
6608
7524
|
return 0;
|
|
6609
7525
|
}
|
|
6610
7526
|
return 0;
|
|
6611
7527
|
}
|
|
6612
|
-
const stat =
|
|
7528
|
+
const stat = fs13.statSync(filePath);
|
|
6613
7529
|
if (stat.size > 0) {
|
|
6614
|
-
process.stdout.write(lastLines(
|
|
7530
|
+
process.stdout.write(lastLines(fs13.readFileSync(filePath, "utf8"), lines));
|
|
6615
7531
|
}
|
|
6616
7532
|
if (parsed.flags.has("-f") || parsed.flags.has("--follow")) {
|
|
6617
7533
|
followLog(filePath, stat.size);
|
|
@@ -6634,7 +7550,7 @@ function followLog(filePath, startOffset) {
|
|
|
6634
7550
|
sleep(1000);
|
|
6635
7551
|
let stat;
|
|
6636
7552
|
try {
|
|
6637
|
-
stat =
|
|
7553
|
+
stat = fs13.statSync(filePath);
|
|
6638
7554
|
} catch {
|
|
6639
7555
|
offset = 0;
|
|
6640
7556
|
continue;
|
|
@@ -6643,17 +7559,17 @@ function followLog(filePath, startOffset) {
|
|
|
6643
7559
|
offset = 0;
|
|
6644
7560
|
if (stat.size <= offset)
|
|
6645
7561
|
continue;
|
|
6646
|
-
const fd =
|
|
7562
|
+
const fd = fs13.openSync(filePath, "r");
|
|
6647
7563
|
try {
|
|
6648
7564
|
const length = stat.size - offset;
|
|
6649
7565
|
const buffer = Buffer.alloc(length);
|
|
6650
|
-
const bytesRead =
|
|
7566
|
+
const bytesRead = fs13.readSync(fd, buffer, 0, length, offset);
|
|
6651
7567
|
if (bytesRead > 0) {
|
|
6652
7568
|
process.stdout.write(buffer.subarray(0, bytesRead).toString("utf8"));
|
|
6653
7569
|
offset += bytesRead;
|
|
6654
7570
|
}
|
|
6655
7571
|
} finally {
|
|
6656
|
-
|
|
7572
|
+
fs13.closeSync(fd);
|
|
6657
7573
|
}
|
|
6658
7574
|
}
|
|
6659
7575
|
}
|
|
@@ -6672,6 +7588,7 @@ var init_cmd_status = __esm(() => {
|
|
|
6672
7588
|
init_sync();
|
|
6673
7589
|
init_ui();
|
|
6674
7590
|
init_health();
|
|
7591
|
+
init_review();
|
|
6675
7592
|
STATUS_LABEL_WIDTH = "pending inbox:".length;
|
|
6676
7593
|
});
|
|
6677
7594
|
|
|
@@ -7060,20 +7977,20 @@ var require_ignore = __commonJS((exports, module) => {
|
|
|
7060
7977
|
const index = body.indexOf(SLASH);
|
|
7061
7978
|
return index < 0 || index === body.length - 1;
|
|
7062
7979
|
};
|
|
7063
|
-
var basenameOf = (
|
|
7064
|
-
const end =
|
|
7065
|
-
const index =
|
|
7066
|
-
return index < 0 ?
|
|
7980
|
+
var basenameOf = (path12) => {
|
|
7981
|
+
const end = path12.length - 1;
|
|
7982
|
+
const index = path12.lastIndexOf(SLASH, path12[end] === SLASH ? end - 1 : end);
|
|
7983
|
+
return index < 0 ? path12 : path12.slice(index + 1);
|
|
7067
7984
|
};
|
|
7068
|
-
var parentOf = (
|
|
7069
|
-
if (
|
|
7070
|
-
const slices =
|
|
7985
|
+
var parentOf = (path12) => {
|
|
7986
|
+
if (path12.charCodeAt(0) === SLASH_CODE || path12.indexOf(DOUBLE_SLASH) >= 0) {
|
|
7987
|
+
const slices = path12.split(SLASH).filter(Boolean);
|
|
7071
7988
|
slices.pop();
|
|
7072
7989
|
return slices.length ? slices.join(SLASH) + SLASH : EMPTY;
|
|
7073
7990
|
}
|
|
7074
|
-
const end =
|
|
7075
|
-
const cut =
|
|
7076
|
-
return cut < 0 ? EMPTY :
|
|
7991
|
+
const end = path12.length - 1;
|
|
7992
|
+
const cut = path12.lastIndexOf(SLASH, path12.charCodeAt(end) === SLASH_CODE ? end - 1 : end);
|
|
7993
|
+
return cut < 0 ? EMPTY : path12.slice(0, cut + 1);
|
|
7077
7994
|
};
|
|
7078
7995
|
var isString = (subject) => typeof subject === "string";
|
|
7079
7996
|
var checkPattern = (pattern) => pattern && isString(pattern) && !REGEX_TEST_BLANK_LINE.test(pattern) && !REGEX_INVALID_TRAILING_BACKSLASH.test(pattern) && pattern.indexOf("#") !== 0;
|
|
@@ -7158,19 +8075,19 @@ var require_ignore = __commonJS((exports, module) => {
|
|
|
7158
8075
|
makeArray(isString(pattern) ? splitPattern(pattern) : pattern).forEach(this._add, this);
|
|
7159
8076
|
return this._added;
|
|
7160
8077
|
}
|
|
7161
|
-
test(
|
|
8078
|
+
test(path12, checkUnignored, mode) {
|
|
7162
8079
|
let ignored = false;
|
|
7163
8080
|
let unignored = false;
|
|
7164
8081
|
let matchedRule;
|
|
7165
8082
|
const rules = this._rules;
|
|
7166
8083
|
const { length } = rules;
|
|
7167
8084
|
const shortcut = this._basenameCount * 2 >= length;
|
|
7168
|
-
const basename = shortcut ? basenameOf(
|
|
8085
|
+
const basename = shortcut ? basenameOf(path12) : path12;
|
|
7169
8086
|
for (let index = 0;index < length; index++) {
|
|
7170
8087
|
const rule = rules[index];
|
|
7171
8088
|
const { negative } = rule;
|
|
7172
8089
|
const skip = unignored === negative && ignored !== unignored || negative && !ignored && !unignored && !checkUnignored;
|
|
7173
|
-
if (!skip && rule[mode].test(shortcut && rule._basenameOnly ? basename :
|
|
8090
|
+
if (!skip && rule[mode].test(shortcut && rule._basenameOnly ? basename : path12)) {
|
|
7174
8091
|
ignored = !negative;
|
|
7175
8092
|
unignored = negative;
|
|
7176
8093
|
matchedRule = negative ? UNDEFINED : rule;
|
|
@@ -7189,38 +8106,38 @@ var require_ignore = __commonJS((exports, module) => {
|
|
|
7189
8106
|
var throwError = (message, Ctor) => {
|
|
7190
8107
|
throw new Ctor(message);
|
|
7191
8108
|
};
|
|
7192
|
-
var checkPath = (
|
|
7193
|
-
if (!isString(
|
|
8109
|
+
var checkPath = (path12, originalPath, doThrow) => {
|
|
8110
|
+
if (!isString(path12)) {
|
|
7194
8111
|
return doThrow(`path must be a string, but got \`${originalPath}\``, TypeError);
|
|
7195
8112
|
}
|
|
7196
|
-
if (!
|
|
8113
|
+
if (!path12) {
|
|
7197
8114
|
return doThrow(`path must not be empty`, TypeError);
|
|
7198
8115
|
}
|
|
7199
|
-
if (checkPath.isNotRelative(
|
|
8116
|
+
if (checkPath.isNotRelative(path12)) {
|
|
7200
8117
|
const r = "`path.relative()`d";
|
|
7201
8118
|
return doThrow(`path should be a ${r} string, but got "${originalPath}"`, RangeError);
|
|
7202
8119
|
}
|
|
7203
8120
|
return true;
|
|
7204
8121
|
};
|
|
7205
|
-
var isNotRelative = (
|
|
7206
|
-
const first =
|
|
8122
|
+
var isNotRelative = (path12) => {
|
|
8123
|
+
const first = path12.charCodeAt(0);
|
|
7207
8124
|
if (first === SLASH_CODE) {
|
|
7208
8125
|
return true;
|
|
7209
8126
|
}
|
|
7210
8127
|
if (first !== DOT_CODE) {
|
|
7211
8128
|
return false;
|
|
7212
8129
|
}
|
|
7213
|
-
if (
|
|
8130
|
+
if (path12.length === 1) {
|
|
7214
8131
|
return true;
|
|
7215
8132
|
}
|
|
7216
|
-
const second =
|
|
8133
|
+
const second = path12.charCodeAt(1);
|
|
7217
8134
|
if (second === SLASH_CODE) {
|
|
7218
8135
|
return true;
|
|
7219
8136
|
}
|
|
7220
8137
|
if (second !== DOT_CODE) {
|
|
7221
8138
|
return false;
|
|
7222
8139
|
}
|
|
7223
|
-
return
|
|
8140
|
+
return path12.length === 2 || path12.charCodeAt(2) === SLASH_CODE;
|
|
7224
8141
|
};
|
|
7225
8142
|
checkPath.isNotRelative = isNotRelative;
|
|
7226
8143
|
checkPath.convert = (p) => p;
|
|
@@ -7250,51 +8167,51 @@ var require_ignore = __commonJS((exports, module) => {
|
|
|
7250
8167
|
return this.add(pattern);
|
|
7251
8168
|
}
|
|
7252
8169
|
_test(originalPath, cache, checkUnignored) {
|
|
7253
|
-
const
|
|
7254
|
-
checkPath(
|
|
7255
|
-
return this._t(
|
|
8170
|
+
const path12 = originalPath && checkPath.convert(originalPath);
|
|
8171
|
+
checkPath(path12, originalPath, this._strictPathCheck ? throwError : RETURN_FALSE);
|
|
8172
|
+
return this._t(path12, cache, checkUnignored);
|
|
7256
8173
|
}
|
|
7257
|
-
checkIgnore(
|
|
7258
|
-
if (
|
|
7259
|
-
return this.test(
|
|
8174
|
+
checkIgnore(path12) {
|
|
8175
|
+
if (path12.charCodeAt(path12.length - 1) !== SLASH_CODE) {
|
|
8176
|
+
return this.test(path12);
|
|
7260
8177
|
}
|
|
7261
|
-
const parentPath = parentOf(
|
|
8178
|
+
const parentPath = parentOf(path12);
|
|
7262
8179
|
if (parentPath) {
|
|
7263
8180
|
const parent = this._t(parentPath, this._testCache, true);
|
|
7264
8181
|
if (parent.ignored) {
|
|
7265
8182
|
return parent;
|
|
7266
8183
|
}
|
|
7267
8184
|
}
|
|
7268
|
-
return this._rules.test(
|
|
8185
|
+
return this._rules.test(path12, false, MODE_CHECK_IGNORE);
|
|
7269
8186
|
}
|
|
7270
|
-
_t(
|
|
7271
|
-
if (
|
|
7272
|
-
return cache[
|
|
8187
|
+
_t(path12, cache, checkUnignored) {
|
|
8188
|
+
if (path12 in cache) {
|
|
8189
|
+
return cache[path12];
|
|
7273
8190
|
}
|
|
7274
|
-
const parentPath = parentOf(
|
|
8191
|
+
const parentPath = parentOf(path12);
|
|
7275
8192
|
const parent = parentPath ? this._t(parentPath, cache, checkUnignored) : UNDEFINED;
|
|
7276
|
-
return cache[
|
|
8193
|
+
return cache[path12] = parent && parent.ignored ? parent : this._rules.test(path12, checkUnignored, MODE_IGNORE);
|
|
7277
8194
|
}
|
|
7278
|
-
ignores(
|
|
7279
|
-
return this._test(
|
|
8195
|
+
ignores(path12) {
|
|
8196
|
+
return this._test(path12, this._ignoreCache, false).ignored;
|
|
7280
8197
|
}
|
|
7281
8198
|
createFilter() {
|
|
7282
|
-
return (
|
|
8199
|
+
return (path12) => !this.ignores(path12);
|
|
7283
8200
|
}
|
|
7284
8201
|
filter(paths) {
|
|
7285
8202
|
return makeArray(paths).filter(this.createFilter());
|
|
7286
8203
|
}
|
|
7287
|
-
test(
|
|
7288
|
-
return this._test(
|
|
8204
|
+
test(path12) {
|
|
8205
|
+
return this._test(path12, this._testCache, true);
|
|
7289
8206
|
}
|
|
7290
8207
|
}
|
|
7291
8208
|
var factory = (options) => new Ignore(options);
|
|
7292
|
-
var isPathValid = (
|
|
8209
|
+
var isPathValid = (path12) => checkPath(path12 && checkPath.convert(path12), path12, RETURN_FALSE);
|
|
7293
8210
|
var setupWindows = () => {
|
|
7294
8211
|
const makePosix = (str) => /^\\\\\?\\/.test(str) || /["<>|\u0000-\u001F]+/u.test(str) ? str : str.replace(/\\/g, "/");
|
|
7295
8212
|
checkPath.convert = makePosix;
|
|
7296
8213
|
const REGEX_TEST_WINDOWS_PATH_ABSOLUTE = /^[a-z]:\//i;
|
|
7297
|
-
checkPath.isNotRelative = (
|
|
8214
|
+
checkPath.isNotRelative = (path12) => REGEX_TEST_WINDOWS_PATH_ABSOLUTE.test(path12) || isNotRelative(path12);
|
|
7298
8215
|
};
|
|
7299
8216
|
if (typeof process !== "undefined" && process.platform === "win32") {
|
|
7300
8217
|
setupWindows();
|
|
@@ -7317,15 +8234,22 @@ __export(exports_daemon, {
|
|
|
7317
8234
|
checkAndInstallUpdate: () => checkAndInstallUpdate,
|
|
7318
8235
|
WATCH_LIMIT: () => WATCH_LIMIT
|
|
7319
8236
|
});
|
|
7320
|
-
import
|
|
7321
|
-
import
|
|
7322
|
-
import { spawn as
|
|
7323
|
-
import { fileURLToPath as
|
|
8237
|
+
import fs14 from "node:fs";
|
|
8238
|
+
import path12 from "node:path";
|
|
8239
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
8240
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
8241
|
+
function outputIsPrivate(key) {
|
|
8242
|
+
try {
|
|
8243
|
+
return reviewRequired(readConfig(key, { loadRules: false }));
|
|
8244
|
+
} catch {
|
|
8245
|
+
return true;
|
|
8246
|
+
}
|
|
8247
|
+
}
|
|
7324
8248
|
function peerRoot(key) {
|
|
7325
|
-
return
|
|
8249
|
+
return path12.dirname(key);
|
|
7326
8250
|
}
|
|
7327
8251
|
function peerName(key) {
|
|
7328
|
-
return peerNameOf(
|
|
8252
|
+
return peerNameOf(path12.basename(key)) ?? DEFAULT_PEER;
|
|
7329
8253
|
}
|
|
7330
8254
|
function peerFields(key) {
|
|
7331
8255
|
const peer = peerName(key);
|
|
@@ -7413,7 +8337,7 @@ function maybeAdoptNewerInstall(state, bootVersion) {
|
|
|
7413
8337
|
executable: onPath,
|
|
7414
8338
|
reason: "new-install"
|
|
7415
8339
|
});
|
|
7416
|
-
const child =
|
|
8340
|
+
const child = spawn3(onPath, ["daemon", "restart"], {
|
|
7417
8341
|
detached: true,
|
|
7418
8342
|
stdio: "ignore",
|
|
7419
8343
|
windowsHide: true,
|
|
@@ -7433,7 +8357,7 @@ async function runCycle(state) {
|
|
|
7433
8357
|
let skipped = 0;
|
|
7434
8358
|
for (const instance of readInstances()) {
|
|
7435
8359
|
const key = instance.configPath;
|
|
7436
|
-
if (!
|
|
8360
|
+
if (!fs14.existsSync(instance.configPath)) {
|
|
7437
8361
|
const misses = (state.misses.get(key) ?? 0) + 1;
|
|
7438
8362
|
state.misses.set(key, misses);
|
|
7439
8363
|
if (misses >= PRUNE_AFTER_MISSES) {
|
|
@@ -7520,7 +8444,7 @@ async function syncInstance(state, key, trigger, options = {}) {
|
|
|
7520
8444
|
command: "daemon",
|
|
7521
8445
|
...peerFields(key),
|
|
7522
8446
|
trigger,
|
|
7523
|
-
message: result.timedOut ? "sync timed out" : result.output.trim().slice(-500) || `sync exited ${result.status}`
|
|
8447
|
+
message: result.timedOut ? "sync timed out" : !outputIsPrivate(key) && result.output.trim().slice(-500) || `sync exited ${result.status}`
|
|
7524
8448
|
});
|
|
7525
8449
|
}
|
|
7526
8450
|
} finally {
|
|
@@ -7547,7 +8471,7 @@ async function syncIfDirty(state, key, trigger) {
|
|
|
7547
8471
|
}
|
|
7548
8472
|
async function checkoutIsDirty(key) {
|
|
7549
8473
|
const sidecarPath = readInstances().find((instance) => instance.configPath === key)?.sidecarPath;
|
|
7550
|
-
if (!sidecarPath || !
|
|
8474
|
+
if (!sidecarPath || !fs14.existsSync(sidecarPath))
|
|
7551
8475
|
return false;
|
|
7552
8476
|
const result = await runChild("git", ["-C", sidecarPath, "status", "--porcelain"], { timeoutMs: 30000 });
|
|
7553
8477
|
return result.status === 0 && Boolean(result.stdout.trim());
|
|
@@ -7555,7 +8479,7 @@ async function checkoutIsDirty(key) {
|
|
|
7555
8479
|
function localSidecarCliPath(root) {
|
|
7556
8480
|
if (!projectDependsOnSidecar(root))
|
|
7557
8481
|
return;
|
|
7558
|
-
const candidate =
|
|
8482
|
+
const candidate = path12.join(root, "node_modules", PACKAGE_NAME, "dist", "cli.js");
|
|
7559
8483
|
if (!isFile(candidate))
|
|
7560
8484
|
return;
|
|
7561
8485
|
const localVersion = installedPackageVersion(root);
|
|
@@ -7564,10 +8488,10 @@ function localSidecarCliPath(root) {
|
|
|
7564
8488
|
return candidate;
|
|
7565
8489
|
}
|
|
7566
8490
|
function currentCliPath() {
|
|
7567
|
-
return process.argv[1] ||
|
|
8491
|
+
return process.argv[1] || fileURLToPath2(import.meta.url);
|
|
7568
8492
|
}
|
|
7569
8493
|
function selectWatchTargets(instances, limit = WATCH_LIMIT) {
|
|
7570
|
-
return [...instances].filter((instance) =>
|
|
8494
|
+
return [...instances].filter((instance) => fs14.existsSync(instance.configPath) && fs14.existsSync(instance.sidecarPath)).sort((left, right) => instanceRecency(right) - instanceRecency(left)).slice(0, limit);
|
|
7571
8495
|
}
|
|
7572
8496
|
function instanceRecency(instance) {
|
|
7573
8497
|
const time = Date.parse(instance.lastSyncAt ?? instance.updatedAt ?? instance.registeredAt);
|
|
@@ -7618,8 +8542,8 @@ async function refreshWatchers(state) {
|
|
|
7618
8542
|
});
|
|
7619
8543
|
watcher.on("all", (...args) => {
|
|
7620
8544
|
scheduleWatchSync(state, key);
|
|
7621
|
-
const changedPath = typeof args[1] === "string" ?
|
|
7622
|
-
if (changedPath !==
|
|
8545
|
+
const changedPath = typeof args[1] === "string" ? path12.resolve(args[1]) : "";
|
|
8546
|
+
if (changedPath !== path12.join(path12.resolve(sidecarPath), ".gitignore"))
|
|
7623
8547
|
return;
|
|
7624
8548
|
if (state.watchers.get(key) !== watcher)
|
|
7625
8549
|
return;
|
|
@@ -7632,7 +8556,7 @@ async function refreshWatchers(state) {
|
|
|
7632
8556
|
logSidecarEvent("failure", {
|
|
7633
8557
|
command: "daemon",
|
|
7634
8558
|
...peerFields(key),
|
|
7635
|
-
message: `watcher error: ${error instanceof Error ? error.message : String(error)}`
|
|
8559
|
+
message: outputIsPrivate(key) ? "watcher error" : `watcher error: ${error instanceof Error ? error.message : String(error)}`
|
|
7636
8560
|
});
|
|
7637
8561
|
});
|
|
7638
8562
|
state.watchers.set(key, watcher);
|
|
@@ -7640,7 +8564,7 @@ async function refreshWatchers(state) {
|
|
|
7640
8564
|
logSidecarEvent("failure", {
|
|
7641
8565
|
command: "daemon",
|
|
7642
8566
|
...peerFields(key),
|
|
7643
|
-
message: `could not watch ${sidecarPath}: ${error instanceof Error ? error.message : String(error)}`
|
|
8567
|
+
message: outputIsPrivate(key) ? `could not watch ${sidecarPath}` : `could not watch ${sidecarPath}: ${error instanceof Error ? error.message : String(error)}`
|
|
7644
8568
|
});
|
|
7645
8569
|
}
|
|
7646
8570
|
}
|
|
@@ -7713,7 +8637,7 @@ async function watchRegistry(state) {
|
|
|
7713
8637
|
const watcher = chokidar.watch(sidecarStateDir(), { ignoreInitial: true, depth: 0 });
|
|
7714
8638
|
watcher.on("all", (...args) => {
|
|
7715
8639
|
const filePath = typeof args[1] === "string" ? args[1] : "";
|
|
7716
|
-
if (
|
|
8640
|
+
if (path12.basename(filePath) !== "instances.json")
|
|
7717
8641
|
return;
|
|
7718
8642
|
if (state.registryTimer)
|
|
7719
8643
|
return;
|
|
@@ -7732,7 +8656,7 @@ async function watchRegistry(state) {
|
|
|
7732
8656
|
function compileGitignoreMatcher(lines) {
|
|
7733
8657
|
const rules = import_ignore.default({ ignorecase: false }).add(lines);
|
|
7734
8658
|
return (relativePath) => {
|
|
7735
|
-
const normalized = relativePath.split(
|
|
8659
|
+
const normalized = relativePath.split(path12.sep).join("/");
|
|
7736
8660
|
if (!import_ignore.default.isPathValid(normalized))
|
|
7737
8661
|
return false;
|
|
7738
8662
|
return rules.ignores(normalized);
|
|
@@ -7741,19 +8665,19 @@ function compileGitignoreMatcher(lines) {
|
|
|
7741
8665
|
function watchIgnoreMatcher(sidecarPath) {
|
|
7742
8666
|
let gitignore;
|
|
7743
8667
|
try {
|
|
7744
|
-
const ignoreFile =
|
|
7745
|
-
if (
|
|
7746
|
-
gitignore = compileGitignoreMatcher(
|
|
8668
|
+
const ignoreFile = path12.join(sidecarPath, ".gitignore");
|
|
8669
|
+
if (fs14.existsSync(ignoreFile)) {
|
|
8670
|
+
gitignore = compileGitignoreMatcher(fs14.readFileSync(ignoreFile, "utf8").split(`
|
|
7747
8671
|
`));
|
|
7748
8672
|
}
|
|
7749
8673
|
} catch {}
|
|
7750
|
-
const root =
|
|
8674
|
+
const root = path12.resolve(sidecarPath);
|
|
7751
8675
|
return (candidate, stats) => {
|
|
7752
|
-
const relative =
|
|
8676
|
+
const relative = path12.relative(root, candidate);
|
|
7753
8677
|
if (!relative)
|
|
7754
8678
|
return false;
|
|
7755
|
-
const normalized = relative.split(
|
|
7756
|
-
if (normalized === ".." || normalized.startsWith("../") ||
|
|
8679
|
+
const normalized = relative.split(path12.sep).join("/");
|
|
8680
|
+
if (normalized === ".." || normalized.startsWith("../") || path12.isAbsolute(relative))
|
|
7757
8681
|
return true;
|
|
7758
8682
|
if (normalized === ".git" || normalized.startsWith(".git/"))
|
|
7759
8683
|
return true;
|
|
@@ -7832,10 +8756,10 @@ function restartAfterUpdate() {
|
|
|
7832
8756
|
}
|
|
7833
8757
|
async function acquireDaemonPid() {
|
|
7834
8758
|
const pidPath = daemonPidPath();
|
|
7835
|
-
|
|
8759
|
+
fs14.mkdirSync(path12.dirname(pidPath), { recursive: true });
|
|
7836
8760
|
while (true) {
|
|
7837
8761
|
try {
|
|
7838
|
-
|
|
8762
|
+
fs14.writeFileSync(pidPath, `${process.pid}
|
|
7839
8763
|
`, { encoding: "utf8", flag: "wx" });
|
|
7840
8764
|
return;
|
|
7841
8765
|
} catch (error) {
|
|
@@ -7851,7 +8775,7 @@ async function acquireDaemonPid() {
|
|
|
7851
8775
|
continue;
|
|
7852
8776
|
}
|
|
7853
8777
|
logSidecarEvent("daemon-pid-heal", { holder: holder ?? null });
|
|
7854
|
-
|
|
8778
|
+
fs14.rmSync(pidPath, { force: true });
|
|
7855
8779
|
}
|
|
7856
8780
|
}
|
|
7857
8781
|
function installShutdownHandlers() {
|
|
@@ -7866,12 +8790,12 @@ function installShutdownHandlers() {
|
|
|
7866
8790
|
function removeOwnPidFile() {
|
|
7867
8791
|
try {
|
|
7868
8792
|
if (readPid(daemonPidPath()) === process.pid)
|
|
7869
|
-
|
|
8793
|
+
fs14.rmSync(daemonPidPath(), { force: true });
|
|
7870
8794
|
} catch {}
|
|
7871
8795
|
}
|
|
7872
8796
|
function readPid(pidPath) {
|
|
7873
8797
|
try {
|
|
7874
|
-
const pid = Number(
|
|
8798
|
+
const pid = Number(fs14.readFileSync(pidPath, "utf8").trim());
|
|
7875
8799
|
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
7876
8800
|
} catch {
|
|
7877
8801
|
return;
|
|
@@ -7879,7 +8803,7 @@ function readPid(pidPath) {
|
|
|
7879
8803
|
}
|
|
7880
8804
|
function runChild(command, args, options) {
|
|
7881
8805
|
return new Promise((resolve) => {
|
|
7882
|
-
const child =
|
|
8806
|
+
const child = spawn3(command, args, {
|
|
7883
8807
|
cwd: options.cwd,
|
|
7884
8808
|
env: options.env ?? process.env,
|
|
7885
8809
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -7912,21 +8836,21 @@ function runChild(command, args, options) {
|
|
|
7912
8836
|
}
|
|
7913
8837
|
function isFile(filePath) {
|
|
7914
8838
|
try {
|
|
7915
|
-
return
|
|
8839
|
+
return fs14.statSync(filePath).isFile();
|
|
7916
8840
|
} catch {
|
|
7917
8841
|
return false;
|
|
7918
8842
|
}
|
|
7919
8843
|
}
|
|
7920
8844
|
function realpathOr2(filePath) {
|
|
7921
8845
|
try {
|
|
7922
|
-
return
|
|
8846
|
+
return fs14.realpathSync(filePath);
|
|
7923
8847
|
} catch {
|
|
7924
|
-
return
|
|
8848
|
+
return path12.resolve(filePath);
|
|
7925
8849
|
}
|
|
7926
8850
|
}
|
|
7927
8851
|
function isInsidePath2(child, parent) {
|
|
7928
|
-
const relative =
|
|
7929
|
-
return Boolean(relative) && !relative.startsWith("..") && !
|
|
8852
|
+
const relative = path12.relative(parent, child);
|
|
8853
|
+
return Boolean(relative) && !relative.startsWith("..") && !path12.isAbsolute(relative);
|
|
7930
8854
|
}
|
|
7931
8855
|
function delay(ms) {
|
|
7932
8856
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -8128,9 +9052,9 @@ var init_cmd_daemon = __esm(() => {
|
|
|
8128
9052
|
});
|
|
8129
9053
|
|
|
8130
9054
|
// src/cmd-sync.ts
|
|
8131
|
-
import
|
|
9055
|
+
import fs15 from "node:fs";
|
|
8132
9056
|
import os6 from "node:os";
|
|
8133
|
-
import
|
|
9057
|
+
import path13 from "node:path";
|
|
8134
9058
|
function cmdSnapshot(args) {
|
|
8135
9059
|
const parsed = parseOptions(args, {
|
|
8136
9060
|
boolean: new Set(["--push"]),
|
|
@@ -8139,17 +9063,23 @@ function cmdSnapshot(args) {
|
|
|
8139
9063
|
if (parsed.positional.length)
|
|
8140
9064
|
throw new SidecarError("usage: sidecar snapshot [--push] [-m message] [--peer name]");
|
|
8141
9065
|
const peers = loadPeers(selectedPeer(parsed));
|
|
9066
|
+
const push = parsed.flags.has("--push");
|
|
9067
|
+
const gated = push ? peers.filter((peer) => reviewRequired(peer.config)) : [];
|
|
9068
|
+
if (gated.length) {
|
|
9069
|
+
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`);
|
|
9070
|
+
}
|
|
8142
9071
|
for (const peer of peers) {
|
|
8143
9072
|
announcePeer(peer, peers);
|
|
8144
9073
|
const { root, config } = peer;
|
|
8145
9074
|
const sidecarPath = requireSidecarCheckout(root, config);
|
|
8146
9075
|
withSyncLock(root, peer.name, "throw", () => {
|
|
9076
|
+
bindReviewPolicy(sidecarPath, config);
|
|
8147
9077
|
const inbox = expandInbox(config, sidecarPath);
|
|
8148
9078
|
ensureCommitIdentity(sidecarPath);
|
|
8149
9079
|
ensureRedactionFilter(sidecarPath, config.redaction, config);
|
|
8150
9080
|
ensureInboxBranch(sidecarPath, config, inbox);
|
|
8151
9081
|
const committed = snapshot(sidecarPath, root, inbox, getValue(parsed, "--message", getValue(parsed, "-m", "")) || undefined, config.redaction, config);
|
|
8152
|
-
if (committed &&
|
|
9082
|
+
if (committed && push) {
|
|
8153
9083
|
syncBranchBeforePush(sidecarPath, inbox, config);
|
|
8154
9084
|
pushBranch(sidecarPath, inbox);
|
|
8155
9085
|
}
|
|
@@ -8167,12 +9097,19 @@ function cmdSync(args) {
|
|
|
8167
9097
|
}
|
|
8168
9098
|
const peers = loadPeers(selectedPeer(parsed), { loadRules: false });
|
|
8169
9099
|
const failed = [];
|
|
9100
|
+
const soft = parsed.flags.has("--soft") || process.env[SOFT_SYNC_ENV] === "1";
|
|
9101
|
+
const options = {
|
|
9102
|
+
lock: soft ? "skip" : "throw",
|
|
9103
|
+
remote: !parsed.flags.has("--local") && process.env[LOCAL_SYNC_ENV] !== "1",
|
|
9104
|
+
snapshot: !parsed.flags.has("--no-snapshot"),
|
|
9105
|
+
message: getValue(parsed, "--message", getValue(parsed, "-m", "")) || undefined
|
|
9106
|
+
};
|
|
8170
9107
|
for (const peer of peers) {
|
|
8171
9108
|
announcePeer(peer, peers);
|
|
8172
9109
|
try {
|
|
8173
9110
|
if (peer.config.rulesPath)
|
|
8174
9111
|
peer.config.rules = readRules(peer.config.rulesPath);
|
|
8175
|
-
syncPeer(peer,
|
|
9112
|
+
syncPeer(peer, options);
|
|
8176
9113
|
} catch (error) {
|
|
8177
9114
|
if (peers.length === 1)
|
|
8178
9115
|
throw error;
|
|
@@ -8185,18 +9122,19 @@ function cmdSync(args) {
|
|
|
8185
9122
|
}
|
|
8186
9123
|
return 0;
|
|
8187
9124
|
}
|
|
8188
|
-
function syncPeer(peer,
|
|
9125
|
+
function syncPeer(peer, options) {
|
|
8189
9126
|
const { root, config } = peer;
|
|
8190
|
-
const
|
|
8191
|
-
const
|
|
9127
|
+
const { remote } = options;
|
|
9128
|
+
const soft = options.lock === "skip";
|
|
8192
9129
|
let stage = "start";
|
|
9130
|
+
let outcome = {};
|
|
8193
9131
|
let synced;
|
|
8194
9132
|
try {
|
|
8195
|
-
synced = withSyncLock(root, peer.name,
|
|
8196
|
-
syncProject(root, config, {
|
|
8197
|
-
snapshot:
|
|
9133
|
+
synced = withSyncLock(root, peer.name, options.lock, () => {
|
|
9134
|
+
outcome = syncProject(root, config, {
|
|
9135
|
+
snapshot: options.snapshot,
|
|
8198
9136
|
remote,
|
|
8199
|
-
message:
|
|
9137
|
+
message: options.message,
|
|
8200
9138
|
onStage: (name) => {
|
|
8201
9139
|
stage = name;
|
|
8202
9140
|
}
|
|
@@ -8208,13 +9146,18 @@ function syncPeer(peer, parsed) {
|
|
|
8208
9146
|
reportSyncHealth(root, config, {
|
|
8209
9147
|
status: "failed",
|
|
8210
9148
|
stage,
|
|
8211
|
-
message: error instanceof Error ? error.message : String(error)
|
|
9149
|
+
message: reviewRequired(config) ? "details are shown only by a sync run by hand on that machine" : error instanceof Error ? error.message : String(error)
|
|
8212
9150
|
});
|
|
8213
9151
|
throw error;
|
|
8214
9152
|
}
|
|
8215
9153
|
if (synced) {
|
|
9154
|
+
const held = outcome.held;
|
|
9155
|
+
if (held) {
|
|
9156
|
+
const { id } = held.review.record;
|
|
9157
|
+
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}\``);
|
|
9158
|
+
}
|
|
8216
9159
|
if (remote)
|
|
8217
|
-
reportSyncHealth(root, config, { status: "ok" });
|
|
9160
|
+
reportSyncHealth(root, config, held ? { status: "ok", review: held.attention ? "attention" : "pending" } : { status: "ok" });
|
|
8218
9161
|
if (!soft) {
|
|
8219
9162
|
const sidecarPath = resolveSidecarPath(root, config);
|
|
8220
9163
|
if (checkoutIsUnlinkedFromFamily(root, config, sidecarPath)) {
|
|
@@ -8239,12 +9182,16 @@ function cmdMerge(args) {
|
|
|
8239
9182
|
const { root, config } = peer;
|
|
8240
9183
|
const sidecarPath = requireSidecarCheckout(root, config);
|
|
8241
9184
|
withSyncLock(root, peer.name, "throw", () => {
|
|
9185
|
+
bindReviewPolicy(sidecarPath, config);
|
|
8242
9186
|
ensureRedactionFilter(sidecarPath, config.redaction, config);
|
|
8243
|
-
|
|
8244
|
-
|
|
8245
|
-
|
|
8246
|
-
|
|
8247
|
-
|
|
9187
|
+
const forkFiles = parsed.flags.has("--fork-files");
|
|
9188
|
+
const push = !parsed.flags.has("--no-push");
|
|
9189
|
+
if (reviewRequired(config) && push) {
|
|
9190
|
+
mergePublishedInboxes(sidecarPath, config, { forkFiles });
|
|
9191
|
+
mergeInboxBranches(sidecarPath, config, { forkFiles, push: false, remote: false });
|
|
9192
|
+
return;
|
|
9193
|
+
}
|
|
9194
|
+
mergeInboxBranches(sidecarPath, config, { forkFiles, push, remote: true });
|
|
8248
9195
|
});
|
|
8249
9196
|
}
|
|
8250
9197
|
return 0;
|
|
@@ -8268,7 +9215,7 @@ function printPeerRedactions({ root, config }) {
|
|
|
8268
9215
|
let shown = 0;
|
|
8269
9216
|
let items = 0;
|
|
8270
9217
|
for (const relPath of files) {
|
|
8271
|
-
const delta = fileRedactionDelta(
|
|
9218
|
+
const delta = fileRedactionDelta(path13.join(sidecarPath, relPath), config.redaction, { rules: config.rules, relativePath: relPath });
|
|
8272
9219
|
if (!delta)
|
|
8273
9220
|
continue;
|
|
8274
9221
|
if (shown)
|
|
@@ -8287,12 +9234,12 @@ ${items} redaction(s) in ${shown} file(s) will be pushed this way (default: ${co
|
|
|
8287
9234
|
console.log("local files are untouched; redaction is controlled by the peer's configuration and rules");
|
|
8288
9235
|
}
|
|
8289
9236
|
function printRedactionDiff(original, redacted) {
|
|
8290
|
-
const scratch =
|
|
9237
|
+
const scratch = fs15.mkdtempSync(path13.join(os6.tmpdir(), "sidecar-redactions-"));
|
|
8291
9238
|
try {
|
|
8292
|
-
const localPath =
|
|
8293
|
-
const pushedPath =
|
|
8294
|
-
|
|
8295
|
-
|
|
9239
|
+
const localPath = path13.join(scratch, "local");
|
|
9240
|
+
const pushedPath = path13.join(scratch, "pushed");
|
|
9241
|
+
fs15.writeFileSync(localPath, original, "utf8");
|
|
9242
|
+
fs15.writeFileSync(pushedPath, redacted, "utf8");
|
|
8296
9243
|
const color = colorLevel() > 0 ? ["--color"] : [];
|
|
8297
9244
|
const diff = gitRaw(["diff", "--no-index", ...color, "--", localPath, pushedPath], { check: false });
|
|
8298
9245
|
const lines = diff.stdout.split(`
|
|
@@ -8303,7 +9250,7 @@ function printRedactionDiff(original, redacted) {
|
|
|
8303
9250
|
if (body)
|
|
8304
9251
|
console.log(body);
|
|
8305
9252
|
} finally {
|
|
8306
|
-
|
|
9253
|
+
fs15.rmSync(scratch, { recursive: true, force: true });
|
|
8307
9254
|
}
|
|
8308
9255
|
}
|
|
8309
9256
|
function cmdRedact(args) {
|
|
@@ -8319,15 +9266,15 @@ function cmdRedact(args) {
|
|
|
8319
9266
|
}
|
|
8320
9267
|
const mode = redactionModeConfigValue(getValue(parsed, "--mode", DEFAULT_REDACTION_MODE), "--mode");
|
|
8321
9268
|
const rulesPath = parsed.values.get("--rules");
|
|
8322
|
-
const effective = checkoutPolicy ? checkoutRedactionPolicy(process.cwd()) : { mode, rules: rulesPath ? readRules(
|
|
9269
|
+
const effective = checkoutPolicy ? checkoutRedactionPolicy(process.cwd()) : { mode, rules: rulesPath ? readRules(path13.resolve(rulesPath)) : [] };
|
|
8323
9270
|
const relativePath = parsed.values.get("--path");
|
|
8324
9271
|
if ((checkoutPolicy || rulesPath) && !relativePath)
|
|
8325
9272
|
throw new SidecarError("redaction rules require --path");
|
|
8326
9273
|
const effectiveMode = relativePath ? resolveFileRules(effective.rules, relativePath, { resolve: "fork", redaction: effective.mode }).redaction : effective.mode;
|
|
8327
|
-
const output = redactBuffer(
|
|
9274
|
+
const output = redactBuffer(fs15.readFileSync(0), effectiveMode);
|
|
8328
9275
|
let offset = 0;
|
|
8329
9276
|
while (offset < output.length) {
|
|
8330
|
-
offset +=
|
|
9277
|
+
offset += fs15.writeSync(1, output, offset, output.length - offset);
|
|
8331
9278
|
}
|
|
8332
9279
|
return 0;
|
|
8333
9280
|
}
|
|
@@ -8343,6 +9290,215 @@ var init_cmd_sync = __esm(() => {
|
|
|
8343
9290
|
init_ui();
|
|
8344
9291
|
});
|
|
8345
9292
|
|
|
9293
|
+
// src/cmd-review.ts
|
|
9294
|
+
import fs16 from "node:fs";
|
|
9295
|
+
import path14 from "node:path";
|
|
9296
|
+
function cmdReview(args) {
|
|
9297
|
+
const [subcommand = "list", ...rest] = args[0]?.startsWith("-") ? ["list", ...args] : args;
|
|
9298
|
+
switch (subcommand) {
|
|
9299
|
+
case "list":
|
|
9300
|
+
return listCommand(rest);
|
|
9301
|
+
case "show":
|
|
9302
|
+
return showCommand(rest);
|
|
9303
|
+
case "approve":
|
|
9304
|
+
case "reject":
|
|
9305
|
+
return decideCommand(subcommand, rest);
|
|
9306
|
+
case "retry":
|
|
9307
|
+
return retryCommand(rest);
|
|
9308
|
+
case "reviewer":
|
|
9309
|
+
return reviewerCommand(rest);
|
|
9310
|
+
case "run":
|
|
9311
|
+
return runCommand(rest);
|
|
9312
|
+
default:
|
|
9313
|
+
throw new SidecarError(`unknown review command ${JSON.stringify(subcommand)}; ${USAGE}`);
|
|
9314
|
+
}
|
|
9315
|
+
}
|
|
9316
|
+
function listCommand(args) {
|
|
9317
|
+
const parsed = parseOptions(args, { boolean: new Set(["--json"]), value: new Set(["--peer"]) });
|
|
9318
|
+
if (parsed.positional.length)
|
|
9319
|
+
throw new SidecarError(USAGE);
|
|
9320
|
+
const peers = loadPeers(selectedPeer(parsed));
|
|
9321
|
+
if (parsed.flags.has("--json")) {
|
|
9322
|
+
console.log(JSON.stringify(peers.map((peer) => ({
|
|
9323
|
+
peer: peer.name,
|
|
9324
|
+
required: reviewRequired(peer.config),
|
|
9325
|
+
reviews: peerReviews(peer).map(reviewPayload)
|
|
9326
|
+
})), null, 2));
|
|
9327
|
+
return 0;
|
|
9328
|
+
}
|
|
9329
|
+
for (const peer of peers) {
|
|
9330
|
+
announcePeer(peer, peers);
|
|
9331
|
+
if (!reviewRequired(peer.config)) {
|
|
9332
|
+
console.log(paint("quiet", 'review is off for this peer; turn it on with review = "required" in its config'));
|
|
9333
|
+
continue;
|
|
9334
|
+
}
|
|
9335
|
+
const reviews = peerReviews(peer);
|
|
9336
|
+
if (!reviews.length) {
|
|
9337
|
+
console.log(paint("quiet", "no reviews yet; the next sync with unpublished changes opens one"));
|
|
9338
|
+
continue;
|
|
9339
|
+
}
|
|
9340
|
+
for (const review of reviews)
|
|
9341
|
+
printReviewRow(review);
|
|
9342
|
+
}
|
|
9343
|
+
return 0;
|
|
9344
|
+
}
|
|
9345
|
+
function peerReviews(peer) {
|
|
9346
|
+
const sidecarPath = resolveSidecarPath(peer.root, peer.config);
|
|
9347
|
+
return hasGitMetadata(sidecarPath) ? listReviews(sidecarPath) : [];
|
|
9348
|
+
}
|
|
9349
|
+
function printReviewRow(review) {
|
|
9350
|
+
const attention = reviewAttention(review);
|
|
9351
|
+
const age = formatRelativeTime(review.record.createdAt) ?? review.record.createdAt;
|
|
9352
|
+
const text = `${review.record.id} ${review.state.padEnd(10)} ${age}, ${review.record.files.length} file(s)`;
|
|
9353
|
+
if (attention)
|
|
9354
|
+
console.log(`${paint("attn", text)} needs attention: ${attention}`);
|
|
9355
|
+
else
|
|
9356
|
+
console.log(isOpen(review) ? text : paint("quiet", text));
|
|
9357
|
+
}
|
|
9358
|
+
function reviewPayload(review) {
|
|
9359
|
+
return {
|
|
9360
|
+
id: review.record.id,
|
|
9361
|
+
state: review.state,
|
|
9362
|
+
attention: reviewAttention(review),
|
|
9363
|
+
createdAt: review.record.createdAt,
|
|
9364
|
+
files: review.record.files,
|
|
9365
|
+
reason: review.verdict?.reason,
|
|
9366
|
+
decidedAt: review.verdict?.decidedAt,
|
|
9367
|
+
publishedAt: review.record.publishedAt,
|
|
9368
|
+
publishedCommit: review.record.publishedCommit,
|
|
9369
|
+
dir: review.dir
|
|
9370
|
+
};
|
|
9371
|
+
}
|
|
9372
|
+
function showCommand(args) {
|
|
9373
|
+
const parsed = parseOptions(args, { boolean: new Set(["--json"]), value: new Set(["--peer"]) });
|
|
9374
|
+
if (parsed.positional.length > 1)
|
|
9375
|
+
throw new SidecarError(USAGE);
|
|
9376
|
+
const review = selectReview(parsed.positional[0], selectedPeer(parsed));
|
|
9377
|
+
if (parsed.flags.has("--json")) {
|
|
9378
|
+
console.log(JSON.stringify(reviewPayload(review), null, 2));
|
|
9379
|
+
return 0;
|
|
9380
|
+
}
|
|
9381
|
+
const { record } = review;
|
|
9382
|
+
const line = (label, value, role) => labelLine(LABEL_WIDTH, label, value, role);
|
|
9383
|
+
const attention = reviewAttention(review);
|
|
9384
|
+
line("review", record.id, "brand");
|
|
9385
|
+
line("state", attention ? `${review.state} — needs attention: ${attention}` : review.state, attention ? "attn" : undefined);
|
|
9386
|
+
line("repo", record.root, "repo");
|
|
9387
|
+
if (record.peer !== "default")
|
|
9388
|
+
line("peer", record.peer);
|
|
9389
|
+
line("opened", formatRelativeTime(record.createdAt) ?? record.createdAt);
|
|
9390
|
+
if (review.verdict)
|
|
9391
|
+
line("decided", `${review.verdict.verdict} ${formatRelativeTime(review.verdict.decidedAt) ?? review.verdict.decidedAt}`);
|
|
9392
|
+
if (review.verdict?.reason)
|
|
9393
|
+
line("reason", review.verdict.reason);
|
|
9394
|
+
if (review.run)
|
|
9395
|
+
line("reviewer", review.run.command, "quiet");
|
|
9396
|
+
line("packet", review.dir, "quiet");
|
|
9397
|
+
console.log("");
|
|
9398
|
+
for (const file of record.files) {
|
|
9399
|
+
console.log(` ${file.status} ${file.path}${file.artifact ? paint("quiet", ` (not in the diff; full content: ${path14.join(review.dir, file.artifact)})`) : ""}`);
|
|
9400
|
+
}
|
|
9401
|
+
console.log("");
|
|
9402
|
+
process.stdout.write(fs16.readFileSync(path14.join(review.dir, "diff.patch"), "utf8"));
|
|
9403
|
+
if (isOpen(review)) {
|
|
9404
|
+
console.log("");
|
|
9405
|
+
console.log(paint("quiet", `decide with \`sidecar review approve ${record.id}\` or \`sidecar review reject ${record.id} --reason "..."\``));
|
|
9406
|
+
}
|
|
9407
|
+
return 0;
|
|
9408
|
+
}
|
|
9409
|
+
function decideCommand(verdict, args) {
|
|
9410
|
+
const parsed = parseOptions(args, { boolean: new Set(["--no-sync"]), value: new Set(["--reason", "--peer"]) });
|
|
9411
|
+
if (parsed.positional.length > 1)
|
|
9412
|
+
throw new SidecarError(USAGE);
|
|
9413
|
+
const selected = selectReview(parsed.positional[0], selectedPeer(parsed));
|
|
9414
|
+
const reason = parsed.values.get("--reason")?.trim() || undefined;
|
|
9415
|
+
const { id } = selected.record;
|
|
9416
|
+
withLockedReview(selected, (review) => decideReview(review, verdict === "approve" ? "approved" : "rejected", reason));
|
|
9417
|
+
if (verdict === "reject") {
|
|
9418
|
+
console.log(`rejected review ${paint("brand", id)}; nothing it covers is published`);
|
|
9419
|
+
console.log("change the files it flags and the next sync opens a new review, or approve it to override");
|
|
9420
|
+
return 0;
|
|
9421
|
+
}
|
|
9422
|
+
console.log(`approved review ${paint("brand", id)}`);
|
|
9423
|
+
if (parsed.flags.has("--no-sync")) {
|
|
9424
|
+
console.log("it publishes on the next sync");
|
|
9425
|
+
return 0;
|
|
9426
|
+
}
|
|
9427
|
+
const peer = loadPeer(selected.record.root, selected.record.peer);
|
|
9428
|
+
syncPeer(peer, { lock: "wait", remote: true, snapshot: true });
|
|
9429
|
+
return 0;
|
|
9430
|
+
}
|
|
9431
|
+
function retryCommand(args) {
|
|
9432
|
+
const parsed = parseOptions(args, { boolean: new Set, value: new Set(["--peer"]) });
|
|
9433
|
+
if (parsed.positional.length > 1)
|
|
9434
|
+
throw new SidecarError(USAGE);
|
|
9435
|
+
configuredReviewer();
|
|
9436
|
+
const selected = selectReview(parsed.positional[0], selectedPeer(parsed));
|
|
9437
|
+
const fresh = withLockedReview(selected, retryReview);
|
|
9438
|
+
console.log(`retired review ${paint("brand", selected.record.id)}; review ${paint("brand", fresh.record.id)} covers the same change, and its reviewer is running`);
|
|
9439
|
+
return 0;
|
|
9440
|
+
}
|
|
9441
|
+
function withLockedReview(selected, fn) {
|
|
9442
|
+
const { id, root, peer } = selected.record;
|
|
9443
|
+
let result;
|
|
9444
|
+
withSyncLock(root, peer, "wait", () => {
|
|
9445
|
+
const review = readReview(id);
|
|
9446
|
+
if (!review)
|
|
9447
|
+
throw new SidecarError(`review ${id} is gone`);
|
|
9448
|
+
result = fn(review);
|
|
9449
|
+
});
|
|
9450
|
+
return result;
|
|
9451
|
+
}
|
|
9452
|
+
function reviewerCommand(args) {
|
|
9453
|
+
const settings = readSettings();
|
|
9454
|
+
if (!args.length) {
|
|
9455
|
+
console.log(settings.reviewer ?? paint("quiet", "no reviewer configured"));
|
|
9456
|
+
return 0;
|
|
9457
|
+
}
|
|
9458
|
+
if (args.length === 1 && args[0] === "--unset") {
|
|
9459
|
+
writeSettings({ ...settings, reviewer: undefined });
|
|
9460
|
+
console.log("reviewer unset; reviews now wait for `sidecar review approve`");
|
|
9461
|
+
return 0;
|
|
9462
|
+
}
|
|
9463
|
+
if (args.length !== 1) {
|
|
9464
|
+
throw new SidecarError('usage: sidecar review reviewer ["<command>"|--unset]; quote the command as one argument');
|
|
9465
|
+
}
|
|
9466
|
+
const command = args[0].trim();
|
|
9467
|
+
if (!command)
|
|
9468
|
+
throw new SidecarError("the reviewer command is empty");
|
|
9469
|
+
writeSettings({ ...settings, reviewer: command });
|
|
9470
|
+
console.log(`reviewer set to ${paint("brand", command)}`);
|
|
9471
|
+
return 0;
|
|
9472
|
+
}
|
|
9473
|
+
function runCommand(args) {
|
|
9474
|
+
if (args.length !== 1)
|
|
9475
|
+
throw new SidecarError("usage: sidecar review run <id>");
|
|
9476
|
+
return runReviewer(args[0]);
|
|
9477
|
+
}
|
|
9478
|
+
function selectReview(id, peer) {
|
|
9479
|
+
if (id)
|
|
9480
|
+
return findReview(id);
|
|
9481
|
+
const open = loadPeers(peer).map((candidate) => resolveSidecarPath(candidate.root, candidate.config)).filter(hasGitMetadata).map(openReview).filter((review) => review !== undefined);
|
|
9482
|
+
if (open.length === 1)
|
|
9483
|
+
return open[0];
|
|
9484
|
+
if (!open.length)
|
|
9485
|
+
throw new SidecarError("no open review here; pass a review id (`sidecar review` lists them)");
|
|
9486
|
+
throw new SidecarError(`more than one open review here (${open.map((review) => review.record.id).join(", ")}); pass the id`);
|
|
9487
|
+
}
|
|
9488
|
+
var USAGE = "usage: sidecar review [list|show|approve|reject|retry|reviewer] [id] [--reason text] [--no-sync] [--json] [--peer name]", LABEL_WIDTH;
|
|
9489
|
+
var init_cmd_review = __esm(() => {
|
|
9490
|
+
init_color();
|
|
9491
|
+
init_util();
|
|
9492
|
+
init_git();
|
|
9493
|
+
init_config();
|
|
9494
|
+
init_state();
|
|
9495
|
+
init_review();
|
|
9496
|
+
init_sync();
|
|
9497
|
+
init_cmd_sync();
|
|
9498
|
+
init_ui();
|
|
9499
|
+
LABEL_WIDTH = "reviewer:".length;
|
|
9500
|
+
});
|
|
9501
|
+
|
|
8346
9502
|
// src/commands.ts
|
|
8347
9503
|
function run(argv) {
|
|
8348
9504
|
const [command, ...rest] = argv;
|
|
@@ -8416,12 +9572,13 @@ var init_commands = __esm(() => {
|
|
|
8416
9572
|
init_cmd_status();
|
|
8417
9573
|
init_cmd_daemon();
|
|
8418
9574
|
init_cmd_sync();
|
|
9575
|
+
init_cmd_review();
|
|
8419
9576
|
COMMANDS = [
|
|
8420
9577
|
{
|
|
8421
9578
|
name: "init",
|
|
8422
9579
|
run: cmdInit,
|
|
8423
9580
|
section: "common",
|
|
8424
|
-
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]",
|
|
9581
|
+
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]",
|
|
8425
9582
|
notes: [
|
|
8426
9583
|
"--path . makes this repo itself the sidecar (standalone)",
|
|
8427
9584
|
"--peer name adds a second sidecar as .sidecar.name; --ignored keeps it out of the tree",
|
|
@@ -8443,6 +9600,16 @@ var init_commands = __esm(() => {
|
|
|
8443
9600
|
usage: "redactions [--peer name]",
|
|
8444
9601
|
summary: "preview what redaction rewrites before content is pushed"
|
|
8445
9602
|
},
|
|
9603
|
+
{
|
|
9604
|
+
name: "review",
|
|
9605
|
+
run: cmdReview,
|
|
9606
|
+
section: "common",
|
|
9607
|
+
usage: "review [show|approve|reject|retry] [id] [--reason text] [--no-sync] [--json] [--peer name]",
|
|
9608
|
+
notes: [
|
|
9609
|
+
'the reviews that gate a peer with review = "required"; approve publishes at once',
|
|
9610
|
+
"review reviewer [<command>|--unset] sets who reviews on this machine"
|
|
9611
|
+
]
|
|
9612
|
+
},
|
|
8446
9613
|
{
|
|
8447
9614
|
name: "sync",
|
|
8448
9615
|
run: cmdSync,
|
|
@@ -8513,7 +9680,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
8513
9680
|
if (command !== "redact" && command !== "deinit" && shouldUseGlobalRegistry()) {
|
|
8514
9681
|
logSidecarEvent("failure", {
|
|
8515
9682
|
command,
|
|
8516
|
-
message: error instanceof Error ? error.message : String(error)
|
|
9683
|
+
...privateFailures() ? {} : { message: error instanceof Error ? error.message : String(error) }
|
|
8517
9684
|
});
|
|
8518
9685
|
}
|
|
8519
9686
|
if (error instanceof SidecarError) {
|
|
@@ -8531,6 +9698,7 @@ var init_cli = __esm(() => {
|
|
|
8531
9698
|
init_color();
|
|
8532
9699
|
init_util();
|
|
8533
9700
|
init_install();
|
|
9701
|
+
init_config();
|
|
8534
9702
|
init_state();
|
|
8535
9703
|
init_commands();
|
|
8536
9704
|
init_util();
|
|
@@ -8552,20 +9720,20 @@ var init_cli = __esm(() => {
|
|
|
8552
9720
|
|
|
8553
9721
|
// src/bin.ts
|
|
8554
9722
|
init_cli();
|
|
8555
|
-
import
|
|
8556
|
-
import
|
|
8557
|
-
import { spawnSync as
|
|
8558
|
-
import { fileURLToPath as
|
|
9723
|
+
import fs17 from "node:fs";
|
|
9724
|
+
import path15 from "node:path";
|
|
9725
|
+
import { spawnSync as spawnSync6 } from "node:child_process";
|
|
9726
|
+
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
8559
9727
|
var SKIP_LOCAL_EXEC_ENV3 = "SIDECAR_SKIP_LOCAL_EXEC";
|
|
8560
9728
|
var GLOBAL_EXEC_ENV3 = "SIDECAR_GLOBAL_EXEC";
|
|
8561
9729
|
var PACKAGE_NAME2 = "sidecarsync";
|
|
8562
9730
|
var GLOBAL_ONLY_COMMANDS = new Set(["daemon", "deinit", "register-install", "set-install-source", "update"]);
|
|
8563
9731
|
if (process.argv[2] !== "redact" && !process.env[SKIP_LOCAL_EXEC_ENV3]) {
|
|
8564
|
-
const local = findLocalInstall(process.cwd(),
|
|
9732
|
+
const local = findLocalInstall(process.cwd(), fileURLToPath3(import.meta.url));
|
|
8565
9733
|
if (local) {
|
|
8566
9734
|
process.env[GLOBAL_EXEC_ENV3] = "1";
|
|
8567
9735
|
if (local.newer && !GLOBAL_ONLY_COMMANDS.has(process.argv[2])) {
|
|
8568
|
-
const result =
|
|
9736
|
+
const result = spawnSync6(process.execPath, [local.executable, ...process.argv.slice(2)], {
|
|
8569
9737
|
stdio: "inherit",
|
|
8570
9738
|
env: {
|
|
8571
9739
|
...process.env,
|
|
@@ -8582,15 +9750,15 @@ if (process.argv[2] !== "redact" && !process.env[SKIP_LOCAL_EXEC_ENV3]) {
|
|
|
8582
9750
|
}
|
|
8583
9751
|
process.exit(await main());
|
|
8584
9752
|
function findLocalInstall(start, self) {
|
|
8585
|
-
let current =
|
|
9753
|
+
let current = path15.resolve(start);
|
|
8586
9754
|
while (true) {
|
|
8587
9755
|
if (projectDependsOnSidecar2(current)) {
|
|
8588
|
-
const candidate =
|
|
9756
|
+
const candidate = path15.join(current, "node_modules", PACKAGE_NAME2, "dist", "cli.js");
|
|
8589
9757
|
if (isFile2(candidate) && !sameFile(candidate, self)) {
|
|
8590
9758
|
return { executable: candidate, newer: localIsNewer(current) };
|
|
8591
9759
|
}
|
|
8592
9760
|
}
|
|
8593
|
-
const parent =
|
|
9761
|
+
const parent = path15.dirname(current);
|
|
8594
9762
|
if (parent === current)
|
|
8595
9763
|
return;
|
|
8596
9764
|
current = parent;
|
|
@@ -8601,11 +9769,11 @@ function localIsNewer(projectRoot) {
|
|
|
8601
9769
|
return localVersion !== undefined && compareVersions(localVersion, packageVersion()) > 0;
|
|
8602
9770
|
}
|
|
8603
9771
|
function projectDependsOnSidecar2(projectRoot) {
|
|
8604
|
-
const manifestPath =
|
|
9772
|
+
const manifestPath = path15.join(projectRoot, "package.json");
|
|
8605
9773
|
if (!isFile2(manifestPath))
|
|
8606
9774
|
return false;
|
|
8607
9775
|
try {
|
|
8608
|
-
const manifest = JSON.parse(
|
|
9776
|
+
const manifest = JSON.parse(fs17.readFileSync(manifestPath, "utf8"));
|
|
8609
9777
|
return Boolean(manifest.dependencies?.[PACKAGE_NAME2] || manifest.devDependencies?.[PACKAGE_NAME2] || manifest.optionalDependencies?.[PACKAGE_NAME2] || manifest.peerDependencies?.[PACKAGE_NAME2]);
|
|
8610
9778
|
} catch {
|
|
8611
9779
|
return false;
|
|
@@ -8613,14 +9781,14 @@ function projectDependsOnSidecar2(projectRoot) {
|
|
|
8613
9781
|
}
|
|
8614
9782
|
function isFile2(filePath) {
|
|
8615
9783
|
try {
|
|
8616
|
-
return
|
|
9784
|
+
return fs17.statSync(filePath).isFile();
|
|
8617
9785
|
} catch {
|
|
8618
9786
|
return false;
|
|
8619
9787
|
}
|
|
8620
9788
|
}
|
|
8621
9789
|
function sameFile(first, second) {
|
|
8622
9790
|
try {
|
|
8623
|
-
return
|
|
9791
|
+
return fs17.realpathSync(first) === fs17.realpathSync(second);
|
|
8624
9792
|
} catch {
|
|
8625
9793
|
return false;
|
|
8626
9794
|
}
|