@indigoai-us/hq-cloud 6.10.1 → 6.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +34 -1
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/share.test.js +52 -0
- package/dist/cli/share.test.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +217 -3
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +211 -1
- package/dist/cli/sync.test.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/share.test.ts +65 -0
- package/src/cli/share.ts +42 -1
- package/src/cli/sync.test.ts +241 -1
- package/src/cli/sync.ts +266 -1
- package/vitest.config.ts +22 -0
package/src/cli/sync.ts
CHANGED
|
@@ -539,6 +539,94 @@ async function reportNewFilesToNotify(
|
|
|
539
539
|
}
|
|
540
540
|
}
|
|
541
541
|
|
|
542
|
+
/** Timeout for the best-effort FILE_TOMBSTONE fetch (GET /v1/files/tombstones). */
|
|
543
|
+
const FETCH_TOMBSTONES_TIMEOUT_MS = 5000;
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* A FILE_TOMBSTONE as the pull planner needs it: the deleted key + when it was
|
|
547
|
+
* deleted. The `deletedAt` timestamp is the decisive precedence signal — a
|
|
548
|
+
* remote object newer than it is a genuine re-create (sync it), an object at or
|
|
549
|
+
* older than it is a stale resurrection of a deleted key (suppress it).
|
|
550
|
+
*/
|
|
551
|
+
interface CompanyTombstone {
|
|
552
|
+
deletedAt: string;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Fetch the company's FILE_TOMBSTONE rows from hq-pro (GET /v1/files/tombstones)
|
|
557
|
+
* and return them as a POSIX-keyed map the pull planner consults to avoid
|
|
558
|
+
* resurrecting an intentionally-deleted object (delete-resync). The endpoint is
|
|
559
|
+
* ACL-filtered server-side, so the map only ever contains keys this caller can
|
|
560
|
+
* read — exactly the keys that can appear in the (STS-scoped) remote LIST.
|
|
561
|
+
*
|
|
562
|
+
* Best-effort and bounded by a 5s timeout: a tombstone read that fails, times
|
|
563
|
+
* out, or returns non-2xx degrades to an EMPTY map — i.e. to the pre-fix
|
|
564
|
+
* behavior (no suppression). That is the safe failure direction: a missed
|
|
565
|
+
* tombstone re-pulls a deleted file (a known, recoverable annoyance), whereas a
|
|
566
|
+
* spurious tombstone would HIDE a file the user wants. The failure is logged
|
|
567
|
+
* (never silently swallowed) so a persistently-degraded read is visible.
|
|
568
|
+
*/
|
|
569
|
+
async function fetchCompanyTombstones(
|
|
570
|
+
vaultConfig: VaultServiceConfig,
|
|
571
|
+
companyUid: string,
|
|
572
|
+
): Promise<Map<string, CompanyTombstone>> {
|
|
573
|
+
const out = new Map<string, CompanyTombstone>();
|
|
574
|
+
try {
|
|
575
|
+
const token =
|
|
576
|
+
typeof vaultConfig.authToken === "function"
|
|
577
|
+
? await vaultConfig.authToken()
|
|
578
|
+
: vaultConfig.authToken;
|
|
579
|
+
const base = vaultConfig.apiUrl.replace(/\/+$/, "");
|
|
580
|
+
const url = `${base}/v1/files/tombstones?company=${encodeURIComponent(
|
|
581
|
+
companyUid,
|
|
582
|
+
)}`;
|
|
583
|
+
const controller = new AbortController();
|
|
584
|
+
const timer = setTimeout(
|
|
585
|
+
() => controller.abort(),
|
|
586
|
+
FETCH_TOMBSTONES_TIMEOUT_MS,
|
|
587
|
+
);
|
|
588
|
+
try {
|
|
589
|
+
const res = await fetch(url, {
|
|
590
|
+
method: "GET",
|
|
591
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
592
|
+
signal: controller.signal,
|
|
593
|
+
});
|
|
594
|
+
if (!res.ok) {
|
|
595
|
+
// Non-2xx is non-fatal: log and degrade to no-suppression. A 404 means
|
|
596
|
+
// the endpoint is not deployed yet (hq-pro release lag) — the pull
|
|
597
|
+
// proceeds with the legacy behavior until it lands.
|
|
598
|
+
console.error(
|
|
599
|
+
`[hq-sync] tombstone fetch returned ${res.status} (degrading to no-suppression)`,
|
|
600
|
+
);
|
|
601
|
+
return out;
|
|
602
|
+
}
|
|
603
|
+
const body = (await res.json()) as {
|
|
604
|
+
tombstones?: Array<{ key?: string; deletedAt?: string }>;
|
|
605
|
+
};
|
|
606
|
+
for (const t of body.tombstones ?? []) {
|
|
607
|
+
if (typeof t.key === "string" && typeof t.deletedAt === "string") {
|
|
608
|
+
out.set(toPosixKey(t.key), { deletedAt: t.deletedAt });
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
} finally {
|
|
612
|
+
clearTimeout(timer);
|
|
613
|
+
}
|
|
614
|
+
} catch (err) {
|
|
615
|
+
// Best-effort: a failed tombstone read must never break the pull. Log
|
|
616
|
+
// (policy: never silently swallow) and degrade to no-suppression.
|
|
617
|
+
try {
|
|
618
|
+
console.error(
|
|
619
|
+
`[hq-sync] tombstone fetch failed (non-fatal, degrading to no-suppression): ${
|
|
620
|
+
err instanceof Error ? err.message : String(err)
|
|
621
|
+
}`,
|
|
622
|
+
);
|
|
623
|
+
} catch {
|
|
624
|
+
// swallow — logging must never break sync
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return out;
|
|
628
|
+
}
|
|
629
|
+
|
|
542
630
|
/**
|
|
543
631
|
* Sync (pull) all allowed files from the entity vault.
|
|
544
632
|
*/
|
|
@@ -614,6 +702,14 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
614
702
|
// List all remote files (IAM session policy filters at the AWS layer)
|
|
615
703
|
const remoteFiles = await listRemoteFiles(ctx);
|
|
616
704
|
|
|
705
|
+
// Fetch the company's FILE_TOMBSTONE records so the planner can suppress
|
|
706
|
+
// resurrection of an intentionally-deleted object (delete-resync). Done in
|
|
707
|
+
// parallel intent with the LIST above conceptually, but kept serial here for
|
|
708
|
+
// a clean read of `ctx`; best-effort — a failed read degrades to an empty map
|
|
709
|
+
// (no suppression), preserving the pre-fix behavior. ctx.uid is the verified
|
|
710
|
+
// companyUid the tombstone rows are keyed under.
|
|
711
|
+
const tombstones = await fetchCompanyTombstones(vaultConfig, ctx.uid);
|
|
712
|
+
|
|
617
713
|
// Stage 1: classify every remote file against the journal + local disk.
|
|
618
714
|
// Hashing happens here (not in the transfer loop) so the plan event below
|
|
619
715
|
// carries an accurate denominator before any progress events fire.
|
|
@@ -626,6 +722,7 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
626
722
|
options.includeLocalCompanies === true,
|
|
627
723
|
options.teamSyncedSlugs ?? null,
|
|
628
724
|
currentPrefixSet,
|
|
725
|
+
tombstones,
|
|
629
726
|
);
|
|
630
727
|
|
|
631
728
|
emit({
|
|
@@ -637,7 +734,10 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
637
734
|
bytesToUpload: 0,
|
|
638
735
|
filesToSkip: plan.filesToSkip,
|
|
639
736
|
filesToConflict: plan.filesToConflict,
|
|
640
|
-
|
|
737
|
+
// Authoritative FILE_TOMBSTONE suppressions (delete-resync) are the only
|
|
738
|
+
// deletes known at plan time; the journal-vs-LIST tombstones are
|
|
739
|
+
// HEAD-verified later and surfaced via the final filesTombstoned count.
|
|
740
|
+
filesToDelete: plan.filesToTombstoneDelete,
|
|
641
741
|
});
|
|
642
742
|
|
|
643
743
|
// ── Scope-shrink cleanup (US-005) ─────────────────────────────────────────
|
|
@@ -835,6 +935,58 @@ export async function sync(options: SyncOptions): Promise<SyncResult> {
|
|
|
835
935
|
continue;
|
|
836
936
|
}
|
|
837
937
|
|
|
938
|
+
if (item.action === "tombstone-delete") {
|
|
939
|
+
// Authoritative FILE_TOMBSTONE delete (delete-resync): the remote object
|
|
940
|
+
// is present but a tombstone marks the key intentionally deleted and it is
|
|
941
|
+
// not a newer re-create. Delete any local copy and drop the journal entry
|
|
942
|
+
// so it stays gone — the mirror of the journal-vs-LIST tombstone executor
|
|
943
|
+
// below, but WITHOUT the HEAD-verify (the remote object is present by
|
|
944
|
+
// definition; the FILE_TOMBSTONE is the deletion authority). The planner
|
|
945
|
+
// already routed any divergent local copy to `conflict`, so a local file
|
|
946
|
+
// reaching here matches the deleted baseline and is safe to remove.
|
|
947
|
+
const tombstoneKey = item.remoteFile.key;
|
|
948
|
+
// Same Windows-backslash landmine guard as the journal-tombstone executor:
|
|
949
|
+
// a malformed key must never reach fs.unlinkSync (path.join collapses the
|
|
950
|
+
// backslashes onto a REAL POSIX file). Drop the poisoned journal entry
|
|
951
|
+
// without touching disk.
|
|
952
|
+
if (isMalformedVaultKey(tombstoneKey)) {
|
|
953
|
+
removeEntry(journal, tombstoneKey);
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
try {
|
|
957
|
+
const lstat = fs.lstatSync(item.localPath);
|
|
958
|
+
if (lstat.isSymbolicLink() || lstat.isFile()) {
|
|
959
|
+
fs.unlinkSync(item.localPath);
|
|
960
|
+
}
|
|
961
|
+
// A directory at the key: don't recursively rm-rf the operator's dir;
|
|
962
|
+
// just drop the journal entry (safe-by-default, same as the other path).
|
|
963
|
+
} catch (err: unknown) {
|
|
964
|
+
const code =
|
|
965
|
+
err && typeof err === "object" && "code" in err
|
|
966
|
+
? (err as { code?: string }).code
|
|
967
|
+
: undefined;
|
|
968
|
+
// ENOENT → local already absent (the common case: a fresh machine that
|
|
969
|
+
// never held the file, or a prior pull already removed it) → drop the
|
|
970
|
+
// journal entry and converge. Other errors (EACCES/EPERM/…) leave the
|
|
971
|
+
// file in place; surface and KEEP the journal entry so the next sync
|
|
972
|
+
// retries rather than forgetting the delete.
|
|
973
|
+
if (code !== "ENOENT") {
|
|
974
|
+
emit({
|
|
975
|
+
type: "error",
|
|
976
|
+
path: tombstoneKey,
|
|
977
|
+
message: `tombstone-suppress unlink failed: ${
|
|
978
|
+
err instanceof Error ? err.message : String(err)
|
|
979
|
+
}`,
|
|
980
|
+
});
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
removeEntry(journal, tombstoneKey);
|
|
985
|
+
filesTombstoned++;
|
|
986
|
+
emit({ type: "progress", path: tombstoneKey, bytes: 0 });
|
|
987
|
+
continue;
|
|
988
|
+
}
|
|
989
|
+
|
|
838
990
|
if (item.action === "download") {
|
|
839
991
|
downloadItems.push(item);
|
|
840
992
|
continue;
|
|
@@ -1481,6 +1633,37 @@ function hasRemoteChanged(
|
|
|
1481
1633
|
return remote.lastModified.getTime() > syncedAt;
|
|
1482
1634
|
}
|
|
1483
1635
|
|
|
1636
|
+
/**
|
|
1637
|
+
* Decide whether a remote object present in the LIST is a GENUINE RE-CREATE
|
|
1638
|
+
* written AFTER a FILE_TOMBSTONE — in which case the tombstone is stale and the
|
|
1639
|
+
* object must still sync (the tombstone is not permanent suppression). Returns
|
|
1640
|
+
* true to ALLOW the download (re-create), false to honor the tombstone.
|
|
1641
|
+
*
|
|
1642
|
+
* Decisive signal: the remote object's `lastModified` strictly newer than the
|
|
1643
|
+
* tombstone's `deletedAt`. A delete-marker + tombstone are written together at
|
|
1644
|
+
* delete time, so any object that post-dates the tombstone is a new write at
|
|
1645
|
+
* that key. An object at-or-before the tombstone is the deleted version (a
|
|
1646
|
+
* stale re-push or an un-propagated delete-marker) → honor the tombstone.
|
|
1647
|
+
*
|
|
1648
|
+
* Fail-OPEN (treat as re-create, allow download) when the comparison can't be
|
|
1649
|
+
* made — a malformed `deletedAt`, or no remote `lastModified`. Hiding a file the
|
|
1650
|
+
* user can see is worse than re-pulling one they deleted; the latter is
|
|
1651
|
+
* recoverable, the former looks like data loss.
|
|
1652
|
+
*/
|
|
1653
|
+
function isRemoteRecreateAfterTombstone(
|
|
1654
|
+
remote: { lastModified?: Date },
|
|
1655
|
+
tombstone: CompanyTombstone,
|
|
1656
|
+
): boolean {
|
|
1657
|
+
const deletedAtMs = Date.parse(tombstone.deletedAt);
|
|
1658
|
+
if (Number.isNaN(deletedAtMs)) return true; // malformed tombstone → don't suppress
|
|
1659
|
+
const remoteMs =
|
|
1660
|
+
remote.lastModified instanceof Date
|
|
1661
|
+
? remote.lastModified.getTime()
|
|
1662
|
+
: NaN;
|
|
1663
|
+
if (Number.isNaN(remoteMs)) return true; // no remote timestamp → don't suppress
|
|
1664
|
+
return remoteMs > deletedAtMs;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1484
1667
|
/**
|
|
1485
1668
|
* Stage-1 classification for a single remote object. Each remote file falls
|
|
1486
1669
|
* into exactly one bucket; the executor in `sync()` switches on `action` to
|
|
@@ -1501,6 +1684,14 @@ type PullPlanItem =
|
|
|
1501
1684
|
// the remote LIST (and accessible per STS) but deliberately not downloaded
|
|
1502
1685
|
// because the membership's sync scope doesn't cover them.
|
|
1503
1686
|
| { action: "skip-out-of-scope"; remoteFile: RemoteFile; localPath: string }
|
|
1687
|
+
// Remote key present in the LIST but carrying a FILE_TOMBSTONE that marks it
|
|
1688
|
+
// intentionally deleted (and the remote object is NOT a newer re-create). The
|
|
1689
|
+
// executor deletes any local copy and drops the journal entry — the
|
|
1690
|
+
// authoritative-delete arm of delete-resync. Unlike `PullPlan.tombstones`
|
|
1691
|
+
// (keys ABSENT from the LIST, HEAD-verified before delete), these are
|
|
1692
|
+
// suppressed purely on the FILE_TOMBSTONE authority and skip HEAD-verify (the
|
|
1693
|
+
// remote object is present by definition).
|
|
1694
|
+
| { action: "tombstone-delete"; remoteFile: RemoteFile; localPath: string }
|
|
1504
1695
|
| {
|
|
1505
1696
|
action: "conflict";
|
|
1506
1697
|
remoteFile: RemoteFile;
|
|
@@ -1544,6 +1735,12 @@ interface PullPlan {
|
|
|
1544
1735
|
* Carried on the plan so the executor can iterate without re-walking.
|
|
1545
1736
|
*/
|
|
1546
1737
|
tombstones: string[];
|
|
1738
|
+
/**
|
|
1739
|
+
* Count of `tombstone-delete` items — remote keys present in the LIST but
|
|
1740
|
+
* suppressed by a FILE_TOMBSTONE (delete-resync). Surfaced on the plan event's
|
|
1741
|
+
* `filesToDelete` axis; the per-item executor applies the local delete.
|
|
1742
|
+
*/
|
|
1743
|
+
filesToTombstoneDelete: number;
|
|
1547
1744
|
}
|
|
1548
1745
|
|
|
1549
1746
|
/**
|
|
@@ -1569,6 +1766,13 @@ function computePullPlan(
|
|
|
1569
1766
|
// `[""]` (the `all`-mode value) covers everything via `isCoveredByAny`, so
|
|
1570
1767
|
// the scope filter below becomes a no-op and legacy behavior is preserved.
|
|
1571
1768
|
prefixSet: string[],
|
|
1769
|
+
// FILE_TOMBSTONE records (POSIX-keyed) for the company — the durable
|
|
1770
|
+
// "this key was intentionally deleted" signal the planner consults before
|
|
1771
|
+
// re-downloading a key, so a deleted folder does not resync back in
|
|
1772
|
+
// (delete-resync). An empty map (the default / degraded-fetch case)
|
|
1773
|
+
// preserves the legacy behavior bit-for-bit. (Named `fileTombstones` to avoid
|
|
1774
|
+
// shadowing the local `tombstones` string[] — the journal-vs-LIST delete set.)
|
|
1775
|
+
fileTombstones: ReadonlyMap<string, CompanyTombstone> = new Map(),
|
|
1572
1776
|
): PullPlan {
|
|
1573
1777
|
const items: PullPlanItem[] = [];
|
|
1574
1778
|
|
|
@@ -1651,6 +1855,22 @@ function computePullPlan(
|
|
|
1651
1855
|
}
|
|
1652
1856
|
|
|
1653
1857
|
const journalEntry = getEntry(journal, remoteFile.key);
|
|
1858
|
+
|
|
1859
|
+
// ── FILE_TOMBSTONE consult (delete-resync) ───────────────────────────────
|
|
1860
|
+
// A remote object present in the LIST may be an intentionally-deleted key
|
|
1861
|
+
// (a peer re-pushed it, or its delete-marker hasn't propagated to this
|
|
1862
|
+
// caller's view). `tombstoneSuppresses` is true when a FILE_TOMBSTONE marks
|
|
1863
|
+
// this key deleted AND the remote object is NOT a newer re-create — i.e. the
|
|
1864
|
+
// object is the stale/deleted version and must NOT be downloaded. A genuine
|
|
1865
|
+
// re-create (object newer than the tombstone) leaves this false so the
|
|
1866
|
+
// normal download/merge path runs (the tombstone is not permanent
|
|
1867
|
+
// suppression). An empty tombstone map (the default / degraded-fetch case)
|
|
1868
|
+
// makes this always false — legacy behavior preserved bit-for-bit.
|
|
1869
|
+
const tombstone = fileTombstones.get(toPosixKey(remoteFile.key));
|
|
1870
|
+
const tombstoneSuppresses =
|
|
1871
|
+
tombstone !== undefined &&
|
|
1872
|
+
!isRemoteRecreateAfterTombstone(remoteFile, tombstone);
|
|
1873
|
+
|
|
1654
1874
|
// lstat (not existsSync/statSync) handles three cases the legacy
|
|
1655
1875
|
// checks got wrong for symlinks:
|
|
1656
1876
|
// 1. A valid symlink at localPath: existsSync returns true and the
|
|
@@ -1750,6 +1970,32 @@ function computePullPlan(
|
|
|
1750
1970
|
const remoteChanged =
|
|
1751
1971
|
!!journalEntry && hasRemoteChanged(remoteFile, journalEntry);
|
|
1752
1972
|
|
|
1973
|
+
// ── Authoritative delete reaches a machine that still holds the file ──
|
|
1974
|
+
// A FILE_TOMBSTONE marks this key deleted and the remote object is not a
|
|
1975
|
+
// newer re-create, but this machine still has a local copy. Honor the
|
|
1976
|
+
// delete — EXCEPT never destroy unsynced local work: if the local copy
|
|
1977
|
+
// diverges from the synced baseline (a post-tombstone edit, or an
|
|
1978
|
+
// untracked local file with no journal entry), surface a CONFLICT instead
|
|
1979
|
+
// of silently deleting (the safety guard the brief requires + the 3-way
|
|
1980
|
+
// merge invariant). A local copy that still matches the deleted baseline
|
|
1981
|
+
// is the stale version → delete it + drop the journal entry so it stays
|
|
1982
|
+
// gone on this machine.
|
|
1983
|
+
if (tombstoneSuppresses) {
|
|
1984
|
+
if (localChanged || !journalEntry) {
|
|
1985
|
+
items.push({
|
|
1986
|
+
action: "conflict",
|
|
1987
|
+
remoteFile,
|
|
1988
|
+
localPath,
|
|
1989
|
+
localHash,
|
|
1990
|
+
localMtime: localLstat!.mtime,
|
|
1991
|
+
localSize: isLocalSymlink ? 0 : localLstat!.size,
|
|
1992
|
+
});
|
|
1993
|
+
} else {
|
|
1994
|
+
items.push({ action: "tombstone-delete", remoteFile, localPath });
|
|
1995
|
+
}
|
|
1996
|
+
continue;
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1753
1999
|
// Mirror the original 3-way merge from the inline loop. Tested by
|
|
1754
2000
|
// `does NOT flag a pull conflict when only local changed since last
|
|
1755
2001
|
// sync` and `detects conflicts with local changes…`.
|
|
@@ -1827,6 +2073,19 @@ function computePullPlan(
|
|
|
1827
2073
|
// through to download.
|
|
1828
2074
|
}
|
|
1829
2075
|
|
|
2076
|
+
// Suppress the re-download of an intentionally-deleted key (delete-resync).
|
|
2077
|
+
// Reaches here only for the `!localExists` case — the `localExists` branch
|
|
2078
|
+
// above already handled (and `continue`d) any suppressing tombstone, so a
|
|
2079
|
+
// tombstone that survives to here means "remote present, local absent, not a
|
|
2080
|
+
// re-create": the classic resurrection ("remote present → I'm behind →
|
|
2081
|
+
// download") the planner must NOT do. Route to `tombstone-delete` so the
|
|
2082
|
+
// executor drops any stale journal entry and the key stays gone, instead of
|
|
2083
|
+
// pulling the deleted object back in.
|
|
2084
|
+
if (tombstoneSuppresses) {
|
|
2085
|
+
items.push({ action: "tombstone-delete", remoteFile, localPath });
|
|
2086
|
+
continue;
|
|
2087
|
+
}
|
|
2088
|
+
|
|
1830
2089
|
items.push({ action: "download", remoteFile, localPath, isNew: !localExists });
|
|
1831
2090
|
}
|
|
1832
2091
|
|
|
@@ -1836,6 +2095,7 @@ function computePullPlan(
|
|
|
1836
2095
|
let filesToConflict = 0;
|
|
1837
2096
|
let filesExcludedByPolicy = 0;
|
|
1838
2097
|
let filesOutOfScope = 0;
|
|
2098
|
+
let filesToTombstoneDelete = 0;
|
|
1839
2099
|
const newFiles: Array<{ path: string; bytes: number }> = [];
|
|
1840
2100
|
for (const item of items) {
|
|
1841
2101
|
if (item.action === "download") {
|
|
@@ -1846,6 +2106,10 @@ function computePullPlan(
|
|
|
1846
2106
|
}
|
|
1847
2107
|
} else if (item.action === "conflict") {
|
|
1848
2108
|
filesToConflict++;
|
|
2109
|
+
} else if (item.action === "tombstone-delete") {
|
|
2110
|
+
// Authoritative FILE_TOMBSTONE delete — its own axis so it never inflates
|
|
2111
|
+
// the "unchanged" tally. Surfaced via the plan event's filesToDelete.
|
|
2112
|
+
filesToTombstoneDelete++;
|
|
1849
2113
|
} else if (item.action === "skip-excluded-policy") {
|
|
1850
2114
|
filesExcludedByPolicy++;
|
|
1851
2115
|
// Excluded-policy items don't roll into filesToSkip — they're a
|
|
@@ -1972,6 +2236,7 @@ function computePullPlan(
|
|
|
1972
2236
|
newFilesCount: newFiles.length,
|
|
1973
2237
|
filesExcludedByPolicy,
|
|
1974
2238
|
filesOutOfScope,
|
|
2239
|
+
filesToTombstoneDelete,
|
|
1975
2240
|
tombstones,
|
|
1976
2241
|
};
|
|
1977
2242
|
}
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
|
|
3
|
+
// Vitest defaults to a 5_000ms per-test timeout and 10_000ms hook timeout.
|
|
4
|
+
// That is comfortable for the unit suite, but the `rescue-*.test.ts`
|
|
5
|
+
// integration suites build a real git repository in `beforeAll` and then shell
|
|
6
|
+
// out to `git` (and the rescue CLI, which itself spawns several `git`
|
|
7
|
+
// subprocesses) inside each `it`. Measured cold these tests run ~4–5s each;
|
|
8
|
+
// under full file-parallelism they contend for CPU and disk and routinely
|
|
9
|
+
// cross the 5s default, producing flaky "Test timed out in 5000ms" failures
|
|
10
|
+
// that have nothing to do with the code under test.
|
|
11
|
+
//
|
|
12
|
+
// Raise the global ceilings to give those git-bound tests headroom. The values
|
|
13
|
+
// are still tight enough to catch a genuinely hung test (an unresolved promise
|
|
14
|
+
// or a deadlocked subprocess) — they just stop punishing legitimately-slow
|
|
15
|
+
// integration work. Keep this central rather than sprinkling per-test timeouts
|
|
16
|
+
// so future git-heavy suites inherit the headroom automatically.
|
|
17
|
+
export default defineConfig({
|
|
18
|
+
test: {
|
|
19
|
+
testTimeout: 20_000,
|
|
20
|
+
hookTimeout: 30_000,
|
|
21
|
+
},
|
|
22
|
+
});
|