@indigoai-us/hq-cloud 6.0.1 → 6.0.3

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.
@@ -467,12 +467,20 @@ fi
467
467
  TMPDIR="$(mktemp -d -t hq-replace-rescue-XXXXXX)"
468
468
  trap 'rm -rf "$TMPDIR"' EXIT
469
469
 
470
- # Append-only record of every file the walk MOVES (rescue) or DELETES
470
+ # Append-only record of every file the walk MOVES (rescue) or LEAVES in place
471
471
  # (unchanged). Folded into the snapshot's RECOVERY.md manifest after the run
472
472
  # so a user can see exactly what changed and restore any single file.
473
473
  RESCUE_LOG="$TMPDIR/rescue-actions.log"
474
474
  : > "$RESCUE_LOG"
475
475
 
476
+ # Paths the walk classified UNCHANGED (left in place). Fed to the overlay as an
477
+ # --exclude-from so rsync never touches them — preserving their on-disk mtime
478
+ # exactly (Layer 1). rsync's -t re-stamps even a checksum-skipped file to the
479
+ # source's time, so excluding is the only way to leave the local mtime truly
480
+ # untouched.
481
+ UNCHANGED_LIST="$TMPDIR/unchanged-paths.txt"
482
+ : > "$UNCHANGED_LIST"
483
+
476
484
  # Build the clone URL. If GH_TOKEN is set in the environment, inject it as
477
485
  # the basic-auth user so `git clone` can access private staging repos
478
486
  # without an interactive credential prompt. This is the form the GitHub
@@ -518,6 +526,51 @@ fi
518
526
  SRC_SHA="$(cd "$TMPDIR/src" && git rev-parse HEAD)"
519
527
  echo "==> Source SHA: $SRC_SHA"
520
528
 
529
+ # --- Restore file mtimes from git history (mtime-preservation fix) -----------
530
+ #
531
+ # A bare `git clone`/checkout stamps every working-tree file with clone-time
532
+ # (git stores no per-file mtimes), so the `rsync -a` overlay below would reset
533
+ # every rescued file's mtime to "whenever rescue ran" — collapsing all history
534
+ # onto one instant and breaking "newer-than" comparisons, mtime-keyed caches,
535
+ # and reproducible state across machines. This is the rescue-path analogue of
536
+ # the 5.37.0 `hq-mtime` fix on the sync/S3 path, sourced from git commit times
537
+ # because the rescue source is a clone, not the vault.
538
+ #
539
+ # Single history walk, newest-first: the first commit a path appears in is its
540
+ # last-modifying commit, and that commit's committer-date becomes the file's
541
+ # mtime. Applied via perl's utime() — portable across macOS/Linux, unlike
542
+ # `touch -d @epoch` (GNU-only). Symlinks are skipped (utime follows the link
543
+ # to its target). Deleted-then-readded paths resolve correctly: newest wins.
544
+ restore_mtimes_from_git() {
545
+ local src="$1"
546
+ if ! command -v perl >/dev/null 2>&1; then
547
+ echo " (perl unavailable; skipping mtime restore — overlay mtimes stay clone-time)"
548
+ return 0
549
+ fi
550
+
551
+ # Correct per-file granularity needs full history. A shallow clone knows
552
+ # only the tip commit, so every file would collapse to the release time;
553
+ # deepen (blob:none keeps it to cheap commit/tree metadata) when shallow.
554
+ if [ "$(git -C "$src" rev-parse --is-shallow-repository 2>/dev/null)" = "true" ]; then
555
+ echo "==> Deepening clone for per-file mtime history (blob:none) ..."
556
+ git -C "$src" fetch --unshallow --filter=blob:none >/dev/null 2>&1 \
557
+ || echo " (unshallow failed; mtimes fall back to release tip-commit time)"
558
+ fi
559
+
560
+ echo "==> Restoring file mtimes from git commit history ..."
561
+ git -C "$src" log --no-renames --pretty=format:'C:%ct' --name-only --diff-filter=ACMR 2>/dev/null \
562
+ | awk '/^C:/ { ts = substr($0, 3); next } NF { if (!seen[$0]++) print ts "\t" $0 }' \
563
+ | SRC="$src" perl -ne '
564
+ chomp;
565
+ my ($ts, $rel) = split(/\t/, $_, 2);
566
+ next unless defined $rel and length $rel;
567
+ my $f = "$ENV{SRC}/$rel";
568
+ next if -l $f or not -f $f;
569
+ utime $ts, $ts, $f;
570
+ ' || true
571
+ }
572
+ restore_mtimes_from_git "$TMPDIR/src"
573
+
521
574
  # --- Resolve the history floor (last-sync SHA reachable in clone?) ----------
522
575
  #
523
576
  # The v0.1.104 algorithm drops the path → all-time-SHA index in favour of
