sidecarsync 2.1.0 → 2.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +464 -51
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -216,12 +216,15 @@ function utcTimestamp() {
|
|
|
216
216
|
function nowIso() {
|
|
217
217
|
return new Date().toISOString();
|
|
218
218
|
}
|
|
219
|
-
var SidecarError;
|
|
219
|
+
var DIRTY_CHECKOUT_EXIT = 75, SidecarError;
|
|
220
220
|
var init_util = __esm(() => {
|
|
221
221
|
SidecarError = class SidecarError extends Error {
|
|
222
|
-
|
|
222
|
+
reason;
|
|
223
|
+
stage;
|
|
224
|
+
constructor(message, options = {}) {
|
|
223
225
|
super(message);
|
|
224
226
|
this.name = "SidecarError";
|
|
227
|
+
this.reason = options.reason;
|
|
225
228
|
}
|
|
226
229
|
};
|
|
227
230
|
});
|
|
@@ -1402,7 +1405,7 @@ function isDirty(repo) {
|
|
|
1402
1405
|
}
|
|
1403
1406
|
function ensureClean(repo) {
|
|
1404
1407
|
if (isDirty(repo))
|
|
1405
|
-
throw new SidecarError("sidecar checkout has uncommitted changes");
|
|
1408
|
+
throw new SidecarError("sidecar checkout has uncommitted changes", { reason: "dirty-checkout" });
|
|
1406
1409
|
}
|
|
1407
1410
|
function sidecarIdentity() {
|
|
1408
1411
|
return { name: currentUser(), email: `${slug(currentUser())}@${slug(currentHost())}.local` };
|
|
@@ -1446,15 +1449,142 @@ var init_git = __esm(() => {
|
|
|
1446
1449
|
});
|
|
1447
1450
|
|
|
1448
1451
|
// src/redaction.ts
|
|
1452
|
+
function redactShellDefaults(text) {
|
|
1453
|
+
let output = "";
|
|
1454
|
+
let copied = 0;
|
|
1455
|
+
for (const match of text.matchAll(SHELL_DEFAULT_REGEX)) {
|
|
1456
|
+
const [whole, open, name, operator] = match;
|
|
1457
|
+
if (match.index < copied)
|
|
1458
|
+
continue;
|
|
1459
|
+
if (SHELL_DIRECTORY_NAMES.has(name) || !isSensitiveKey(name))
|
|
1460
|
+
continue;
|
|
1461
|
+
const wordStart = match.index + whole.length;
|
|
1462
|
+
const end = shellWordEnd(text, wordStart, "}");
|
|
1463
|
+
const wordEnd = end === -1 ? lineEnd(text, wordStart) : end;
|
|
1464
|
+
const word = text.slice(wordStart, wordEnd);
|
|
1465
|
+
output += text.slice(copied, wordStart);
|
|
1466
|
+
output += !word || isPlaceholder(word) || isShellReference(word) ? word : placeholderForKey(name);
|
|
1467
|
+
copied = wordEnd;
|
|
1468
|
+
}
|
|
1469
|
+
return output + text.slice(copied);
|
|
1470
|
+
}
|
|
1471
|
+
function shellWordEnd(text, start, close, depth = 0) {
|
|
1472
|
+
if (depth > SHELL_NESTING_LIMIT)
|
|
1473
|
+
return -1;
|
|
1474
|
+
let parens = 0;
|
|
1475
|
+
for (let index = start;index < text.length; index += 1) {
|
|
1476
|
+
const char = text[index];
|
|
1477
|
+
if (char === `
|
|
1478
|
+
` || char === "\r")
|
|
1479
|
+
return -1;
|
|
1480
|
+
if (char === "\\") {
|
|
1481
|
+
index += 1;
|
|
1482
|
+
} else if (char === "'" || char === '"' || char === "`") {
|
|
1483
|
+
index = closingQuote(text, index + 1, char, depth + 1);
|
|
1484
|
+
if (index === -1)
|
|
1485
|
+
return -1;
|
|
1486
|
+
} else if (char === "$" && (text[index + 1] === "{" || text[index + 1] === "(")) {
|
|
1487
|
+
index = shellWordEnd(text, index + 2, text[index + 1] === "{" ? "}" : ")", depth + 1);
|
|
1488
|
+
if (index === -1)
|
|
1489
|
+
return -1;
|
|
1490
|
+
} else if (close === ")" && char === "(") {
|
|
1491
|
+
parens += 1;
|
|
1492
|
+
} else if (close === ")" && char === ")" && parens > 0) {
|
|
1493
|
+
parens -= 1;
|
|
1494
|
+
} else if (char === close) {
|
|
1495
|
+
return index;
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
return -1;
|
|
1499
|
+
}
|
|
1500
|
+
function closingQuote(text, start, quote, depth) {
|
|
1501
|
+
if (depth > SHELL_NESTING_LIMIT)
|
|
1502
|
+
return -1;
|
|
1503
|
+
for (let index = start;index < text.length; index += 1) {
|
|
1504
|
+
const char = text[index];
|
|
1505
|
+
if (char === `
|
|
1506
|
+
` || char === "\r")
|
|
1507
|
+
return -1;
|
|
1508
|
+
if (char === quote)
|
|
1509
|
+
return index;
|
|
1510
|
+
if (quote === "'")
|
|
1511
|
+
continue;
|
|
1512
|
+
if (char === "\\") {
|
|
1513
|
+
index += 1;
|
|
1514
|
+
} else if (quote === '"' && char === "$" && (text[index + 1] === "{" || text[index + 1] === "(")) {
|
|
1515
|
+
index = shellWordEnd(text, index + 2, text[index + 1] === "{" ? "}" : ")", depth + 1);
|
|
1516
|
+
if (index === -1)
|
|
1517
|
+
return -1;
|
|
1518
|
+
} else if (quote === '"' && char === "`") {
|
|
1519
|
+
index = closingQuote(text, index + 1, "`", depth + 1);
|
|
1520
|
+
if (index === -1)
|
|
1521
|
+
return -1;
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
return -1;
|
|
1525
|
+
}
|
|
1526
|
+
function lineEnd(text, start) {
|
|
1527
|
+
const match = /[\r\n]/.exec(text.slice(start));
|
|
1528
|
+
return match ? start + match.index : text.length;
|
|
1529
|
+
}
|
|
1530
|
+
function isShellReadOfKey(text, offset, separator) {
|
|
1531
|
+
if (SHELL_EXPANSION_KEY_PREFIX.test(text.slice(Math.max(0, offset - 64), offset)))
|
|
1532
|
+
return true;
|
|
1533
|
+
return text[offset - 1] === "$" && separator.trim().startsWith(":");
|
|
1534
|
+
}
|
|
1535
|
+
function isShellReference(value, depth = 0) {
|
|
1536
|
+
if (depth > SHELL_NESTING_LIMIT)
|
|
1537
|
+
return false;
|
|
1538
|
+
const unquoted = value.length > 1 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value;
|
|
1539
|
+
if (SHELL_VARIABLE_REFERENCE.test(unquoted))
|
|
1540
|
+
return true;
|
|
1541
|
+
if (!unquoted.startsWith("${") || shellWordEnd(unquoted, 2, "}") !== unquoted.length - 1)
|
|
1542
|
+
return false;
|
|
1543
|
+
const rest = SHELL_EXPANSION_INNER.exec(unquoted.slice(2, -1))?.groups?.rest;
|
|
1544
|
+
if (rest === undefined)
|
|
1545
|
+
return false;
|
|
1546
|
+
if (rest === "" || /^:?\?/.test(rest) || /^(?::[a-zA-Z0-9]+)+$/.test(rest))
|
|
1547
|
+
return true;
|
|
1548
|
+
const word = /^:?[-=+](?<word>.*)$/s.exec(rest)?.groups?.word;
|
|
1549
|
+
return word !== undefined && (word === "" || isPlaceholder(word) || isShellReference(word, depth + 1));
|
|
1550
|
+
}
|
|
1551
|
+
function startsWithShellReference(text, start, value) {
|
|
1552
|
+
if (SHELL_COMMAND_NAME_VALUE.test(value))
|
|
1553
|
+
return true;
|
|
1554
|
+
if (text[start] !== "$")
|
|
1555
|
+
return false;
|
|
1556
|
+
let end;
|
|
1557
|
+
if (text[start + 1] === "{") {
|
|
1558
|
+
end = shellWordEnd(text, start + 2, "}");
|
|
1559
|
+
if (end === -1)
|
|
1560
|
+
return false;
|
|
1561
|
+
end += 1;
|
|
1562
|
+
} else {
|
|
1563
|
+
end = start + 1 + (/^[A-Za-z0-9_]*/.exec(text.slice(start + 1))?.[0].length ?? 0);
|
|
1564
|
+
}
|
|
1565
|
+
if (end < text.length && !/[\s;&|)]/.test(text[end]))
|
|
1566
|
+
return false;
|
|
1567
|
+
return isShellReference(text.slice(start, end));
|
|
1568
|
+
}
|
|
1449
1569
|
function redactText(input, mode = DEFAULT_REDACTION_MODE) {
|
|
1450
1570
|
if (mode === "none")
|
|
1451
1571
|
return input;
|
|
1452
|
-
let output = input.replace(PEM_PRIVATE_KEY_REGEX, "<PRIVATEKEY>").replace(AUTHORIZATION_HEADER_REGEX, (_match, prefix) => `${prefix}<TOKEN>`).replace(BARE_BEARER_TOKEN_REGEX, (_match, prefix) => `${prefix}<TOKEN>`).replace(URL_CREDENTIALS_REGEX, (_match, prefix) => `${prefix}:<SECRET>@`).replace(QUOTED_SECRET_REGEX, (match, ...args) => {
|
|
1453
|
-
const { keyQuote = "", key, separator, valueQuote } = args.at(-1);
|
|
1454
|
-
|
|
1572
|
+
let output = redactShellDefaults(input).replace(PEM_PRIVATE_KEY_REGEX, "<PRIVATEKEY>").replace(AUTHORIZATION_HEADER_REGEX, (_match, prefix) => `${prefix}<TOKEN>`).replace(BARE_BEARER_TOKEN_REGEX, (_match, prefix) => `${prefix}<TOKEN>`).replace(URL_CREDENTIALS_REGEX, (_match, prefix) => `${prefix}:<SECRET>@`).replace(QUOTED_SECRET_REGEX, (match, ...args) => {
|
|
1573
|
+
const { keyQuote = "", key, separator, valueQuote, value } = args.at(-1);
|
|
1574
|
+
const offset = args.at(-3);
|
|
1575
|
+
const text = args.at(-2);
|
|
1576
|
+
if (!isSensitiveKey(key) || isShellReadOfKey(text, offset + keyQuote.length, separator))
|
|
1577
|
+
return match;
|
|
1578
|
+
if (valueQuote === '"' && isShellReference(value))
|
|
1455
1579
|
return match;
|
|
1456
1580
|
return `${keyQuote}${key}${keyQuote}${separator}${valueQuote}${placeholderForKey(key)}${valueQuote}`;
|
|
1457
|
-
}).replace(BARE_ASSIGNMENT_SECRET_REGEX, (match, key, separator
|
|
1581
|
+
}).replace(BARE_ASSIGNMENT_SECRET_REGEX, (match, key, separator, value, offset, text) => {
|
|
1582
|
+
if (!isSensitiveKey(key) || isShellReadOfKey(text, offset, separator))
|
|
1583
|
+
return match;
|
|
1584
|
+
if (startsWithShellReference(text, offset + key.length + separator.length, value))
|
|
1585
|
+
return match;
|
|
1586
|
+
return `${key}${separator}${placeholderForKey(key)}`;
|
|
1587
|
+
});
|
|
1458
1588
|
for (const [pattern, replacement] of TOKEN_PATTERNS) {
|
|
1459
1589
|
output = output.replace(pattern, replacement);
|
|
1460
1590
|
}
|
|
@@ -1462,6 +1592,12 @@ function redactText(input, mode = DEFAULT_REDACTION_MODE) {
|
|
|
1462
1592
|
return output;
|
|
1463
1593
|
return output.replace(EMAIL_REGEX, "<EMAIL>").replace(PHONE_REGEX, "<PHONENUMBER>").replace(SSN_REGEX, "<SSN>").replace(CREDIT_CARD_CANDIDATE_REGEX, (candidate) => isLikelyCreditCard(candidate) ? "<CREDITCARD>" : candidate);
|
|
1464
1594
|
}
|
|
1595
|
+
function isPlaceholder(text) {
|
|
1596
|
+
return /^<(?:API_KEY|TOKEN|SECRET|PRIVATEKEY|EMAIL|PHONENUMBER|SSN|CREDITCARD)>$/.test(text);
|
|
1597
|
+
}
|
|
1598
|
+
function hasRedactionPlaceholder(text) {
|
|
1599
|
+
return countRedactionPlaceholders(text) > 0;
|
|
1600
|
+
}
|
|
1465
1601
|
function countRedactionPlaceholders(text) {
|
|
1466
1602
|
return text.match(PLACEHOLDER_REGEX)?.length ?? 0;
|
|
1467
1603
|
}
|
|
@@ -1515,12 +1651,20 @@ function isLikelyCreditCard(value) {
|
|
|
1515
1651
|
}
|
|
1516
1652
|
return sum % 10 === 0;
|
|
1517
1653
|
}
|
|
1518
|
-
var DEFAULT_REDACTION_MODE = "secrets", REDACTION_MODES, KEY_NAME_PATTERN, QUOTED_SECRET_REGEX, BARE_ASSIGNMENT_SECRET_REGEX, AUTHORIZATION_HEADER_REGEX, PEM_PRIVATE_KEY_REGEX, URL_CREDENTIALS_REGEX, BARE_BEARER_TOKEN_REGEX, TOKEN_PATTERNS, EMAIL_REGEX, PHONE_REGEX, SSN_REGEX, CREDIT_CARD_CANDIDATE_REGEX, PLACEHOLDER_REGEX, COMPACT_SENSITIVE_KEYS;
|
|
1654
|
+
var DEFAULT_REDACTION_MODE = "secrets", REDACTION_MODES, KEY_NAME_PATTERN, QUOTED_SECRET_REGEX, BARE_ASSIGNMENT_SECRET_REGEX, SHELL_NAME_PATTERN, SHELL_EXPANSION_OPEN, SHELL_DEFAULT_REGEX, SHELL_EXPANSION_KEY_PREFIX, SHELL_EXPANSION_INNER, SHELL_DIRECTORY_NAMES, SHELL_VARIABLE_REFERENCE, SHELL_NESTING_LIMIT = 16, SHELL_COMMAND_NAME_VALUE, AUTHORIZATION_HEADER_REGEX, PEM_PRIVATE_KEY_REGEX, URL_CREDENTIALS_REGEX, BARE_BEARER_TOKEN_REGEX, TOKEN_PATTERNS, EMAIL_REGEX, PHONE_REGEX, SSN_REGEX, CREDIT_CARD_CANDIDATE_REGEX, PLACEHOLDER_REGEX, COMPACT_SENSITIVE_KEYS;
|
|
1519
1655
|
var init_redaction = __esm(() => {
|
|
1520
1656
|
REDACTION_MODES = ["none", "secrets", "secrets+pii"];
|
|
1521
1657
|
KEY_NAME_PATTERN = String.raw`[A-Za-z0-9_][A-Za-z0-9_-]*`;
|
|
1522
|
-
QUOTED_SECRET_REGEX = new RegExp(String.raw`(?:(?<keyQuote>["'])|\b)(?<key>${KEY_NAME_PATTERN})\k<keyQuote>` + String.raw`(?<separator>\s*[:=]\s*)(?<valueQuote>["'])(?:\\[^\r\n]|(?!\k<valueQuote>)[^\\\r\n])
|
|
1658
|
+
QUOTED_SECRET_REGEX = new RegExp(String.raw`(?:(?<keyQuote>["'])|\b)(?<key>${KEY_NAME_PATTERN})\k<keyQuote>` + String.raw`(?<separator>\s*[:=]\s*)(?<valueQuote>["'])(?<value>(?:\\[^\r\n]|(?!\k<valueQuote>)[^\\\r\n])+)\k<valueQuote>`, "g");
|
|
1523
1659
|
BARE_ASSIGNMENT_SECRET_REGEX = new RegExp(String.raw`\b(${KEY_NAME_PATTERN})(\s*[:=]\s*)([^\s"',;` + "`" + String.raw`]+)`, "g");
|
|
1660
|
+
SHELL_NAME_PATTERN = String.raw`[A-Za-z_][A-Za-z0-9_]*`;
|
|
1661
|
+
SHELL_EXPANSION_OPEN = String.raw`\$\{(?:\([^()\s]*\))?[#!^=~]*`;
|
|
1662
|
+
SHELL_DEFAULT_REGEX = new RegExp(String.raw`(${SHELL_EXPANSION_OPEN})(${SHELL_NAME_PATTERN})(:?[-=+])`, "g");
|
|
1663
|
+
SHELL_EXPANSION_KEY_PREFIX = new RegExp(`${SHELL_EXPANSION_OPEN}$`);
|
|
1664
|
+
SHELL_EXPANSION_INNER = new RegExp(String.raw`^(?:\([^()\s]*\))?[#!^=~]*${SHELL_NAME_PATTERN}(?<rest>.*)$`, "s");
|
|
1665
|
+
SHELL_DIRECTORY_NAMES = new Set(["PWD", "OLDPWD"]);
|
|
1666
|
+
SHELL_VARIABLE_REFERENCE = new RegExp(String.raw`^\$(?:[A-Z_][A-Z0-9_]*)$`);
|
|
1667
|
+
SHELL_COMMAND_NAME_VALUE = /^\$\((?:[A-Za-z0-9_./+-]+|<(?:API_KEY|TOKEN|SECRET|PRIVATEKEY|EMAIL|PHONENUMBER|SSN|CREDITCARD)>)\)?$/;
|
|
1524
1668
|
AUTHORIZATION_HEADER_REGEX = /\b(authorization\s*[:=]\s*(?:bearer|basic|token)\s+)([^\s"',;`]+)/gi;
|
|
1525
1669
|
PEM_PRIVATE_KEY_REGEX = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/g;
|
|
1526
1670
|
URL_CREDENTIALS_REGEX = /(\/\/[^\s/:@"'`]+):([^\s/@"'`]+)@/g;
|
|
@@ -3812,7 +3956,7 @@ function inboxBranchPrefix(template) {
|
|
|
3812
3956
|
const slashIndex = staticPrefix.lastIndexOf("/");
|
|
3813
3957
|
return slashIndex === -1 ? staticPrefix : staticPrefix.slice(0, slashIndex + 1);
|
|
3814
3958
|
}
|
|
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/";
|
|
3959
|
+
var DEFAULT_PATH = "sidecar", DEFAULT_BRANCH = "main", DEFAULT_INBOX = "sidecar-inbox/{user}/{random}", branchValidity, DEFAULT_PEER = "default", PEER_ENV = "SIDECAR_PEER", SOFT_SYNC_ENV = "SIDECAR_SYNC_SOFT", LOCAL_SYNC_ENV = "SIDECAR_SYNC_LOCAL", DEFAULT_DEBOUNCE_SECONDS = 60, PEER_NAME, RESERVED_PEER_SUFFIXES, RESOLVE_MODES, DEFAULT_RESOLVE = "fork", REVIEW_MODES, DEFAULT_REVIEW_TIMEOUT_SECONDS, CONFIG_VERSION = 1, CONFIG_KEYS, failuresArePrivate = false, PUBLIC_REFS = "refs/sidecar-public/";
|
|
3816
3960
|
var init_config = __esm(() => {
|
|
3817
3961
|
init_dist();
|
|
3818
3962
|
init_util();
|
|
@@ -4200,6 +4344,37 @@ function syncLockIsStale(lockDir) {
|
|
|
4200
4344
|
return error.code !== "EPERM";
|
|
4201
4345
|
}
|
|
4202
4346
|
}
|
|
4347
|
+
function reviewDueDir() {
|
|
4348
|
+
return path6.join(sidecarStateDir(), "review-due");
|
|
4349
|
+
}
|
|
4350
|
+
function reviewDuePath(configPath) {
|
|
4351
|
+
const key = `${realpathOr(path6.dirname(configPath))}\x00${path6.basename(configPath)}`;
|
|
4352
|
+
return path6.join(reviewDueDir(), `${crypto3.createHash("sha256").update(key).digest("hex").slice(0, 16)}.json`);
|
|
4353
|
+
}
|
|
4354
|
+
function writeReviewDue(configPath, dueAt) {
|
|
4355
|
+
const filePath = reviewDuePath(configPath);
|
|
4356
|
+
fs6.mkdirSync(path6.dirname(filePath), { recursive: true });
|
|
4357
|
+
const temporary = `${filePath}.${crypto3.randomUUID()}.tmp`;
|
|
4358
|
+
try {
|
|
4359
|
+
fs6.writeFileSync(temporary, `${JSON.stringify({ configPath, dueAt: new Date(dueAt).toISOString() })}
|
|
4360
|
+
`, "utf8");
|
|
4361
|
+
fs6.renameSync(temporary, filePath);
|
|
4362
|
+
} finally {
|
|
4363
|
+
fs6.rmSync(temporary, { force: true });
|
|
4364
|
+
}
|
|
4365
|
+
}
|
|
4366
|
+
function clearReviewDue(configPath) {
|
|
4367
|
+
fs6.rmSync(reviewDuePath(configPath), { force: true });
|
|
4368
|
+
}
|
|
4369
|
+
function readReviewDue(configPath) {
|
|
4370
|
+
try {
|
|
4371
|
+
const { dueAt } = JSON.parse(fs6.readFileSync(reviewDuePath(configPath), "utf8"));
|
|
4372
|
+
const time = typeof dueAt === "string" ? Date.parse(dueAt) : Number.NaN;
|
|
4373
|
+
return Number.isFinite(time) ? time : undefined;
|
|
4374
|
+
} catch {
|
|
4375
|
+
return;
|
|
4376
|
+
}
|
|
4377
|
+
}
|
|
4203
4378
|
var STATE_DIR_ENV = "SIDECAR_STATE_DIR", DEFAULT_SETTINGS, LOG_ROTATE_BYTES, SYNC_LOCK_WAIT_MS;
|
|
4204
4379
|
var init_state = __esm(() => {
|
|
4205
4380
|
init_util();
|
|
@@ -4692,7 +4867,7 @@ function createReview(options) {
|
|
|
4692
4867
|
if (guidelines)
|
|
4693
4868
|
writePrivateFile(path8.join(dir, "guidelines.md"), `${guidelines}
|
|
4694
4869
|
`);
|
|
4695
|
-
writePrivateFile(path8.join(dir, "instructions.md"), reviewInstructions(record, config, guidelines));
|
|
4870
|
+
writePrivateFile(path8.join(dir, "instructions.md"), reviewInstructions(record, config, guidelines, addedPlaceholderLines(diff)));
|
|
4696
4871
|
writePrivateJson(path8.join(dir, "review.json"), record);
|
|
4697
4872
|
logSidecarEvent("review-open", { id, root, peer: config.peer, files: files.length });
|
|
4698
4873
|
pruneClosedReviews(sidecarPath);
|
|
@@ -4751,6 +4926,102 @@ function pruneOrphanedReviews() {
|
|
|
4751
4926
|
fs8.rmSync(review.dir, { recursive: true, force: true });
|
|
4752
4927
|
}
|
|
4753
4928
|
}
|
|
4929
|
+
function addedPlaceholderLines(patch) {
|
|
4930
|
+
const found = [];
|
|
4931
|
+
let file;
|
|
4932
|
+
let oldLeft = 0;
|
|
4933
|
+
let newLeft = 0;
|
|
4934
|
+
let newLine = 0;
|
|
4935
|
+
for (const raw of patch.split(`
|
|
4936
|
+
`)) {
|
|
4937
|
+
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
4938
|
+
if (oldLeft > 0 || newLeft > 0) {
|
|
4939
|
+
if (line.startsWith("+")) {
|
|
4940
|
+
if (file && hasRedactionPlaceholder(line))
|
|
4941
|
+
found.push({ file, line: newLine, text: line.slice(1) });
|
|
4942
|
+
newLeft -= 1;
|
|
4943
|
+
newLine += 1;
|
|
4944
|
+
} else if (line.startsWith("-")) {
|
|
4945
|
+
oldLeft -= 1;
|
|
4946
|
+
} else if (!line.startsWith("\\")) {
|
|
4947
|
+
oldLeft -= 1;
|
|
4948
|
+
newLeft -= 1;
|
|
4949
|
+
newLine += 1;
|
|
4950
|
+
}
|
|
4951
|
+
continue;
|
|
4952
|
+
}
|
|
4953
|
+
if (line.startsWith("diff --git ")) {
|
|
4954
|
+
file = undefined;
|
|
4955
|
+
} else if (line.startsWith("+++ ")) {
|
|
4956
|
+
file = patchHeaderPath(line.slice(4));
|
|
4957
|
+
} else {
|
|
4958
|
+
const hunk = /^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
|
4959
|
+
if (hunk) {
|
|
4960
|
+
oldLeft = Number(hunk[1] ?? 1);
|
|
4961
|
+
newLine = Number(hunk[2]);
|
|
4962
|
+
newLeft = Number(hunk[3] ?? 1);
|
|
4963
|
+
}
|
|
4964
|
+
}
|
|
4965
|
+
}
|
|
4966
|
+
return found;
|
|
4967
|
+
}
|
|
4968
|
+
function patchHeaderPath(header) {
|
|
4969
|
+
const target = header.endsWith("\t") ? header.slice(0, -1) : header;
|
|
4970
|
+
if (target === "/dev/null")
|
|
4971
|
+
return;
|
|
4972
|
+
const name = target.length > 1 && target.startsWith('"') && target.endsWith('"') ? unquoteGitPath(target.slice(1, -1)) : target;
|
|
4973
|
+
return name.replace(/^b\//, "");
|
|
4974
|
+
}
|
|
4975
|
+
function unquoteGitPath(quoted) {
|
|
4976
|
+
const parts = [];
|
|
4977
|
+
let literal = "";
|
|
4978
|
+
for (let index = 0;index < quoted.length; index += 1) {
|
|
4979
|
+
if (quoted[index] !== "\\" || index + 1 === quoted.length) {
|
|
4980
|
+
literal += quoted[index];
|
|
4981
|
+
continue;
|
|
4982
|
+
}
|
|
4983
|
+
parts.push(Buffer.from(literal, "utf8"));
|
|
4984
|
+
literal = "";
|
|
4985
|
+
const octal = /^[0-3][0-7]{2}/.exec(quoted.slice(index + 1))?.[0];
|
|
4986
|
+
if (octal) {
|
|
4987
|
+
parts.push(Buffer.from([Number.parseInt(octal, 8)]));
|
|
4988
|
+
index += 3;
|
|
4989
|
+
} else {
|
|
4990
|
+
const next = quoted[index + 1];
|
|
4991
|
+
parts.push(GIT_PATH_ESCAPES[next] === undefined ? Buffer.from(next, "utf8") : Buffer.from([GIT_PATH_ESCAPES[next]]));
|
|
4992
|
+
index += 1;
|
|
4993
|
+
}
|
|
4994
|
+
}
|
|
4995
|
+
parts.push(Buffer.from(literal, "utf8"));
|
|
4996
|
+
return Buffer.concat(parts).toString("utf8");
|
|
4997
|
+
}
|
|
4998
|
+
function placeholderSection(lines) {
|
|
4999
|
+
if (!lines.length)
|
|
5000
|
+
return "";
|
|
5001
|
+
const shown = lines.slice(0, PLACEHOLDER_LINES_SHOWN);
|
|
5002
|
+
const more = lines.length - shown.length;
|
|
5003
|
+
return `
|
|
5004
|
+
## Redaction placeholders
|
|
5005
|
+
|
|
5006
|
+
These lines in the change carry a placeholder (numbered as in the new file).
|
|
5007
|
+
For each one, check that the placeholder stands where a credential's value
|
|
5008
|
+
was, and only there. Reject, naming the line, if it replaced something that is
|
|
5009
|
+
not a credential — part of a shell expansion such as \`\${PWD:t}\`, a path, a
|
|
5010
|
+
command, ordinary words — or if the text around it no longer holds together
|
|
5011
|
+
(a script that no longer parses, a broken config file). That holds even when
|
|
5012
|
+
it did replace a real credential: the other machines still receive a broken
|
|
5013
|
+
file, and the credential belongs outside the synced file. Some placeholders
|
|
5014
|
+
may have been typed by hand or come from content already published; judge
|
|
5015
|
+
them the same way.
|
|
5016
|
+
|
|
5017
|
+
${shown.map((entry) => `- ${entry.file}:${entry.line}
|
|
5018
|
+
|
|
5019
|
+
${entry.text}
|
|
5020
|
+
`).join(`
|
|
5021
|
+
`)}${more ? `
|
|
5022
|
+
…and ${more} more; find them in \`diff.patch\`.
|
|
5023
|
+
` : ""}`;
|
|
5024
|
+
}
|
|
4754
5025
|
function opaqueSection(record) {
|
|
4755
5026
|
const opaque = record.files.filter((file) => file.artifact);
|
|
4756
5027
|
if (!opaque.length)
|
|
@@ -4765,7 +5036,7 @@ ${opaque.map((file) => `- \`${file.artifact}\` — ${file.path}`).join(`
|
|
|
4765
5036
|
`)}
|
|
4766
5037
|
`;
|
|
4767
5038
|
}
|
|
4768
|
-
function reviewInstructions(record, config, guidelines) {
|
|
5039
|
+
function reviewInstructions(record, config, guidelines, placeholders) {
|
|
4769
5040
|
return `# Sidecar publish review ${record.id}
|
|
4770
5041
|
|
|
4771
5042
|
A sidecar is a scratchpad repository that syncs automatically to a shared Git
|
|
@@ -4773,8 +5044,9 @@ remote (${redactText(config.remote)}). This machine wants to publish the change
|
|
|
4773
5044
|
\`diff.patch\`. Everyone with access to that remote can read what you approve,
|
|
4774
5045
|
and it stays in the remote's history.
|
|
4775
5046
|
|
|
4776
|
-
Decide whether anything in the change should not leave this machine
|
|
4777
|
-
not a code review: ignore quality,
|
|
5047
|
+
Decide whether anything in the change should not leave this machine, and
|
|
5048
|
+
whether redaction damaged it. This is not a code review: ignore the quality,
|
|
5049
|
+
style, and correctness of what the author wrote.
|
|
4778
5050
|
|
|
4779
5051
|
Secret redaction has already run. Values it caught appear as placeholders such
|
|
4780
5052
|
as \`<API_KEY>\`, \`<TOKEN>\`, or \`<SECRET>\`; they are not leaks. Look for what
|
|
@@ -4785,6 +5057,11 @@ pattern matching misses:
|
|
|
4785
5057
|
- confidential material: customer names and data, unreleased plans, internal-only details
|
|
4786
5058
|
- anything the repository's guidelines below rule out
|
|
4787
5059
|
|
|
5060
|
+
Pattern matching also has false positives, and they are not harmless. A
|
|
5061
|
+
placeholder replaces the original text in the published file; every other
|
|
5062
|
+
machine that syncs this repository gets the placeholder in its own copy, while
|
|
5063
|
+
this machine keeps the original, so the author never sees the damage.
|
|
5064
|
+
${placeholderSection(placeholders)}
|
|
4788
5065
|
## Files
|
|
4789
5066
|
|
|
4790
5067
|
All in this directory (\`$SIDECAR_REVIEW_DIR\`):
|
|
@@ -4882,7 +5159,7 @@ function decideReview(review, verdict, reason) {
|
|
|
4882
5159
|
review.state = verdict;
|
|
4883
5160
|
logSidecarEvent("review-decision", { id: review.record.id, verdict });
|
|
4884
5161
|
}
|
|
4885
|
-
var REVIEW_SCHEMA = 1, KEEP_CLOSED_REVIEWS = 10, ID_PATTERN, LAUNCH_GRACE_SECONDS = 60, PACKET_DIFF_OPTIONS;
|
|
5162
|
+
var REVIEW_SCHEMA = 1, KEEP_CLOSED_REVIEWS = 10, ID_PATTERN, LAUNCH_GRACE_SECONDS = 60, PACKET_DIFF_OPTIONS, GIT_PATH_ESCAPES, PLACEHOLDER_LINES_SHOWN = 200;
|
|
4886
5163
|
var init_review = __esm(() => {
|
|
4887
5164
|
init_util();
|
|
4888
5165
|
init_git();
|
|
@@ -4892,6 +5169,7 @@ var init_review = __esm(() => {
|
|
|
4892
5169
|
init_redaction();
|
|
4893
5170
|
ID_PATTERN = /^[0-9a-f]+$/;
|
|
4894
5171
|
PACKET_DIFF_OPTIONS = ["-r", "--no-renames", "--ignore-submodules=none"];
|
|
5172
|
+
GIT_PATH_ESCAPES = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, "\\": 92 };
|
|
4895
5173
|
});
|
|
4896
5174
|
|
|
4897
5175
|
// src/sync.ts
|
|
@@ -4924,8 +5202,9 @@ function syncProject(root, config, options) {
|
|
|
4924
5202
|
}
|
|
4925
5203
|
if (!options.remote)
|
|
4926
5204
|
return {};
|
|
4927
|
-
if (reviewRequired(config))
|
|
4928
|
-
return publishReviewed(root, sidecarPath, config, inbox, siblings, stage);
|
|
5205
|
+
if (reviewRequired(config)) {
|
|
5206
|
+
return publishReviewed(root, sidecarPath, config, inbox, siblings, stage, options.reviewFloor ?? false);
|
|
5207
|
+
}
|
|
4929
5208
|
stage("push-inbox");
|
|
4930
5209
|
syncBranchBeforePush(sidecarPath, inbox, config);
|
|
4931
5210
|
pushBranch(sidecarPath, inbox);
|
|
@@ -5025,7 +5304,7 @@ function publicTip(repo, branch) {
|
|
|
5025
5304
|
function isPublic(repo, commit) {
|
|
5026
5305
|
return Boolean(git(repo, ["for-each-ref", "--contains", commit, "--format=%(refname)", PUBLIC_REFS]).stdout.trim());
|
|
5027
5306
|
}
|
|
5028
|
-
function publishReviewed(root, sidecarPath, config, inbox, siblings, stage) {
|
|
5307
|
+
function publishReviewed(root, sidecarPath, config, inbox, siblings, stage, reviewFloor) {
|
|
5029
5308
|
stage("fetch");
|
|
5030
5309
|
withPublicGit(sidecarPath, () => {
|
|
5031
5310
|
fetchPublic(sidecarPath);
|
|
@@ -5043,12 +5322,25 @@ function publishReviewed(root, sidecarPath, config, inbox, siblings, stage) {
|
|
|
5043
5322
|
stage("settle-remote");
|
|
5044
5323
|
settleCheckouts(sidecarPath, config, inbox, siblings);
|
|
5045
5324
|
stage("review");
|
|
5046
|
-
const gate = withPublicGit(sidecarPath, () => gateUnpublished(root, sidecarPath, config));
|
|
5047
|
-
if (gate
|
|
5325
|
+
const gate = withPublicGit(sidecarPath, () => gateUnpublished(root, sidecarPath, config, reviewFloor));
|
|
5326
|
+
if (!gate)
|
|
5327
|
+
return {};
|
|
5328
|
+
if ("due" in gate)
|
|
5329
|
+
return { reviewDue: gate.due };
|
|
5330
|
+
if (gate.launch)
|
|
5048
5331
|
launchReviewer(gate.review);
|
|
5049
|
-
return
|
|
5332
|
+
return { held: { review: gate.review, attention: reviewAttention(gate.review) } };
|
|
5333
|
+
}
|
|
5334
|
+
function gateUnpublished(root, sidecarPath, config, floor) {
|
|
5335
|
+
const configPath = peerConfigPath(root, config.peer);
|
|
5336
|
+
const result = gateReview(root, sidecarPath, config, floor);
|
|
5337
|
+
if (result && "due" in result)
|
|
5338
|
+
writeReviewDue(configPath, result.due);
|
|
5339
|
+
else
|
|
5340
|
+
clearReviewDue(configPath);
|
|
5341
|
+
return result;
|
|
5050
5342
|
}
|
|
5051
|
-
function
|
|
5343
|
+
function gateReview(root, sidecarPath, config, floor) {
|
|
5052
5344
|
const head = git(sidecarPath, ["rev-parse", `refs/heads/${config.branch}`]).stdout.trim();
|
|
5053
5345
|
const headTree = treeOf(sidecarPath, head);
|
|
5054
5346
|
const publicMain = publicTip(sidecarPath, config.branch);
|
|
@@ -5070,10 +5362,25 @@ function gateUnpublished(root, sidecarPath, config) {
|
|
|
5070
5362
|
return { review: open, launch: false };
|
|
5071
5363
|
markSuperseded(open);
|
|
5072
5364
|
}
|
|
5365
|
+
if (floor) {
|
|
5366
|
+
const due = reviewFloorEndsAt(sidecarPath, config);
|
|
5367
|
+
if (due > Date.now()) {
|
|
5368
|
+
console.log(`the next review opens in ${Math.ceil((due - Date.now()) / 1000)}s; reviews open at most once per debounce`);
|
|
5369
|
+
return { due };
|
|
5370
|
+
}
|
|
5371
|
+
}
|
|
5073
5372
|
const review = createReview({ root, config, sidecarPath, base, head });
|
|
5074
5373
|
console.log(`opened review ${paint("brand", review.record.id)} for ${review.record.files.length} changed file(s)`);
|
|
5075
5374
|
return { review, launch: hasReviewer };
|
|
5076
5375
|
}
|
|
5376
|
+
function reviewFloorEndsAt(sidecarPath, config) {
|
|
5377
|
+
const previous = listReviews(sidecarPath)[0];
|
|
5378
|
+
if (!previous)
|
|
5379
|
+
return 0;
|
|
5380
|
+
const { createdAt, launchedAt } = previous.record;
|
|
5381
|
+
const started = Math.max(Date.parse(createdAt), launchedAt ? Date.parse(launchedAt) : 0);
|
|
5382
|
+
return started + (config.debounce ?? DEFAULT_DEBOUNCE_SECONDS) * 1000;
|
|
5383
|
+
}
|
|
5077
5384
|
function publishApprovedReview(sidecarPath, config, inbox, review) {
|
|
5078
5385
|
const { record } = review;
|
|
5079
5386
|
const target = record.publishing ?? { inbox, commit: cutPublishCommit(sidecarPath, config, inbox, record) };
|
|
@@ -5333,7 +5640,6 @@ function readFleetHealth(sidecarPath) {
|
|
|
5333
5640
|
return entries.sort((left, right) => rank[left.state] - rank[right.state] || left.record.machine.localeCompare(right.record.machine) || left.branch.localeCompare(right.branch));
|
|
5334
5641
|
}
|
|
5335
5642
|
function mergeInboxBranches(sidecarPath, config, options) {
|
|
5336
|
-
ensureClean(sidecarPath);
|
|
5337
5643
|
ensureCommitIdentity(sidecarPath);
|
|
5338
5644
|
if (options.remote)
|
|
5339
5645
|
fetch(sidecarPath, false);
|
|
@@ -5353,9 +5659,11 @@ function mergeInboxBranches(sidecarPath, config, options) {
|
|
|
5353
5659
|
}
|
|
5354
5660
|
}
|
|
5355
5661
|
if (!hasAnyCommit(sidecarPath)) {
|
|
5662
|
+
ensureClean(sidecarPath);
|
|
5356
5663
|
return mergeInboxBranchesAt(sidecarPath, config, options);
|
|
5357
5664
|
}
|
|
5358
5665
|
if (git(sidecarPath, ["branch", "--show-current"]).stdout.trim() === config.branch) {
|
|
5666
|
+
ensureClean(sidecarPath);
|
|
5359
5667
|
ensureInboxBranch(sidecarPath, config, expandInbox(config, sidecarPath));
|
|
5360
5668
|
}
|
|
5361
5669
|
return withScratchWorktree(sidecarPath, config, "HEAD", (worktree) => mergeInboxBranchesAt(worktree, config, options));
|
|
@@ -5915,7 +6223,7 @@ function syncBranchBeforePush(repo, branch, config) {
|
|
|
5915
6223
|
if (isAncestor(repo, remoteBranch, "HEAD"))
|
|
5916
6224
|
return;
|
|
5917
6225
|
if (isDirty(repo)) {
|
|
5918
|
-
throw new SidecarError(`${remoteBranch} has commits not in local ${branch}, and the sidecar checkout has uncommitted changes
|
|
6226
|
+
throw new SidecarError(`${remoteBranch} has commits not in local ${branch}, and the sidecar checkout has uncommitted changes`, { reason: "dirty-checkout" });
|
|
5919
6227
|
}
|
|
5920
6228
|
if (isAncestor(repo, "HEAD", remoteBranch)) {
|
|
5921
6229
|
git(repo, ["merge", "--ff-only", remoteBranch]);
|
|
@@ -5926,12 +6234,19 @@ function syncBranchBeforePush(repo, branch, config) {
|
|
|
5926
6234
|
function refreshInboxFromMain(repo, config, inbox) {
|
|
5927
6235
|
if (!branchExists(repo, inbox) || !branchExists(repo, config.branch))
|
|
5928
6236
|
return;
|
|
5929
|
-
|
|
6237
|
+
if (isDirty(repo)) {
|
|
6238
|
+
logSidecarEvent("settle-deferred", { sidecarPath: repo });
|
|
6239
|
+
return;
|
|
6240
|
+
}
|
|
5930
6241
|
git(repo, ["switch", inbox]);
|
|
5931
6242
|
const result = git(repo, ["merge", "--ff-only", config.branch], { check: false });
|
|
5932
|
-
if (result.status
|
|
5933
|
-
|
|
6243
|
+
if (result.status === 0)
|
|
6244
|
+
return;
|
|
6245
|
+
if (isDirty(repo)) {
|
|
6246
|
+
logSidecarEvent("settle-deferred", { sidecarPath: repo });
|
|
6247
|
+
return;
|
|
5934
6248
|
}
|
|
6249
|
+
throw new SidecarError(result.stderr.trim() || `could not fast-forward ${inbox} to ${config.branch}`);
|
|
5935
6250
|
}
|
|
5936
6251
|
function pushBranch(repo, branch) {
|
|
5937
6252
|
git(repo, ["push", "--no-follow-tags", "-u", "origin", `HEAD:refs/heads/${branch}`]);
|
|
@@ -6203,7 +6518,7 @@ function pendingInboxBranches(repo, config) {
|
|
|
6203
6518
|
});
|
|
6204
6519
|
return [...local, ...remote].sort();
|
|
6205
6520
|
}
|
|
6206
|
-
var REVIEW_POLICY_KEY = "sidecar.review", WRITTEN_TRAILER = "written:", WRITTEN_TRAILER_LIMIT = 500, REDACTION_FILTER_NAME = "sidecar-redact", REDACTION_FILTER_REVISION = "
|
|
6521
|
+
var REVIEW_POLICY_KEY = "sidecar.review", WRITTEN_TRAILER = "written:", WRITTEN_TRAILER_LIMIT = 500, REDACTION_FILTER_NAME = "sidecar-redact", REDACTION_FILTER_REVISION = "3", REDACTION_POLICY_FILE = "sidecar-redaction-policy";
|
|
6207
6522
|
var init_sync = __esm(() => {
|
|
6208
6523
|
init_color();
|
|
6209
6524
|
init_util();
|
|
@@ -8229,6 +8544,7 @@ __export(exports_daemon, {
|
|
|
8229
8544
|
selectWatchTargets: () => selectWatchTargets,
|
|
8230
8545
|
scheduleFor: () => scheduleFor,
|
|
8231
8546
|
runDaemonLoop: () => runDaemonLoop,
|
|
8547
|
+
reviewDueAt: () => reviewDueAt,
|
|
8232
8548
|
refreshWatchers: () => refreshWatchers,
|
|
8233
8549
|
compileGitignoreMatcher: () => compileGitignoreMatcher,
|
|
8234
8550
|
checkAndInstallUpdate: () => checkAndInstallUpdate,
|
|
@@ -8299,6 +8615,7 @@ async function runDaemonLoop(options) {
|
|
|
8299
8615
|
await acquireDaemonPid();
|
|
8300
8616
|
installShutdownHandlers();
|
|
8301
8617
|
await watchRegistry(state);
|
|
8618
|
+
await watchReviewDue(state);
|
|
8302
8619
|
const bootVersion = packageVersion();
|
|
8303
8620
|
while (true) {
|
|
8304
8621
|
maybeAdoptNewerInstall(state, bootVersion);
|
|
@@ -8411,10 +8728,11 @@ async function syncInstance(state, key, trigger, options = {}) {
|
|
|
8411
8728
|
state.syncingFamilies.add(family);
|
|
8412
8729
|
if (!options.localOnly) {
|
|
8413
8730
|
state.lastRemoteSyncAt.set(key, Date.now());
|
|
8414
|
-
clearTimeout(state.remoteTimers.get(key));
|
|
8731
|
+
clearTimeout(state.remoteTimers.get(key)?.timer);
|
|
8415
8732
|
state.remoteTimers.delete(key);
|
|
8416
8733
|
}
|
|
8417
8734
|
let succeeded = false;
|
|
8735
|
+
let dirtyCheckout = false;
|
|
8418
8736
|
try {
|
|
8419
8737
|
const localCli = localSidecarCliPath(root);
|
|
8420
8738
|
const cli = localCli ?? currentCliPath();
|
|
@@ -8431,19 +8749,27 @@ async function syncInstance(state, key, trigger, options = {}) {
|
|
|
8431
8749
|
},
|
|
8432
8750
|
timeoutMs: SYNC_TIMEOUT_MS
|
|
8433
8751
|
});
|
|
8752
|
+
dirtyCheckout = !result.timedOut && result.status === DIRTY_CHECKOUT_EXIT;
|
|
8434
8753
|
if (result.status === 0) {
|
|
8435
|
-
|
|
8436
|
-
|
|
8754
|
+
if (!options.localOnly) {
|
|
8755
|
+
state.failures.delete(key);
|
|
8756
|
+
state.skipUntilCycle.delete(key);
|
|
8757
|
+
}
|
|
8437
8758
|
logSidecarEvent("daemon-sync", { ...peerFields(key), trigger, local: Boolean(localCli) });
|
|
8438
8759
|
succeeded = true;
|
|
8439
8760
|
} else {
|
|
8440
|
-
|
|
8441
|
-
|
|
8442
|
-
|
|
8761
|
+
if (!dirtyCheckout) {
|
|
8762
|
+
const failures = (state.failures.get(key) ?? 0) + 1;
|
|
8763
|
+
state.failures.set(key, failures);
|
|
8764
|
+
state.skipUntilCycle.set(key, state.cycleCount + Math.min(2 ** (failures - 1), MAX_BACKOFF_CYCLES));
|
|
8765
|
+
}
|
|
8766
|
+
if (!options.localOnly)
|
|
8767
|
+
state.lastRemoteSyncAt.set(key, Date.now());
|
|
8443
8768
|
logSidecarEvent("failure", {
|
|
8444
8769
|
command: "daemon",
|
|
8445
8770
|
...peerFields(key),
|
|
8446
8771
|
trigger,
|
|
8772
|
+
reason: result.timedOut ? "timeout" : dirtyCheckout ? "dirty-checkout" : `exit-${result.status}`,
|
|
8447
8773
|
message: result.timedOut ? "sync timed out" : !outputIsPrivate(key) && result.output.trim().slice(-500) || `sync exited ${result.status}`
|
|
8448
8774
|
});
|
|
8449
8775
|
}
|
|
@@ -8453,12 +8779,24 @@ async function syncInstance(state, key, trigger, options = {}) {
|
|
|
8453
8779
|
if (options.localOnly)
|
|
8454
8780
|
armRemoteSync(state, key);
|
|
8455
8781
|
}
|
|
8456
|
-
if (succeeded)
|
|
8782
|
+
if (succeeded) {
|
|
8783
|
+
if (!options.localOnly)
|
|
8784
|
+
armReviewDue(state, key, "after-sync");
|
|
8457
8785
|
await followUpTrailingSync(state, key);
|
|
8458
|
-
else
|
|
8459
|
-
state.
|
|
8786
|
+
} else {
|
|
8787
|
+
followUpFailedSync(state, key, { dirtyCheckout, remote: !options.localOnly });
|
|
8788
|
+
}
|
|
8460
8789
|
return succeeded;
|
|
8461
8790
|
}
|
|
8791
|
+
function followUpFailedSync(state, key, failure) {
|
|
8792
|
+
if (failure.dirtyCheckout) {
|
|
8793
|
+
state.trailingPending.add(key);
|
|
8794
|
+
if (failure.remote)
|
|
8795
|
+
armRemoteSync(state, key);
|
|
8796
|
+
}
|
|
8797
|
+
if (state.trailingPending.has(key) && !state.pendingTimers.has(key))
|
|
8798
|
+
openTrailingWindow(state, key, SETTLE_WINDOW_MS);
|
|
8799
|
+
}
|
|
8462
8800
|
async function followUpTrailingSync(state, key) {
|
|
8463
8801
|
if (!state.trailingPending.delete(key))
|
|
8464
8802
|
return;
|
|
@@ -8474,7 +8812,9 @@ async function checkoutIsDirty(key) {
|
|
|
8474
8812
|
if (!sidecarPath || !fs14.existsSync(sidecarPath))
|
|
8475
8813
|
return false;
|
|
8476
8814
|
const result = await runChild("git", ["-C", sidecarPath, "status", "--porcelain"], { timeoutMs: 30000 });
|
|
8477
|
-
|
|
8815
|
+
if (result.status !== 0)
|
|
8816
|
+
return true;
|
|
8817
|
+
return Boolean(result.stdout.trim());
|
|
8478
8818
|
}
|
|
8479
8819
|
function localSidecarCliPath(root) {
|
|
8480
8820
|
if (!projectDependsOnSidecar(root))
|
|
@@ -8602,14 +8942,38 @@ async function beginWatchSync(state, key) {
|
|
|
8602
8942
|
state.trailingPending.add(key);
|
|
8603
8943
|
}
|
|
8604
8944
|
function armRemoteSync(state, key) {
|
|
8605
|
-
if (state.remoteTimers.has(key))
|
|
8606
|
-
return;
|
|
8607
8945
|
const elapsed = Date.now() - (state.lastRemoteSyncAt.get(key) ?? 0);
|
|
8946
|
+
armRemoteAt(state, key, Date.now() + Math.max(SETTLE_WINDOW_MS, scheduleFor(key, state.options).debounceSeconds * 1000 - elapsed), "remote-due");
|
|
8947
|
+
}
|
|
8948
|
+
function armRemoteAt(state, key, at, trigger) {
|
|
8949
|
+
const booked = state.remoteTimers.get(key);
|
|
8950
|
+
if (booked && booked.at <= at)
|
|
8951
|
+
return;
|
|
8952
|
+
clearTimeout(booked?.timer);
|
|
8608
8953
|
const timer = setTimeout(() => {
|
|
8609
8954
|
state.remoteTimers.delete(key);
|
|
8610
|
-
syncInstance(state, key,
|
|
8611
|
-
}, Math.max(
|
|
8612
|
-
state.remoteTimers.set(key, timer);
|
|
8955
|
+
syncInstance(state, key, trigger);
|
|
8956
|
+
}, Math.max(0, at - Date.now()));
|
|
8957
|
+
state.remoteTimers.set(key, { timer, at });
|
|
8958
|
+
}
|
|
8959
|
+
function armReviewDue(state, key, when) {
|
|
8960
|
+
const at = reviewDueAt(readReviewDue(key), when, Date.now(), scheduleFor(key, state.options).debounceSeconds * 1000, comingBack(state, key));
|
|
8961
|
+
if (at !== undefined)
|
|
8962
|
+
armRemoteAt(state, key, at, "review-due");
|
|
8963
|
+
}
|
|
8964
|
+
function comingBack(state, key) {
|
|
8965
|
+
return state.syncing.has(key) || state.remoteTimers.has(key) || state.cycleCount < (state.skipUntilCycle.get(key) ?? 0);
|
|
8966
|
+
}
|
|
8967
|
+
function reviewDueAt(due, when, now, debounceMs, comingBack2) {
|
|
8968
|
+
if (due === undefined)
|
|
8969
|
+
return;
|
|
8970
|
+
if (due > now)
|
|
8971
|
+
return due;
|
|
8972
|
+
if (when === "start")
|
|
8973
|
+
return now;
|
|
8974
|
+
if (when === "after-sync")
|
|
8975
|
+
return now + Math.max(SETTLE_WINDOW_MS, debounceMs);
|
|
8976
|
+
return comingBack2 ? undefined : now;
|
|
8613
8977
|
}
|
|
8614
8978
|
function remoteIsDue(state, key) {
|
|
8615
8979
|
const last = state.lastRemoteSyncAt.get(key) ?? 0;
|
|
@@ -8653,6 +9017,34 @@ async function watchRegistry(state) {
|
|
|
8653
9017
|
});
|
|
8654
9018
|
}
|
|
8655
9019
|
}
|
|
9020
|
+
async function watchReviewDue(state) {
|
|
9021
|
+
const scan = (when) => {
|
|
9022
|
+
for (const instance of readInstances())
|
|
9023
|
+
armReviewDue(state, instance.configPath, when);
|
|
9024
|
+
};
|
|
9025
|
+
scan("start");
|
|
9026
|
+
const chokidar = await loadChokidar();
|
|
9027
|
+
if (!chokidar)
|
|
9028
|
+
return;
|
|
9029
|
+
try {
|
|
9030
|
+
fs14.mkdirSync(reviewDueDir(), { recursive: true });
|
|
9031
|
+
const watcher = chokidar.watch(reviewDueDir(), { ignoreInitial: true, depth: 0 });
|
|
9032
|
+
let timer;
|
|
9033
|
+
watcher.on("all", () => {
|
|
9034
|
+
if (timer)
|
|
9035
|
+
return;
|
|
9036
|
+
timer = setTimeout(() => {
|
|
9037
|
+
timer = undefined;
|
|
9038
|
+
scan("change");
|
|
9039
|
+
}, 500);
|
|
9040
|
+
});
|
|
9041
|
+
} catch (error) {
|
|
9042
|
+
logSidecarEvent("failure", {
|
|
9043
|
+
command: "daemon",
|
|
9044
|
+
message: `could not watch review due times: ${error instanceof Error ? error.message : String(error)}`
|
|
9045
|
+
});
|
|
9046
|
+
}
|
|
9047
|
+
}
|
|
8656
9048
|
function compileGitignoreMatcher(lines) {
|
|
8657
9049
|
const rules = import_ignore.default({ ignorecase: false }).add(lines);
|
|
8658
9050
|
return (relativePath) => {
|
|
@@ -8978,7 +9370,7 @@ async function cmdDaemonRun(args) {
|
|
|
8978
9370
|
if (!Number.isFinite(intervalSeconds) || intervalSeconds <= 0) {
|
|
8979
9371
|
throw new SidecarError("--interval must be > 0");
|
|
8980
9372
|
}
|
|
8981
|
-
const debounceSeconds = Number(getValue(parsed, "--debounce",
|
|
9373
|
+
const debounceSeconds = Number(getValue(parsed, "--debounce", String(DEFAULT_DEBOUNCE_SECONDS)));
|
|
8982
9374
|
if (!Number.isFinite(debounceSeconds) || debounceSeconds < 0) {
|
|
8983
9375
|
throw new SidecarError("--debounce must be >= 0");
|
|
8984
9376
|
}
|
|
@@ -9102,7 +9494,8 @@ function cmdSync(args) {
|
|
|
9102
9494
|
lock: soft ? "skip" : "throw",
|
|
9103
9495
|
remote: !parsed.flags.has("--local") && process.env[LOCAL_SYNC_ENV] !== "1",
|
|
9104
9496
|
snapshot: !parsed.flags.has("--no-snapshot"),
|
|
9105
|
-
message: getValue(parsed, "--message", getValue(parsed, "-m", "")) || undefined
|
|
9497
|
+
message: getValue(parsed, "--message", getValue(parsed, "-m", "")) || undefined,
|
|
9498
|
+
reviewFloor: soft
|
|
9106
9499
|
};
|
|
9107
9500
|
for (const peer of peers) {
|
|
9108
9501
|
announcePeer(peer, peers);
|
|
@@ -9113,12 +9506,13 @@ function cmdSync(args) {
|
|
|
9113
9506
|
} catch (error) {
|
|
9114
9507
|
if (peers.length === 1)
|
|
9115
9508
|
throw error;
|
|
9116
|
-
failed.push(peer.name);
|
|
9509
|
+
failed.push({ name: peer.name, error });
|
|
9117
9510
|
console.error(`sidecar: ${peer.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
9118
9511
|
}
|
|
9119
9512
|
}
|
|
9120
9513
|
if (failed.length) {
|
|
9121
|
-
|
|
9514
|
+
const dirty = failed.every(({ error }) => error instanceof SidecarError && error.reason === "dirty-checkout");
|
|
9515
|
+
throw new SidecarError(`${failed.length} of ${peers.length} peers failed to sync: ${failed.map(({ name }) => name).join(", ")}`, dirty ? { reason: "dirty-checkout" } : {});
|
|
9122
9516
|
}
|
|
9123
9517
|
return 0;
|
|
9124
9518
|
}
|
|
@@ -9135,6 +9529,7 @@ function syncPeer(peer, options) {
|
|
|
9135
9529
|
snapshot: options.snapshot,
|
|
9136
9530
|
remote,
|
|
9137
9531
|
message: options.message,
|
|
9532
|
+
reviewFloor: options.reviewFloor,
|
|
9138
9533
|
onStage: (name) => {
|
|
9139
9534
|
stage = name;
|
|
9140
9535
|
}
|
|
@@ -9142,6 +9537,8 @@ function syncPeer(peer, options) {
|
|
|
9142
9537
|
registerCurrentInstance(root, config, remote ? { event: "sync", lastSyncAt: nowIso() } : { event: "sync-local" });
|
|
9143
9538
|
});
|
|
9144
9539
|
} catch (error) {
|
|
9540
|
+
if (error instanceof SidecarError)
|
|
9541
|
+
error.stage ??= stage;
|
|
9145
9542
|
if (remote)
|
|
9146
9543
|
reportSyncHealth(root, config, {
|
|
9147
9544
|
status: "failed",
|
|
@@ -9156,8 +9553,9 @@ function syncPeer(peer, options) {
|
|
|
9156
9553
|
const { id } = held.review.record;
|
|
9157
9554
|
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
9555
|
}
|
|
9159
|
-
if (remote)
|
|
9160
|
-
reportSyncHealth(root, config, held ? { status: "ok", review: held.attention ? "attention" : "pending" } : { status: "ok" });
|
|
9556
|
+
if (remote) {
|
|
9557
|
+
reportSyncHealth(root, config, held ? { status: "ok", review: held.attention ? "attention" : "pending" } : outcome.reviewDue ? { status: "ok", review: "pending" } : { status: "ok" });
|
|
9558
|
+
}
|
|
9161
9559
|
if (!soft) {
|
|
9162
9560
|
const sidecarPath = resolveSidecarPath(root, config);
|
|
9163
9561
|
if (checkoutIsUnlinkedFromFamily(root, config, sidecarPath)) {
|
|
@@ -9425,7 +9823,20 @@ function decideCommand(verdict, args) {
|
|
|
9425
9823
|
return 0;
|
|
9426
9824
|
}
|
|
9427
9825
|
const peer = loadPeer(selected.record.root, selected.record.peer);
|
|
9428
|
-
|
|
9826
|
+
try {
|
|
9827
|
+
syncPeer(peer, { lock: "wait", remote: true, snapshot: true, reviewFloor: true });
|
|
9828
|
+
} catch (error) {
|
|
9829
|
+
if (readReview(id)?.state !== "published") {
|
|
9830
|
+
console.error(`sidecar: review ${id} is approved, but this sync could not confirm it published; the next sync finishes publishing it`);
|
|
9831
|
+
throw error;
|
|
9832
|
+
}
|
|
9833
|
+
logSidecarEvent("review-followup-failure", {
|
|
9834
|
+
id,
|
|
9835
|
+
...error instanceof SidecarError && error.reason ? { reason: error.reason } : {},
|
|
9836
|
+
...error instanceof SidecarError && error.stage ? { stage: error.stage } : {}
|
|
9837
|
+
});
|
|
9838
|
+
console.error(`sidecar: published review ${id}; the sync after it stopped early and the next sync finishes it: ${error instanceof Error ? error.message : String(error)}`);
|
|
9839
|
+
}
|
|
9429
9840
|
return 0;
|
|
9430
9841
|
}
|
|
9431
9842
|
function retryCommand(args) {
|
|
@@ -9680,12 +10091,14 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
9680
10091
|
if (command !== "redact" && command !== "deinit" && shouldUseGlobalRegistry()) {
|
|
9681
10092
|
logSidecarEvent("failure", {
|
|
9682
10093
|
command,
|
|
10094
|
+
...error instanceof SidecarError && error.reason ? { reason: error.reason } : {},
|
|
10095
|
+
...error instanceof SidecarError && error.stage ? { stage: error.stage } : {},
|
|
9683
10096
|
...privateFailures() ? {} : { message: error instanceof Error ? error.message : String(error) }
|
|
9684
10097
|
});
|
|
9685
10098
|
}
|
|
9686
10099
|
if (error instanceof SidecarError) {
|
|
9687
10100
|
console.error(`${paint("bad", "sidecar:", colorLevel(process.stderr))} ${error.message}`);
|
|
9688
|
-
return 1;
|
|
10101
|
+
return error.reason === "dirty-checkout" ? DIRTY_CHECKOUT_EXIT : 1;
|
|
9689
10102
|
}
|
|
9690
10103
|
if (error instanceof Error && error.name === "AbortError") {
|
|
9691
10104
|
console.error("sidecar: stopped");
|