@@ -1090,13 +1143,30 @@ process_one() {
1090
1143
  fi
1091
1144
  fi
1092
1145
  else
1093
- # UNCHANGED overlay will replace cleanly.
1146
+ # user_edited=0: local matches the floor baseline (user didn't touch it).
1147
+ # That does NOT mean it matches upstream HEAD — upstream may have advanced
1148
+ # or removed it. Split on byte-equality to the HEAD version the overlay
1149
+ # would write ($TMPDIR/src is checked out at HEAD):
1150
+ # identical to HEAD -> overlay is a content no-op; leave it untouched and
1151
+ # protect it from the overlay so its mtime is preserved (Layer 1).
1152
+ # differs from HEAD -> keep the original delete semantics: rm now; the
1153
+ # no-delete overlay re-lays it from source (with a git-commit mtime)
1154
+ # when still upstream, or leaves it gone when upstream removed it.
1094
1155
  COUNT_UNCHANGED=$((COUNT_UNCHANGED + 1))
1095
- if [ "$DRY_RUN" = "1" ]; then
1096
- echo " unchanged (delete + replace): $rel"
1156
+ if cmp -s "$local_path" "$TMPDIR/src/$rel" 2>/dev/null; then
1157
+ if [ "$DRY_RUN" = "1" ]; then
1158
+ echo " unchanged (preserved in place): $rel"
1159
+ else
1160
+ printf '/%s\n' "$rel" >> "$UNCHANGED_LIST"
1161
+ printf 'unchanged\t%s\t(identical to upstream; left in place, mtime preserved)\n' "$rel" >> "$RESCUE_LOG" 2>/dev/null || true
1162
+ fi
1097
1163
  else
1098
- rm -f "$local_path"
1099
- printf 'deleted\t%s\t(unchanged vs baseline; re-created by overlay)\n' "$rel" >> "$RESCUE_LOG" 2>/dev/null || true
1164
+ if [ "$DRY_RUN" = "1" ]; then
1165
+ echo " unchanged (delete + replace): $rel"
1166
+ else
1167
+ rm -f "$local_path"
1168
+ printf 'deleted\t%s\t(unchanged vs baseline; re-laid by overlay if still upstream)\n' "$rel" >> "$RESCUE_LOG" 2>/dev/null || true
1169
+ fi
1100
1170
  fi
1101
1171
  fi
1102
1172
  }
@@ -1211,7 +1281,7 @@ if [ "$DRY_RUN" = "1" ]; then
1211
1281
  echo ""
1212
1282
  echo "==> DRY RUN classification summary:"
1213
1283
  echo " user-only (leave in place): $COUNT_USER_ONLY files"
1214
- echo " unchanged (delete + replace): $COUNT_UNCHANGED files"
1284
+ echo " unchanged (preserved/re-laid): $COUNT_UNCHANGED files"
1215
1285
  echo " user-edit (rescue / diff-append): $COUNT_USER_EDIT files"
1216
1286
  echo " user-edit (conflict quarantine): $COUNT_USER_CONFLICT files"
1217
1287
  echo " user-edit (overwrite-safe): $COUNT_USER_OVERWRITE files"
@@ -1270,7 +1340,7 @@ for sp in "${PRESERVE_SUBPATHS[@]+"${PRESERVE_SUBPATHS[@]}"}"; do
1270
1340
  done
1271
1341
 
1272
1342
  echo ""
1273
- echo "==> Walk complete (user-only: $COUNT_USER_ONLY, unchanged-deleted: $COUNT_UNCHANGED, user-edit-rescued: $COUNT_USER_EDIT, conflict-quarantined: $COUNT_USER_CONFLICT, overwrite-safe: $COUNT_USER_OVERWRITE, cloud-symlink-reconciled: $COUNT_CLOUD_SYMLINK_RECONCILED, symlinks-dropped: $COUNT_SYMLINK_DROPPED)"
1343
+ echo "==> Walk complete (user-only: $COUNT_USER_ONLY, unchanged: $COUNT_UNCHANGED, user-edit-rescued: $COUNT_USER_EDIT, conflict-quarantined: $COUNT_USER_CONFLICT, overwrite-safe: $COUNT_USER_OVERWRITE, cloud-symlink-reconciled: $COUNT_CLOUD_SYMLINK_RECONCILED, symlinks-dropped: $COUNT_SYMLINK_DROPPED)"
1274
1344
  # Note: v0.1.103-and-earlier did a wholesale `rm -rf` of every wipe-set
1275
1345
  # top-level entry here. The new walk_and_process does per-file deletion
1276
1346
  # (only files that exist in upstream + are unchanged are deleted; user-only
@@ -1279,7 +1349,16 @@ echo "==> Walk complete (user-only: $COUNT_USER_ONLY, unchanged-deleted: $COUNT_
1279
1349
  # the walk left alone.
1280
1350
 
1281
1351
  echo "==> Overlaying source onto HQ root ..."
1282
- rsync -a "${RSYNC_EXCLUDES[@]}" "$TMPDIR/src/" "$HQ_ROOT/"
1352
+ # Protect UNCHANGED (identical-to-HEAD) files: feed their paths as an
1353
+ # --exclude-from (FIRST in the filter chain, so it wins) so rsync never touches
1354
+ # them — -a's -t would otherwise re-stamp their mtime to the source's time, and
1355
+ # excluding is the only way to leave the local mtime truly untouched (Layer 1).
1356
+ # Every other file under the wipe set was already rm'd by the walk, so the
1357
+ # overlay writes into an absent slot and -a carries the git-commit mtimes
1358
+ # restore_mtimes_from_git stamped onto the source.
1359
+ OVERLAY_PROTECT=()
1360
+ [ -s "$UNCHANGED_LIST" ] && OVERLAY_PROTECT=( --exclude-from="$UNCHANGED_LIST" )
1361
+ rsync -a "${OVERLAY_PROTECT[@]+"${OVERLAY_PROTECT[@]}"}" "${RSYNC_EXCLUDES[@]}" "$TMPDIR/src/" "$HQ_ROOT/"
1283
1362
 
1284
1363
  if [ -s "$TMPDIR/preserve.map" ]; then
1285
1364
  echo "==> Restoring preserved sub-paths ..."
@@ -1355,7 +1434,7 @@ done
1355
1434
  echo ""
1356
1435
  echo "==> Classification:"
1357
1436
  echo " user-only (left in place): $COUNT_USER_ONLY files"
1358
- echo " unchanged (deleted + overlaid): $COUNT_UNCHANGED files"
1437
+ echo " unchanged (preserved/re-laid): $COUNT_UNCHANGED files"
1359
1438
  echo " user-edits (rescued): $COUNT_USER_EDIT files"
1360
1439
  echo " user-edits (conflict quarantine): $COUNT_USER_CONFLICT files"
1361
1440
  echo " user-edits (overwrite-safe): $COUNT_USER_OVERWRITE files"
@@ -1390,7 +1469,7 @@ if [ "$DO_BACKUP" = "1" ] && [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ]; then
1390
1469
  echo ""
1391
1470
  echo "## What the rescue did"
1392
1471
  echo " user-only (left in place): $COUNT_USER_ONLY"
1393
- echo " unchanged (deleted + re-overlaid): $COUNT_UNCHANGED"
1472
+ echo " unchanged (preserved/re-laid): $COUNT_UNCHANGED"
1394
1473
  echo " user-edit (rescued into personal/): $COUNT_USER_EDIT"
1395
1474
  echo " user-edit (conflict quarantine): $COUNT_USER_CONFLICT"
1396
1475
  echo " user-edit (overwrite-safe): $COUNT_USER_OVERWRITE"
@@ -150,7 +150,7 @@ exec ${JSON.stringify(realGit)} "$@"
150
150
  expect(out).toContain("user-edit (rescue): core/edited.md -> personal/edited.md");
151
151
 
152
152
  // Untouched file classified UNCHANGED.
153
- expect(out).toContain("unchanged (delete + replace): core/keep.md");
153
+ expect(out).toContain("unchanged (preserved in place): core/keep.md");
154
154
 
155
155
  // Summary reflects exactly one reconcile.
156
156
  expect(out).toMatch(/drift reconciled \(== upstream\):\s+1 files/);
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Integration regression for the rescue mtime-preservation fix in
3
+ * scripts/replace-rescue.sh.
4
+ *
5
+ * The bug: rescue rebuilds the tree by `git clone` + `rsync -a`. A git
6
+ * checkout stamps every file with clone-time (git stores no per-file mtimes),
7
+ * so the overlay reset EVERY rescued file's mtime to "whenever rescue ran" —
8
+ * collapsing real history onto one instant (the exact fingerprint that flagged
9
+ * this: hundreds of files sharing one mtime, mtime == ctime == birth).
10
+ *
11
+ * The fix has three parts, all asserted below:
12
+ * - Layer 1: UNCHANGED files are left in place; the --checksum overlay skips
13
+ * them, so their existing mtime survives.
14
+ * - Layer 2: files the overlay DOES write (upstream-changed, brand-new) get
15
+ * the git committer-date of their last-modifying commit, not wall-clock.
16
+ * - Layer 3: full history is used (history_floor mode clones full history;
17
+ * the shallow path unshallows) so per-file commit times are real.
18
+ *
19
+ * The script clones from GitHub, so we shim `git clone` to a local fixture
20
+ * (same technique as rescue-drift-reconcile.test.ts) and run for real
21
+ * (non-dry-run, --no-backup) so the overlay actually lays files down.
22
+ */
23
+ import { describe, it, expect, beforeAll, afterAll } from "vitest";
24
+ import { execFileSync, spawnSync } from "child_process";
25
+ import * as fs from "fs";
26
+ import * as os from "os";
27
+ import * as path from "path";
28
+
29
+ const RESCUE_SCRIPT = path.resolve(process.cwd(), "scripts/replace-rescue.sh");
30
+
31
+ function has(bin: string, ...args: string[]): boolean {
32
+ try {
33
+ execFileSync(bin, args, { stdio: "ignore" });
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ // Needs git (fixture + clone), rsync (overlay), and perl (the utime pass).
41
+ const toolsAvailable =
42
+ has("git", "--version") && has("rsync", "--version") && has("perl", "-e", "1");
43
+
44
+ // Fixed commit epochs so assertions are exact and machine-independent.
45
+ const FLOOR_EPOCH = 1577836800; // 2020-01-01T00:00:00Z
46
+ const HEAD_EPOCH = 1609459200; // 2021-01-01T00:00:00Z
47
+ const KEEP_PRESET_EPOCH = 1546300800; // 2019-01-01T00:00:00Z (local, pre-rescue)
48
+
49
+ describe.skipIf(!toolsAvailable)("rescue preserves mtimes (no clone-time flattening)", () => {
50
+ let workDir: string;
51
+ let upstream: string;
52
+ let hqRoot: string;
53
+ let shimDir: string;
54
+ let floorSha: string;
55
+ let env: NodeJS.ProcessEnv;
56
+
57
+ const gitAt = (cwd: string, epoch: number, ...args: string[]) =>
58
+ execFileSync("git", args, {
59
+ cwd,
60
+ stdio: ["ignore", "pipe", "pipe"],
61
+ env: {
62
+ ...process.env,
63
+ GIT_AUTHOR_NAME: "t",
64
+ GIT_AUTHOR_EMAIL: "t@t",
65
+ GIT_COMMITTER_NAME: "t",
66
+ GIT_COMMITTER_EMAIL: "t@t",
67
+ GIT_AUTHOR_DATE: `${epoch} +0000`,
68
+ GIT_COMMITTER_DATE: `${epoch} +0000`,
69
+ },
70
+ })
71
+ .toString()
72
+ .trim();
73
+
74
+ const mtimeSec = (p: string) => Math.floor(fs.statSync(p).mtimeMs / 1000);
75
+
76
+ beforeAll(() => {
77
+ workDir = fs.mkdtempSync(path.join(os.tmpdir(), "hq-rescue-mtime-"));
78
+
79
+ // --- "upstream" repo ----------------------------------------------------
80
+ // floor commit (2020): x.md=v1, edited.md=base, keep.md=same
81
+ // HEAD commit (2021): x.md=v2 (changed) + new.md added; keep.md untouched
82
+ upstream = path.join(workDir, "upstream");
83
+ fs.mkdirSync(path.join(upstream, "core"), { recursive: true });
84
+ gitAt(workDir, FLOOR_EPOCH, "init", "-b", "main", "upstream");
85
+ fs.writeFileSync(path.join(upstream, "core/x.md"), "v1\n");
86
+ fs.writeFileSync(path.join(upstream, "core/edited.md"), "base\n");
87
+ fs.writeFileSync(path.join(upstream, "core/keep.md"), "same\n");
88
+ gitAt(upstream, FLOOR_EPOCH, "add", "-A");
89
+ gitAt(upstream, FLOOR_EPOCH, "commit", "-m", "floor");
90
+ floorSha = gitAt(upstream, FLOOR_EPOCH, "rev-parse", "HEAD");
91
+ fs.writeFileSync(path.join(upstream, "core/x.md"), "v2\n");
92
+ fs.writeFileSync(path.join(upstream, "core/new.md"), "fresh\n");
93
+ gitAt(upstream, HEAD_EPOCH, "add", "-A");
94
+ gitAt(upstream, HEAD_EPOCH, "commit", "-m", "head");
95
+
96
+ // --- local HQ root being rescued ----------------------------------------
97
+ hqRoot = path.join(workDir, "hq");
98
+ fs.mkdirSync(path.join(hqRoot, "core"), { recursive: true });
99
+ fs.mkdirSync(path.join(hqRoot, "personal"), { recursive: true });
100
+ fs.mkdirSync(path.join(hqRoot, "companies"), { recursive: true });
101
+ // Unchanged vs floor but upstream advanced v1 -> v2: overlay rewrites it.
102
+ fs.writeFileSync(path.join(hqRoot, "core/x.md"), "v1\n");
103
+ // User-edited: rescued to personal/ (not asserted here).
104
+ fs.writeFileSync(path.join(hqRoot, "core/edited.md"), "MINE\n");
105
+ // Identical to upstream HEAD: UNCHANGED -> left in place. Pre-stamp an old
106
+ // mtime that must survive the rescue.
107
+ const keep = path.join(hqRoot, "core/keep.md");
108
+ fs.writeFileSync(keep, "same\n");
109
+ fs.utimesSync(keep, KEEP_PRESET_EPOCH, KEEP_PRESET_EPOCH);
110
+
111
+ // --- git shim: redirect `git clone <github-url>` to the local fixture ----
112
+ const realGit = execFileSync("bash", ["-lc", "command -v git"]).toString().trim() || "/usr/bin/git";
113
+ shimDir = path.join(workDir, "shim");
114
+ fs.mkdirSync(shimDir, { recursive: true });
115
+ const shim = `#!/usr/bin/env bash
116
+ if [ "$1" = "clone" ]; then
117
+ args=()
118
+ for a in "$@"; do
119
+ case "$a" in
120
+ https://github.com/*) a=${JSON.stringify(upstream)} ;;
121
+ esac
122
+ args+=("$a")
123
+ done
124
+ exec ${JSON.stringify(realGit)} "\${args[@]}"
125
+ fi
126
+ exec ${JSON.stringify(realGit)} "$@"
127
+ `;
128
+ fs.writeFileSync(path.join(shimDir, "git"), shim, { mode: 0o755 });
129
+ env = { ...process.env, PATH: `${shimDir}:${process.env.PATH ?? ""}` };
130
+
131
+ // --- run the real rescue (non-dry-run) ----------------------------------
132
+ const r = spawnSync(
133
+ "bash",
134
+ [
135
+ RESCUE_SCRIPT,
136
+ "--hq-root", hqRoot,
137
+ "--source", "test/repo",
138
+ "--ref", "main",
139
+ "--floor-sha", floorSha,
140
+ "--yes",
141
+ "--no-backup",
142
+ ],
143
+ { env, encoding: "utf-8" },
144
+ );
145
+ // Surface script output on failure for debuggability.
146
+ if (r.status !== 0) {
147
+ throw new Error(`rescue failed (${r.status}):\n${r.stdout}\n${r.stderr}`);
148
+ }
149
+ });
150
+
151
+ afterAll(() => {
152
+ if (workDir) fs.rmSync(workDir, { recursive: true, force: true });
153
+ });
154
+
155
+ it("rewrites an upstream-changed file with its git commit mtime (not now)", () => {
156
+ const x = path.join(hqRoot, "core/x.md");
157
+ expect(fs.readFileSync(x, "utf-8")).toBe("v2\n");
158
+ expect(mtimeSec(x)).toBe(HEAD_EPOCH);
159
+ });
160
+
161
+ it("creates a brand-new upstream file with its git commit mtime", () => {
162
+ const n = path.join(hqRoot, "core/new.md");
163
+ expect(fs.existsSync(n)).toBe(true);
164
+ expect(mtimeSec(n)).toBe(HEAD_EPOCH);
165
+ });
166
+
167
+ it("leaves an UNCHANGED file in place, preserving its existing mtime (Layer 1)", () => {
168
+ const keep = path.join(hqRoot, "core/keep.md");
169
+ expect(fs.readFileSync(keep, "utf-8")).toBe("same\n");
170
+ expect(mtimeSec(keep)).toBe(KEEP_PRESET_EPOCH);
171
+ });
172
+
173
+ it("never stamps an overlaid file with wall-clock-now (the original bug)", () => {
174
+ const now = Math.floor(Date.now() / 1000);
175
+ for (const rel of ["core/x.md", "core/new.md", "core/keep.md"]) {
176
+ const age = now - mtimeSec(path.join(hqRoot, rel));
177
+ // Every asserted file predates the run by years; a clone-time stamp
178
+ // would be within seconds of now.
179
+ expect(age, `${rel} mtime looks like wall-clock-now`).toBeGreaterThan(60);
180
+ }
181
+ });
182
+ });
@@ -1132,14 +1132,12 @@ describe("share", () => {
1132
1132
  }),
1133
1133
  );
1134
1134
 
1135
- // Currency-gated HEADs the candidate; return an etag matching the
1136
- // journal so the entry resolves as "remote still current → safe to
1137
- // tombstone."
1138
- vi.mocked(headRemoteFile).mockResolvedValueOnce({
1139
- lastModified: new Date(),
1140
- etag: '"personal-vault-drift-etag"',
1141
- size: 1054,
1142
- });
1135
+ // (Pre-6.0.2: currency-gated would have HEADed the candidate; the
1136
+ // mockResolvedValueOnce that lived here is now redundant because the
1137
+ // vault-litter drain bypasses HEAD entirely for `.drift-` markers.
1138
+ // Leaving the mock here would queue an unconsumed return value and
1139
+ // poison the next test's HEAD call — removed so the assertion above
1140
+ // verifies the litter path, not the etag path.)
1143
1141
 
1144
1142
  const personalCtx = makeEntityContext({
1145
1143
  uid: "prs_01HASSAANTESTUSER",
@@ -1970,14 +1968,23 @@ describe("share", () => {
1970
1968
  expect(uploadFile).not.toHaveBeenCalled();
1971
1969
  });
1972
1970
 
1973
- it("conflict-mirror exclusion: journaled mirror with local-missing is NOT swept by delete plan", async () => {
1971
+ it("vault-litter drain (6.0.2): journaled `.conflict-*` mirror with local-missing IS swept by the delete plan", async () => {
1972
+ // Updated semantics (6.0.2): the existing-litter state — a conflict
1973
+ // mirror that leaked into the journal from a prior buggy upload and was
1974
+ // subsequently deleted locally — used to survive every sync because the
1975
+ // `isEphemeralPath` skip in computeDeletePlan was wired to drop it
1976
+ // before tombstoning. The deferred "dedicated reconcile command" the
1977
+ // doc-comment promised never materialized, and the litter accumulated
1978
+ // until users noticed it (e.g. the May-27 `personal/.obsidian/*.drift-*`
1979
+ // pair on the EC2 outpost). The fix: drain unconditionally via the
1980
+ // litter bucket — bypass shouldSync, ephemeral-skip, policy, and the
1981
+ // bulk-asymmetry breaker. By construction litter is not user content
1982
+ // (the EPHEMERAL_PATH_PATTERN / DRIFT_PATH_PATTERN regexes are precise
1983
+ // enough that a false-positive on a real user filename is vanishingly
1984
+ // unlikely), so the absence of those gates is the correct behavior.
1974
1985
  const companyRoot = path.join(tmpDir, "companies", "acme");
1975
1986
  fs.mkdirSync(companyRoot, { recursive: true });
1976
- // Simulate the existing-litter state: a conflict mirror that leaked
1977
- // into the journal in a prior buggy version. Locally missing (user
1978
- // already deleted it). The regular delete plan must NOT issue a
1979
- // DeleteObject — that's the dedicated reconcile command's job, and
1980
- // a sync should not accidentally race a user reviewing the mirror.
1987
+
1981
1988
  const journalPath = path.join(stateDir, "sync-journal.acme.json");
1982
1989
  fs.writeFileSync(
1983
1990
  journalPath,
@@ -1999,7 +2006,10 @@ describe("share", () => {
1999
2006
  }),
2000
2007
  );
2001
2008
 
2002
- // HEAD needed for the non-mirror entry under currency-gated.
2009
+ // HEAD needed for the non-mirror entry under currency-gated. The litter
2010
+ // drain bypasses HEAD by design — the etag check encodes a "remote
2011
+ // hasn't drifted since I last synced" invariant that doesn't apply to
2012
+ // never-should-have-been-there litter.
2003
2013
  vi.mocked(headRemoteFile).mockResolvedValue({
2004
2014
  lastModified: new Date(),
2005
2015
  etag: '"regular-etag"',
@@ -2015,19 +2025,81 @@ describe("share", () => {
2015
2025
  propagateDeletes: true,
2016
2026
  });
2017
2027
 
2018
- expect(result.filesDeleted).toBe(1);
2019
- expect(deleteRemoteFile).toHaveBeenCalledTimes(1);
2028
+ // BOTH the regular file AND the conflict mirror tombstone in one pass.
2029
+ expect(result.filesDeleted).toBe(2);
2030
+ expect(deleteRemoteFile).toHaveBeenCalledTimes(2);
2020
2031
  expect(deleteRemoteFile).toHaveBeenCalledWith(expect.anything(), "regular.md");
2021
- expect(deleteRemoteFile).not.toHaveBeenCalledWith(
2032
+ expect(deleteRemoteFile).toHaveBeenCalledWith(
2022
2033
  expect.anything(),
2023
- expect.stringContaining("conflict-"),
2034
+ "CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md",
2024
2035
  );
2025
- // Mirror's journal entry survives reconcile command (separate skill)
2026
- // sweeps it once the user explicitly opts in.
2036
+ // Both journal entries are removed by the delete loop's removeEntry call.
2027
2037
  const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2028
2038
  expect(
2029
2039
  journal.files["CLAUDE.md.conflict-2026-05-13T19-40-40Z-e5797a.md"],
2030
- ).toBeDefined();
2040
+ ).toBeUndefined();
2041
+ expect(journal.files["regular.md"]).toBeUndefined();
2042
+ });
2043
+
2044
+ it("vault-litter drain (6.0.2): `.drift-<unixts>-<pid>` rescue markers drain regardless of personal-vault exclusion path (the live obsidian case)", async () => {
2045
+ // The exact failure mode that motivated the fix: a rescue-overlay
2046
+ // `.drift-` marker sitting under a personal-vault default exclusion
2047
+ // (`.obsidian/**`) survives every sync because `shouldSync` rejects
2048
+ // the parent path before the delete plan ever considers the entry.
2049
+ // The litter bypass routes such keys around shouldSync directly into
2050
+ // `litterToDelete`.
2051
+ const stateDirPersonal = fs.mkdtempSync(
2052
+ path.join(os.tmpdir(), "hq-state-prs-drift-"),
2053
+ );
2054
+ process.env.HQ_STATE_DIR = stateDirPersonal;
2055
+ try {
2056
+ const journalPath = path.join(
2057
+ stateDirPersonal,
2058
+ "sync-journal.__hq_personal_vault__.json",
2059
+ );
2060
+ fs.writeFileSync(
2061
+ journalPath,
2062
+ JSON.stringify({
2063
+ version: "1",
2064
+ lastSync: new Date().toISOString(),
2065
+ files: {
2066
+ "personal/.obsidian/graph.json.drift-1779863862-42519": {
2067
+ hash: "h", size: 1054, syncedAt: new Date().toISOString(),
2068
+ direction: "down",
2069
+ remoteEtag: "obsidian-drift-etag",
2070
+ },
2071
+ },
2072
+ }),
2073
+ );
2074
+
2075
+ const personalCtx = makeEntityContext({
2076
+ uid: "prs_01HASSAANTESTUSER",
2077
+ slug: "__hq_personal_vault__",
2078
+ bucketName: "hq-vault-prs-01HASSAANTESTUSER",
2079
+ });
2080
+
2081
+ const result = await share({
2082
+ paths: [tmpDir],
2083
+ entityContext: personalCtx,
2084
+ hqRoot: tmpDir,
2085
+ personalMode: true,
2086
+ skipUnchanged: true,
2087
+ propagateDeletes: true,
2088
+ });
2089
+
2090
+ expect(result.filesDeleted).toBe(1);
2091
+ expect(deleteRemoteFile).toHaveBeenCalledWith(
2092
+ expect.anything(),
2093
+ "personal/.obsidian/graph.json.drift-1779863862-42519",
2094
+ );
2095
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf-8"));
2096
+ expect(
2097
+ journal.files["personal/.obsidian/graph.json.drift-1779863862-42519"],
2098
+ ).toBeUndefined();
2099
+ } finally {
2100
+ fs.rmSync(stateDirPersonal, { recursive: true, force: true });
2101
+ delete process.env.HQ_STATE_DIR;
2102
+ }
2031
2103
  });
2032
2104
 
2033
2105
  // ── personalMode ───────────────────────────────────────────────────────────
package/src/cli/share.ts CHANGED
@@ -100,6 +100,64 @@ export const EPHEMERAL_PATH_PATTERN =
100
100
  * matches anywhere in the string, which is fine: the
101
101
  * `.conflict-<ISO>-<hash>.` token is unambiguous.
102
102
  */
103
+ /**
104
+ * Rescue-overlay drift marker pattern. Written by `replace-rescue.sh`
105
+ * (`rescue_one` / `conflict_one`) when its rescue target already exists:
106
+ * `<orig>.drift-<unix-ts>-<pid>` — a collision suffix so the previous override
107
+ * is never silently overwritten. Distinct from `EPHEMERAL_PATH_PATTERN` —
108
+ * different producer (the rescue script vs the sync conflict-mirror path),
109
+ * different filename grammar (decimal timestamp + decimal pid vs ISO timestamp
110
+ * + hex machine hash). Like conflict mirrors, drift markers should never live
111
+ * in the vault; if a buggy past run uploaded one, the delete plan must be
112
+ * able to drain it regardless of where it sits.
113
+ */
114
+ export const DRIFT_PATH_PATTERN = /\.drift-\d+-\d+$/;
115
+
116
+ /**
117
+ * True iff the key is local-only ephemeral vault litter — a sync conflict
118
+ * mirror (`.conflict-<ISO>-<machine>[.ext]`) OR a rescue drift marker
119
+ * (`.drift-<unixts>-<pid>`).
120
+ *
121
+ * Used by `computeDeletePlan` to UNCONDITIONALLY drain existing vault litter,
122
+ * bypassing every "skip" gate that would otherwise trap it:
123
+ *
124
+ * 1. `shouldSync` — the personal-vault default exclusions (introduced in
125
+ * 5.25) reject paths like `**.obsidian/**`, `**.env`, `**output/**`,
126
+ * `**node_modules/**`, etc. from BOTH the upload walk AND the delete
127
+ * plan. That's correct for fresh content (don't upload), but it also
128
+ * strands any litter already in the vault at those paths — `<orig>.drift`
129
+ * / `<orig>.conflict` files inside an excluded parent get re-pulled
130
+ * every sync and never tombstoned. The live obsidian case here:
131
+ * `personal/.obsidian/graph.json.drift-1779863862-42519` survived every
132
+ * sync for two weeks because `.obsidian` is excluded.
133
+ * 2. `isEphemeralPath` — the existing skip in `computeDeletePlan` was
134
+ * designed to prevent a FRESH local `.conflict-*` mirror (written by the
135
+ * pull leg's "keep" branch as a side-by-side comparison file) from being
136
+ * miscounted as a delete candidate before the user has resolved the
137
+ * conflict. That intent stands, but it accidentally protects EXISTING
138
+ * cloud litter too. The fix: when the local file is already gone AND the
139
+ * key matches the litter pattern, drain it; the "fresh mirror, hasn't
140
+ * been resolved yet" case is impossible because that mirror would still
141
+ * be on disk.
142
+ * 3. The policy gate — `owned-only`'s direction filter and
143
+ * `currency-gated`'s etag check both encode user-content invariants
144
+ * that don't apply to litter (a `.drift-…-PID` file has no meaningful
145
+ * "ownership" or "freshness"; it's a stale local-overlay collision
146
+ * marker, by construction).
147
+ * 4. The bulk-asymmetry circuit breaker — litter cleanup is intentional
148
+ * ratchet-drain, not a "corrupt local mirror, refuse mass-delete"
149
+ * signal. Litter is queued via a separate bucket the breaker never
150
+ * sweeps.
151
+ *
152
+ * Together: a vault key matching either pattern always tombstones on the
153
+ * next push leg, regardless of personal-vault exclusions, ephemeral skip,
154
+ * policy, or breaker. Producer-side exclusions (upload walker + rescue
155
+ * script) close the ratchet on new litter; this drains the legacy buildup.
156
+ */
157
+ export function isVaultLitterArtifact(p: string): boolean {
158
+ return EPHEMERAL_PATH_PATTERN.test(p) || DRIFT_PATH_PATTERN.test(p);
159
+ }
160
+
103
161
  export function isEphemeralPath(p: string): boolean {
104
162
  return EPHEMERAL_PATH_PATTERN.test(p);
105
163
  }
@@ -1799,12 +1857,21 @@ async function computeDeletePlan(
1799
1857
  // and the journal-mutation buckets are already settled before any I/O.
1800
1858
  type HeadCandidate = { key: string; journalEtag: string };
1801
1859
  const headCandidates: HeadCandidate[] = [];
1860
+ // Litter drain bucket — kept separate from `plan.toDelete` so the
1861
+ // bulk-asymmetry breaker (which moves toDelete + headCandidates into
1862
+ // `refusedStale` when it trips) can't sweep these out. See
1863
+ // `isVaultLitterArtifact` for the patterns and rationale: by construction
1864
+ // these aren't user content losses, so a high ratio of litter must not
1865
+ // refuse the drain — that's the whole point of the bypass. Merged into
1866
+ // `plan.toDelete` after the breaker check.
1867
+ const litterToDelete: string[] = [];
1802
1868
  // Bulk-asymmetry tracking: count every in-scope journal entry (denominator)
1803
1869
  // and every entry that would have been a delete-candidate before the guard
1804
1870
  // (numerator). Numerator = headCandidates + owned-only/all toDelete picks +
1805
1871
  // legacy-no-etag refusals. We do NOT count "ENOENT but ignore-filtered" or
1806
1872
  // "ENOENT but ephemeral" — those drop out of the plan entirely on their own
1807
- // and don't reflect mirror-loss intent.
1873
+ // and don't reflect mirror-loss intent — and litter drains (see above),
1874
+ // which are intentional ratchet-cleanup, not mass-delete intent.
1808
1875
  let inScopeJournalEntries = 0;
1809
1876
  let bulkCandidatePicks = 0;
1810
1877
 
@@ -1834,9 +1901,28 @@ async function computeDeletePlan(
1834
1901
  }
1835
1902
  }
1836
1903
  if (presentLocally) continue;
1904
+
1905
+ // Vault-litter drain (6.0.2): conflict mirrors + rescue drift markers
1906
+ // ALWAYS drain, bypassing `shouldSync` (which would skip them when the
1907
+ // parent path is in personal-vault default exclusions like
1908
+ // `.obsidian/**`), the `isEphemeralPath` skip (which protects FRESH
1909
+ // local conflict mirrors but accidentally also protects existing vault
1910
+ // litter), the policy gate (no meaningful ownership/freshness for
1911
+ // litter), and the bulk-asymmetry breaker (intentional ratchet-drain,
1912
+ // not corrupt-mirror mass-delete intent). See `isVaultLitterArtifact`
1913
+ // for the full rationale and patterns. Queued via the separate
1914
+ // `litterToDelete` bucket so the breaker can't sweep these out.
1915
+ if (isVaultLitterArtifact(relativeKey)) {
1916
+ litterToDelete.push(relativeKey);
1917
+ continue;
1918
+ }
1919
+
1837
1920
  if (!shouldSync(localPath, false) && !shouldSync(localPath, true)) continue;
1838
1921
  // Ephemeral artifacts (conflict mirrors) never propagate-delete via the
1839
- // normal path — see EPHEMERAL_PATH_PATTERN doc.
1922
+ // normal path — see EPHEMERAL_PATH_PATTERN doc. NOTE: this is a no-op
1923
+ // post-litter-drain above — any key matching EPHEMERAL_PATH_PATTERN was
1924
+ // already routed to `litterToDelete`. Retained as defense-in-depth +
1925
+ // documentation of the policy-side intent.
1840
1926
  if (isEphemeralPath(relativeKey)) continue;
1841
1927
 
1842
1928
  if (policy === "all") {
@@ -1907,6 +1993,12 @@ async function computeDeletePlan(
1907
1993
  ratio: bulkCandidatePicks / inScopeJournalEntries,
1908
1994
  samplePaths,
1909
1995
  };
1996
+ // Litter still drains even when the breaker trips — see `litterToDelete`
1997
+ // declaration. The breaker protects against corrupt-mirror mass-deletes
1998
+ // of user content; litter cleanup is orthogonal and should never be
1999
+ // refused for the same reason new-litter producer-side exclusions
2000
+ // shouldn't block it.
2001
+ plan.toDelete.push(...litterToDelete);
1910
2002
  return plan;
1911
2003
  }
1912
2004
 
@@ -1944,6 +2036,13 @@ async function computeDeletePlan(
1944
2036
  }
1945
2037
  }
1946
2038
 
2039
+ // Litter drains alongside the normal candidates — bypasses every gate but
2040
+ // settles into the same plan.toDelete bucket so the share() executor's
2041
+ // delete loop tombstones each key identically (DeleteObject + remove from
2042
+ // journal). Merged at the end so the bulk-asymmetry breaker above had its
2043
+ // chance to NOT include litter in either the numerator or the sweep.
2044
+ plan.toDelete.push(...litterToDelete);
2045
+
1947
2046
  return plan;
1948
2047
  }
1949
2048