tether-sync 0.2.0 → 0.6.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.
@@ -1,18 +1,29 @@
1
1
  import {
2
2
  GIT_MODES,
3
3
  VERSION,
4
+ blobAt,
5
+ changeFromGit,
6
+ changesBetween,
7
+ fileBlob,
4
8
  files,
5
9
  findRepo,
10
+ gitBinary,
6
11
  gitBusy,
7
- installHooks,
12
+ head,
13
+ indexBlob,
8
14
  ipcEndpoint,
15
+ isAncestor,
16
+ isKept,
9
17
  isNewer,
18
+ isTracked,
10
19
  isWindows,
11
20
  latestVersion,
12
21
  listen,
13
22
  removeHooks,
23
+ restage,
24
+ setKeep,
14
25
  tetherHome
15
- } from "./chunk-NDKBAHAG.js";
26
+ } from "./chunk-KG4OTE34.js";
16
27
 
17
28
  // packages/client/src/daemon.js
18
29
  import fs8 from "node:fs";
@@ -34,6 +45,7 @@ var DEFAULTS = {
34
45
  // all | errors | off
35
46
  };
36
47
  var NUMERIC = /* @__PURE__ */ new Set(["tickSeconds", "graceMs"]);
48
+ var MIN_TICK_SECONDS = Number(process.env.TETHER_MIN_TICK_SECONDS) || 10;
37
49
  var Config = class {
38
50
  constructor(file) {
39
51
  this.file = file;
@@ -52,6 +64,7 @@ var Config = class {
52
64
  if (NUMERIC.has(key)) {
53
65
  value = Number(value);
54
66
  if (!Number.isFinite(value) || value <= 0) throw new Error(`${key} must be a positive number`);
67
+ if (key === "tickSeconds" && value < MIN_TICK_SECONDS) throw new Error(`tickSeconds must be at least ${MIN_TICK_SECONDS}`);
55
68
  }
56
69
  if (key === "notifications" && !["all", "errors", "off"].includes(value)) throw new Error("notifications must be all, errors or off");
57
70
  if (key === "scanRoots" && typeof value === "string") value = value.split(/[;,]/).map((s) => s.trim()).filter(Boolean);
@@ -113,11 +126,16 @@ var COLUMNS = {
113
126
  gitRoot: "TEXT",
114
127
  gitDir: "TEXT",
115
128
  gitHooks: "TEXT",
129
+ // unused since 0.2.1 (hooks were replaced by watching the git directory)
130
+ gitWaiting: "INTEGER DEFAULT 0",
131
+ // follow: a newer Tether version came from a git commit not pulled here yet
132
+ gitOverlay: "INTEGER DEFAULT 0",
133
+ // follow: holds a Tether-only version that git does not have (hidden from git)
116
134
  lastError: "TEXT",
117
135
  createdAt: "REAL",
118
136
  updatedAt: "REAL"
119
137
  };
120
- var BOOL = /* @__PURE__ */ new Set(["realtime", "hasBom", "exec", "canDelete", "detached", "needsRestore"]);
138
+ var BOOL = /* @__PURE__ */ new Set(["gitOverlay", "gitWaiting", "realtime", "hasBom", "exec", "canDelete", "detached", "needsRestore"]);
121
139
  var LocalDb = class {
122
140
  constructor(file) {
123
141
  this.db = new DatabaseSync(file);
@@ -342,7 +360,73 @@ function randomString(len) {
342
360
  }
343
361
  var newInstanceId = () => "i_" + randomString(10);
344
362
 
363
+ // packages/shared/src/chunks.js
364
+ var CHUNK_MIN = 2 * 1024;
365
+ var CHUNK_AVG = 8 * 1024;
366
+ var CHUNK_MAX = 64 * 1024;
367
+ var MASK_HARD = 4294836224;
368
+ var MASK_EASY = 4292870144;
369
+ var GEAR = (() => {
370
+ let a = 2122219134;
371
+ const t = new Uint32Array(256);
372
+ for (let i = 0; i < 256; i++) {
373
+ a = a + 1831565813 | 0;
374
+ let x = Math.imul(a ^ a >>> 15, 1 | a);
375
+ x = x + Math.imul(x ^ x >>> 7, 61 | x) ^ x;
376
+ t[i] = (x ^ x >>> 14) >>> 0;
377
+ }
378
+ return t;
379
+ })();
380
+ function chunkBoundaries(buf) {
381
+ const out = [];
382
+ const n = buf.length;
383
+ let start = 0;
384
+ while (start < n) {
385
+ if (n - start <= CHUNK_MIN) {
386
+ out.push({ o: start, s: n - start });
387
+ break;
388
+ }
389
+ const end = Math.min(n, start + CHUNK_MAX);
390
+ const normal = Math.min(end, start + CHUNK_AVG);
391
+ let h = 0;
392
+ let cut = end;
393
+ let i = start + CHUNK_MIN;
394
+ for (; i < normal; i++) {
395
+ h = (h << 1) + GEAR[buf[i]] >>> 0;
396
+ if ((h & MASK_HARD) === 0) {
397
+ cut = i + 1;
398
+ break;
399
+ }
400
+ }
401
+ if (cut === end) {
402
+ for (; i < end; i++) {
403
+ h = (h << 1) + GEAR[buf[i]] >>> 0;
404
+ if ((h & MASK_EASY) === 0) {
405
+ cut = i + 1;
406
+ break;
407
+ }
408
+ }
409
+ }
410
+ out.push({ o: start, s: cut - start });
411
+ start = cut;
412
+ }
413
+ return out;
414
+ }
415
+ function chunkContent(buf) {
416
+ return chunkBoundaries(buf).map(({ o, s }) => {
417
+ const bytes = buf.subarray(o, o + s);
418
+ return { h: sha256(bytes), s, bytes };
419
+ });
420
+ }
421
+
345
422
  // packages/client/src/api.js
423
+ var PUT_BATCH_BYTES = 1024 * 1024;
424
+ var PUT_PARALLEL = 4;
425
+ var PUT_BATCH_CHUNKS = 100;
426
+ var FETCH_BATCH_BYTES = 1024 * 1024;
427
+ var FETCH_PARALLEL = 4;
428
+ var kb = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${Math.round(n / 1024)} KB` : `${(n / 1048576).toFixed(1)} MB`;
429
+ var describeTransfer = (t, verb) => t ? `${verb} ${t.moved} of ${t.total} chunk${t.total === 1 ? "" : "s"}, ${kb(t.bytes)}` : "";
346
430
  var ApiError = class extends Error {
347
431
  constructor(status, body) {
348
432
  super(body?.message ?? `HTTP ${status}`);
@@ -375,6 +459,39 @@ var Api = class {
375
459
  this.config = config;
376
460
  this.credentials = credentials;
377
461
  this.accessToken = null;
462
+ this.manifests = /* @__PURE__ */ new Map();
463
+ this.transfers = /* @__PURE__ */ new Map();
464
+ }
465
+ /** Authenticated POST with a binary body or a binary response; refreshes the token once on 401. */
466
+ async binary(p, { json, body, headers = {} }) {
467
+ const send = async (token) => {
468
+ let res;
469
+ try {
470
+ res = await fetch(this.base + "/v1" + p, {
471
+ method: "POST",
472
+ headers: { authorization: `Bearer ${token}`, "content-type": json ? "application/json" : "application/octet-stream", ...headers },
473
+ body: json ? JSON.stringify(json) : body,
474
+ signal: AbortSignal.timeout(18e4)
475
+ });
476
+ } catch (e) {
477
+ throw new OfflineError(`cannot reach the Tether server at ${this.base} (${e.cause?.code ?? e.message})`);
478
+ }
479
+ if (!res.ok) throw new ApiError(res.status, (await res.json().catch(() => ({}))).error);
480
+ return Buffer.from(await res.arrayBuffer());
481
+ };
482
+ try {
483
+ return await send(await this.token());
484
+ } catch (e) {
485
+ if (e instanceof ApiError && e.status === 401 && this.accessToken) {
486
+ this.accessToken = null;
487
+ return send(await this.token());
488
+ }
489
+ throw e;
490
+ }
491
+ }
492
+ /** The chunk list to commit for content uploaded by uploadBlobs (undefined on old servers). */
493
+ manifestFor(sha) {
494
+ return this.manifests.get(sha);
378
495
  }
379
496
  get base() {
380
497
  return this.config.get("server").replace(/\/$/, "");
@@ -485,10 +602,58 @@ var Api = class {
485
602
  this.credentials.clear();
486
603
  this.accessToken = null;
487
604
  }
488
- /** Uploads canonical blobs the server doesn't have yet. blobs: [{ sha256, bytes }] */
489
- async uploadBlobs(blobs) {
490
- const unique = [...new Map(blobs.map((b) => [b.sha256, b])).values()];
491
- if (!unique.length) return;
605
+ /**
606
+ * Uploads content as chunks (delta sync): every file is split into content-defined chunks and
607
+ * only the chunks the server does not have are sent. blobs: [{ sha256, bytes }] (canonical).
608
+ * Afterwards manifestFor(sha) gives each file's chunk list for the commit.
609
+ */
610
+ async uploadBlobs(blobs, { artifactId } = {}) {
611
+ const files2 = [...new Map(blobs.map((b) => [b.sha256, b])).values()];
612
+ if (!files2.length) return;
613
+ const all = /* @__PURE__ */ new Map();
614
+ for (const f of files2) {
615
+ const parts = chunkContent(f.bytes);
616
+ this.manifests.set(f.sha256, parts.map(({ h, s }) => ({ h, s })));
617
+ for (const c of parts) all.set(c.h, c.bytes);
618
+ }
619
+ let missing;
620
+ try {
621
+ ({ missing } = await this.call("POST", "/chunks/missing", { hashes: [...all.keys()], artifactId }));
622
+ } catch (e) {
623
+ if (!(e instanceof ApiError && e.status === 404)) throw e;
624
+ for (const f of files2) this.manifests.delete(f.sha256);
625
+ return this.uploadWhole(files2);
626
+ }
627
+ const batches = [];
628
+ let batch = [];
629
+ let size = 0;
630
+ for (const h of missing) {
631
+ const bytes = all.get(h);
632
+ if (batch.length && (batch.length >= PUT_BATCH_CHUNKS || size + bytes.length > PUT_BATCH_BYTES)) {
633
+ batches.push(batch);
634
+ batch = [];
635
+ size = 0;
636
+ }
637
+ batch.push({ h, bytes });
638
+ size += bytes.length;
639
+ }
640
+ if (batch.length) batches.push(batch);
641
+ let next = 0;
642
+ await Promise.all(Array.from({ length: Math.min(PUT_PARALLEL, batches.length) }, async () => {
643
+ while (next < batches.length) {
644
+ const b = batches[next++];
645
+ await this.binary("/chunks/put", { body: Buffer.concat(b.map((c) => c.bytes)), headers: { "x-tether-chunks": b.map((c) => `${c.h}:${c.bytes.length}`).join(",") } });
646
+ }
647
+ }));
648
+ const sent = new Set(missing);
649
+ for (const f of files2) {
650
+ const m = this.manifests.get(f.sha256);
651
+ const moved = m.filter((c) => sent.has(c.h));
652
+ this.transfers.set(f.sha256, { moved: moved.length, total: m.length, bytes: moved.reduce((a, c) => a + c.s, 0) });
653
+ }
654
+ }
655
+ /** Whole-file upload through signed URLs, for servers without delta sync. */
656
+ async uploadWhole(unique) {
492
657
  const { upload } = await this.call("POST", "/blobs/upload", { blobs: unique.map((b) => ({ sha256: b.sha256, size: b.bytes.length })) });
493
658
  const bySha = new Map(unique.map((b) => [b.sha256, b]));
494
659
  for (const u of upload) {
@@ -496,10 +661,63 @@ var Api = class {
496
661
  if (!res.ok && res.status !== 412) throw new ApiError(res.status, { message: `upload failed (${res.status}): ${(await res.text().catch(() => "")).slice(0, 200)}` });
497
662
  }
498
663
  }
499
- /** Downloads blobs for [{ artifactId, version? }] into the cache; skips blobs already cached. */
664
+ /**
665
+ * Downloads content into the cache, by chunks (delta sync). items: [{ artifactId, version, sha,
666
+ * base? }] where base is content already cached (usually the version on disk): its chunks are
667
+ * reused, so only chunks that changed are downloaded.
668
+ */
500
669
  async fetchBlobs(cache, items) {
501
- if (!items.length) return [];
502
- const { downloads } = await this.call("POST", "/blobs/download", { items });
670
+ const need = [...new Map(items.filter((i) => !i.sha || !cache.has(i.sha)).map((i) => [`${i.artifactId}@${i.version}`, i])).values()];
671
+ if (!need.length) return;
672
+ let manifests;
673
+ try {
674
+ ({ manifests } = await this.call("POST", "/chunks/manifest", { items: need.map(({ artifactId, version }) => ({ artifactId, version })) }));
675
+ } catch (e) {
676
+ if (!(e instanceof ApiError && e.status === 404)) throw e;
677
+ return this.fetchWhole(cache, need);
678
+ }
679
+ for (const [k, m] of manifests.entries()) {
680
+ if (cache.has(m.blob)) continue;
681
+ const local = /* @__PURE__ */ new Map();
682
+ const base = need[k].base;
683
+ if (base && cache.has(base)) for (const c of chunkContent(cache.get(base))) local.set(c.h, c.bytes);
684
+ const size = new Map(m.chunks.map((c) => [c.h, c.s]));
685
+ const want = [...size.keys()].filter((h) => !local.has(h));
686
+ let bytesIn = 0;
687
+ const batches = [];
688
+ for (let i = 0; i < want.length; ) {
689
+ const batch = [];
690
+ let total = 0;
691
+ while (i < want.length && (!batch.length || total + size.get(want[i]) <= FETCH_BATCH_BYTES)) {
692
+ batch.push(want[i]);
693
+ total += size.get(want[i++]);
694
+ }
695
+ batches.push(batch);
696
+ bytesIn += total;
697
+ }
698
+ let next = 0;
699
+ await Promise.all(Array.from({ length: Math.min(FETCH_PARALLEL, batches.length) }, async () => {
700
+ while (next < batches.length) {
701
+ const batch = batches[next++];
702
+ const buf = await this.binary("/chunks/fetch", { json: { artifactId: m.artifactId, version: m.version, hashes: batch } });
703
+ let off = 0;
704
+ for (const h of batch) {
705
+ const bytes = buf.subarray(off, off + size.get(h));
706
+ off += size.get(h);
707
+ if (sha256(bytes) !== h) throw new Error("downloaded chunk does not match its hash");
708
+ local.set(h, Buffer.from(bytes));
709
+ }
710
+ }
711
+ }));
712
+ const content = Buffer.concat(m.chunks.map((c) => local.get(c.h)));
713
+ if (sha256(content) !== m.blob) throw new Error("downloaded content does not match its hash");
714
+ cache.put(m.blob, content);
715
+ this.transfers.set(m.blob, { moved: want.length, total: size.size, bytes: bytesIn });
716
+ }
717
+ }
718
+ /** Whole-file downloads through signed URLs, for servers without delta sync. */
719
+ async fetchWhole(cache, items) {
720
+ const { downloads } = await this.call("POST", "/blobs/download", { items: items.map(({ artifactId, version }) => ({ artifactId, version })) });
503
721
  for (const d of downloads) {
504
722
  if (cache.has(d.sha256)) continue;
505
723
  const res = await fetch(d.url);
@@ -771,6 +989,7 @@ function walkFiles(root) {
771
989
  var UserError = class extends Error {
772
990
  };
773
991
  var ACTIVE = (i) => i.mode !== "orphaned";
992
+ var FULL_CHECK_MS = 60 * 60 * 1e3;
774
993
  var Engine = class {
775
994
  constructor({ db, api, cache, tracker, config, log = () => {
776
995
  }, notify = () => {
@@ -819,20 +1038,38 @@ var Engine = class {
819
1038
  if (!this.cache.has(sha)) throw new Error(`content ${sha.slice(0, 12)} is not in the local cache`);
820
1039
  return this.cache.get(sha);
821
1040
  }
1041
+ /** Downloads content not yet cached; `base` (content already here) lets only changed chunks travel. */
822
1042
  async ensureBlobs(pairs) {
823
1043
  const missing = pairs.filter((p) => p.sha && !this.cache.has(p.sha));
824
- if (missing.length) await this.api.fetchBlobs(this.cache, missing.map(({ artifactId, version }) => ({ artifactId, version })));
1044
+ if (missing.length) await this.api.fetchBlobs(this.cache, missing);
1045
+ }
1046
+ /** Uploads content (only chunks the server lacks) and returns the commit fields for it. */
1047
+ async uploadContent(local, artifactId) {
1048
+ await this.api.uploadBlobs([{ sha256: local.hash, bytes: local.canonical }], { artifactId });
1049
+ this.cache.put(local.hash, local.canonical);
1050
+ return { blob: local.hash, size: local.canonical.length, chunks: this.api.manifestFor(local.hash) };
1051
+ }
1052
+ /** ", sent 2 of 540 chunks, 17 KB" for log lines (empty when nothing is known). */
1053
+ sentNote(sha) {
1054
+ const t = describeTransfer(this.api.transfers.get(sha), "sent");
1055
+ return t ? `; ${t}` : "";
825
1056
  }
826
1057
  /** Writes content for an instance at `p`; records the result for the quick change check. */
827
1058
  writeInstance(inst, p, sha, eolMode = inst.eolMode, exec = inst.exec) {
828
1059
  const style = inst.eolStyle ? { eol: inst.eolStyle, bom: !!inst.hasBom } : null;
829
- return writeLocal(p, this.blobFor(sha), eolMode, { exec, style });
1060
+ const st = writeLocal(p, this.blobFor(sha), eolMode, { exec, style });
1061
+ if (inst.gitMode === "keep" && inst.gitRoot) {
1062
+ restage(inst.gitRoot, p);
1063
+ const after = statOrNull(p);
1064
+ if (after) Object.assign(st, { size: after.size, mtimeMs: after.mtimeMs });
1065
+ }
1066
+ return st;
830
1067
  }
831
1068
  // ---------- tick ----------
832
1069
  async tick({ remote = true } = {}) {
833
1070
  this.busyCache = /* @__PURE__ */ new Map();
834
1071
  await this.locateAll();
835
- if (remote) await this.gitTransitions();
1072
+ await this.gitPass();
836
1073
  if (remote) {
837
1074
  await this.checkRemote();
838
1075
  await this.syncRealtime();
@@ -847,16 +1084,26 @@ var Engine = class {
847
1084
  if (!this.busyCache.has(inst.gitDir)) this.busyCache.set(inst.gitDir, gitBusy(inst.gitDir));
848
1085
  return !!this.busyCache.get(inst.gitDir);
849
1086
  }
1087
+ gitInstances() {
1088
+ return this.db.all().filter((i) => i.gitMode && i.gitRoot && i.mode !== "orphaned");
1089
+ }
850
1090
  /** Sets the git mode on an instance (and a folder's files). Throws if follow is not allowed. */
851
1091
  applyGitMode(root, mode) {
852
1092
  if (!root) return;
853
1093
  if (!GIT_MODES.includes(mode)) throw new UserError(`git mode must be one of ${GIT_MODES.join(", ")}`);
854
1094
  if (mode === "follow" && root.role === "viewer") throw new UserError("git follow mode needs editor access");
1095
+ if (mode === "follow" && !gitBinary()) throw new UserError("git follow mode needs git to be installed");
855
1096
  const repo = findRepo(root.path);
856
1097
  if (!repo) throw new UserError(`${root.path} is not inside a git repository`);
857
- const fields = { gitMode: mode, gitRoot: repo.root, gitDir: repo.gitDir, gitHooks: repo.hooksDir };
858
- for (const i of [root, ...root.kind === "folder" ? this.db.childrenOf(root.instanceId) : []]) this.db.update(i.instanceId, fields);
859
- this.syncGitHooks();
1098
+ const fields = { gitMode: mode, gitRoot: repo.root, gitDir: repo.gitDir, gitWaiting: false };
1099
+ for (const i of [root, ...root.kind === "folder" ? this.db.childrenOf(root.instanceId) : []]) {
1100
+ this.db.update(i.instanceId, fields);
1101
+ if (i.kind === "file") setKeep(repo.root, i.path, mode === "keep");
1102
+ }
1103
+ }
1104
+ /** Lets git see the file normally again (after unmanage, orphaning, or leaving keep mode). */
1105
+ releaseGit(inst) {
1106
+ if (inst.gitMode === "keep" && inst.kind === "file" && inst.gitRoot) setKeep(inst.gitRoot, inst.path, false);
860
1107
  }
861
1108
  /** `tether git <path> [--mode]`: show or change the git mode of managed files. */
862
1109
  gitMode({ path: p, mode }) {
@@ -865,73 +1112,135 @@ var Engine = class {
865
1112
  if (mode) for (const i of ts.filter((x) => !x.folderInstanceId || !folders.has(x.folderInstanceId))) this.applyGitMode(i.folderInstanceId ? this.db.get(i.folderInstanceId) : i, mode);
866
1113
  return this.targets(p).map((i) => ({ path: i.path, kind: i.kind, role: i.role, gitMode: this.db.get(i.instanceId)?.gitMode ?? null, gitRoot: this.db.get(i.instanceId)?.gitRoot ?? findRepo(i.path)?.root ?? null }));
867
1114
  }
868
- /** Installs hooks in repositories with follow-mode files and removes them where none remain. */
869
- syncGitHooks() {
870
- if (!this.home || !this.cliPath) return;
871
- const want = new Set(this.db.all().filter((i) => i.gitMode === "follow" && i.mode !== "orphaned" && i.gitHooks).map((i) => i.gitHooks));
872
- const had = new Set(this.db.getMeta("gitHooks") ?? []);
873
- for (const dir of want) {
874
- try {
875
- installHooks(dir, { home: this.home, node: process.execPath, cli: this.cliPath });
876
- } catch (e) {
877
- this.log(`could not install git hooks in ${dir}: ${e.message}`);
878
- }
879
- }
880
- for (const dir of had) if (!want.has(dir)) removeHooks(dir, this.home);
881
- this.db.setMeta("gitHooks", [...want]);
1115
+ /** Tether 0.2.0 used git hooks; they are no longer needed. */
1116
+ cleanupLegacyHooks() {
1117
+ const dirs = this.db.getMeta("gitHooks") ?? [];
1118
+ for (const dir of dirs) if (this.home) removeHooks(dir, this.home);
1119
+ if (dirs.length) this.db.setMeta("gitHooks", null);
882
1120
  }
883
- /** Repositories that just finished a git operation (merge resolved, rebase done) get reconciled. */
884
- async gitTransitions() {
885
- const dirs = /* @__PURE__ */ new Map();
886
- for (const i of this.db.all()) if (i.gitMode === "follow" && i.gitDir && i.mode !== "orphaned") dirs.set(i.gitDir, i.gitRoot);
887
- for (const [gitDir, root] of dirs) {
888
- const busy = gitBusy(gitDir);
889
- const was = this.gitState.get(gitDir);
890
- this.gitState.set(gitDir, busy);
891
- if (was && !busy) await this.gitReconcile(root);
1121
+ /** Managed files that ended up inside a git repository later (git init, moved into one) get keep mode. */
1122
+ detectNewRepos() {
1123
+ for (const i of this.db.all().filter((x) => !x.gitMode && !x.folderInstanceId && x.mode !== "orphaned" && !x.detached)) {
1124
+ const repo = findRepo(i.path);
1125
+ if (!repo) continue;
1126
+ this.applyGitMode(i, "keep");
1127
+ this.log(`${i.path}: now inside the git repository ${repo.root}; using keep mode`);
1128
+ this.notify({ kind: "git-new", level: "info", short: path4.basename(i.path), title: "Managed file is now in a git repository", body: `${path4.basename(i.path)} is inside ${repo.root}. Tether keeps it as the shared version and git ignores Tether's copy. Run "tether git ${path4.basename(i.path)} --mode follow" to publish git's changes instead.` });
892
1129
  }
893
1130
  }
894
1131
  /**
895
- * After git changed files (hook, or the end of a merge/rebase): publish git's version of
896
- * follow-mode files. If Tether also moved on, the file stays in editing mode for `tether done`.
1132
+ * Runs after git may have changed files (watcher on the git directory, and every tick):
1133
+ * follows renames git made, brings back files git restored, keeps keep-mode files hidden from
1134
+ * git, and publishes follow-mode files whose content now matches git's committed or staged state.
897
1135
  */
898
- async gitReconcile(root) {
899
- if (gitBusy(findRepo(root)?.gitDir)) return { busy: true };
900
- const mine = () => this.db.all().filter((i) => i.gitMode === "follow" && i.kind === "file" && i.mode === "normal" && i.gitRoot && samePath(i.gitRoot, root));
901
- for (const i of mine().filter((x) => x.detached)) {
902
- if (statOrNull(i.path)?.isFile()) {
903
- this.db.update(i.instanceId, { ...this.identity(i.path), detached: false, missingSince: null, size: null, mtimeMs: null });
904
- this.log(`${i.path}: back (restored by git)`);
1136
+ async gitPass({ only } = {}) {
1137
+ const repos = /* @__PURE__ */ new Map();
1138
+ for (const i of this.gitInstances()) if (!only || samePath(i.gitRoot, only)) repos.set(i.gitDir, i.gitRoot);
1139
+ if (!repos.size) return;
1140
+ const heads = this.db.getMeta("gitHeads") ?? {};
1141
+ const publish = [];
1142
+ for (const [gitDir, root] of repos) {
1143
+ if (gitBusy(gitDir)) continue;
1144
+ const h = gitBinary() ? head(root) : null;
1145
+ const prev = heads[gitDir];
1146
+ const headMoved = !!h && h !== prev;
1147
+ if (h && prev && h !== prev) await this.followGitHistory(root, prev, h);
1148
+ if (h) heads[gitDir] = h;
1149
+ for (const i of this.gitInstances().filter((x) => x.gitDir === gitDir)) {
1150
+ if (i.kind !== "file") continue;
1151
+ if (i.detached) {
1152
+ if (statOrNull(i.path)?.isFile()) {
1153
+ this.db.update(i.instanceId, { ...this.identity(i.path), detached: false, missingSince: null, size: null, mtimeMs: null });
1154
+ if (i.gitMode === "keep") setKeep(root, i.path, true);
1155
+ this.log(`${i.path}: back (restored by git)`);
1156
+ }
1157
+ continue;
1158
+ }
1159
+ if (i.mode !== "normal") continue;
1160
+ if (i.gitMode === "keep") {
1161
+ if ((headMoved || !prev) && gitBinary() && !isKept(root, i.path)) setKeep(root, i.path, true);
1162
+ continue;
1163
+ }
1164
+ if (!gitBinary()) continue;
1165
+ let local;
1166
+ try {
1167
+ local = readLocal(i.path, i.eolMode);
1168
+ } catch {
1169
+ continue;
1170
+ }
1171
+ if (i.gitOverlay) {
1172
+ if (fileBlob(root, i.path) !== blobAt(root, "HEAD", i.path)) continue;
1173
+ setKeep(root, i.path, false);
1174
+ this.db.update(i.instanceId, { gitOverlay: false });
1175
+ i.gitOverlay = false;
1176
+ }
1177
+ const folder = i.folderInstanceId ? this.folderOf(i) : null;
1178
+ const rel = folder ? this.localRel(i, folder) : null;
1179
+ const moved = folder && rel && rel !== i.relPath;
1180
+ const changed = local && local.hash !== i.baseHash;
1181
+ if (!changed && !moved) continue;
1182
+ const { fromGit, commit } = changed ? changeFromGit(root, i.path) : { fromGit: true, commit: null };
1183
+ if (fromGit) publish.push({ inst: i, local: changed ? local : null, commit, relPath: moved ? rel : void 0 });
905
1184
  }
906
1185
  }
907
- await this.locateAll();
908
- const changed = [];
909
- for (const i of mine().filter((x) => !x.detached)) {
910
- try {
911
- const local = readLocal(i.path, i.eolMode);
912
- if (local && local.hash !== i.baseHash) changed.push({ inst: i, local });
913
- } catch {
1186
+ this.db.setMeta("gitHeads", heads);
1187
+ if (publish.length) await this.publishGitChanges(publish);
1188
+ }
1189
+ /** Renames and new files git brought in between two commits. */
1190
+ async followGitHistory(root, from, to) {
1191
+ for (const c of changesBetween(root, from, to)) {
1192
+ if (c.status === "R") {
1193
+ const inst = this.gitInstances().find((i) => i.kind === "file" && samePath(i.path, c.from));
1194
+ if (inst && !statOrNull(c.from) && statOrNull(c.to)?.isFile()) {
1195
+ this.db.update(inst.instanceId, { path: canonicalPath(c.to), ...this.identity(c.to), detached: false, missingSince: null, size: null, mtimeMs: null });
1196
+ if (inst.gitMode === "keep") {
1197
+ setKeep(root, c.from, false);
1198
+ setKeep(root, c.to, true);
1199
+ }
1200
+ this.log(`${c.from} -> ${c.to}: followed a rename made in git`);
1201
+ }
1202
+ } else if (c.status === "A" && !this.instanceAt(c.to) && statOrNull(c.to)?.isFile()) {
1203
+ const folder = this.gitInstances().find((i) => i.kind === "folder" && i.gitMode === "follow" && isUnder(c.to, i.path) && !samePath(c.to, i.path));
1204
+ if (!folder) continue;
1205
+ const name = path4.basename(c.to);
1206
+ if (folder.role === "owner") {
1207
+ try {
1208
+ const r = await this.add({ path: c.to, gitCommit: to });
1209
+ this.log(`${c.to}: added to ${folder.path} (new in git)`);
1210
+ this.notify({ kind: "git-published", level: "info", short: name, title: "Published git's changes", body: `${r.relPath} is new in git and was added to ${path4.basename(folder.path)} for everyone.` });
1211
+ } catch (e) {
1212
+ this.log(`${c.to}: could not add (${e.message})`);
1213
+ }
1214
+ } else {
1215
+ this.notify({ kind: "git-new-file", level: "info", short: name, title: "New file in a managed folder", body: `git added ${name} inside ${path4.basename(folder.path)}. Only the owner can add files to it, so it is not shared through Tether.` });
1216
+ }
914
1217
  }
915
1218
  }
916
- if (!changed.length) return { published: 0 };
1219
+ }
1220
+ /**
1221
+ * Publishes git's version of follow-mode files. If Tether already has exactly that content it is
1222
+ * adopted; if Tether moved on to something else, the file stays in editing mode for `tether done`.
1223
+ */
1224
+ async publishGitChanges(list) {
917
1225
  let results;
918
1226
  try {
919
- ({ results } = await this.api.call("POST", "/sync/check", { items: changed.map(({ inst }) => ({ artifactId: inst.artifactId, version: inst.baseVersion })) }));
1227
+ ({ results } = await this.api.call("POST", "/sync/check", { items: list.map(({ inst }) => ({ artifactId: inst.artifactId, version: inst.baseVersion })) }));
920
1228
  } catch (e) {
921
1229
  if (!(e instanceof OfflineError)) throw e;
922
- for (const { inst } of changed) this.db.update(inst.instanceId, { mode: "editing", lastError: "git changed this file while offline; run `tether done` to publish it" });
923
- return { published: 0, offline: true };
1230
+ for (const { inst } of list) this.db.update(inst.instanceId, { mode: "editing", lastError: "git changed this file while offline; run `tether done` to publish it" });
1231
+ return;
924
1232
  }
925
- let published = 0;
926
- for (const [k, { inst, local }] of changed.entries()) {
1233
+ for (const [k, { inst, local, commit, relPath }] of list.entries()) {
927
1234
  const r = results[k];
928
1235
  const name = path4.basename(inst.path);
1236
+ const hash = local?.hash ?? inst.baseHash;
929
1237
  if (r.status === "gone" || r.state?.deleted || r.folderDeleted) {
930
1238
  this.orphan(inst, r.reason ?? "deleted");
931
1239
  continue;
932
1240
  }
933
1241
  if (r.role === "viewer") {
934
1242
  this.db.update(inst.instanceId, { gitMode: "keep", role: "viewer" });
1243
+ setKeep(inst.gitRoot, inst.path, true);
935
1244
  continue;
936
1245
  }
937
1246
  const conflict = () => {
@@ -939,18 +1248,32 @@ var Engine = class {
939
1248
  this.log(`${inst.path}: git changed it but Tether also has a newer version; left in editing mode`);
940
1249
  this.notify({ kind: "conflict", level: "warn", title: "Merge conflict (git and Tether)", body: `${name}: git changed it, but someone also pushed a newer version to Tether. Git's version is kept and the file is in editing mode; run "tether done" to choose.`, short: name, artifactId: inst.artifactId });
941
1250
  };
1251
+ const adopt = (state) => {
1252
+ this.db.update(inst.instanceId, { baseVersion: state.version, remoteVersion: state.version, baseHash: state.blob, relPath: state.relPath ?? inst.relPath, gitWaiting: false, lastError: null, ...local ? { size: local.size, mtimeMs: local.mtimeMs, ...styleOf(local) } : {} });
1253
+ this.log(`${inst.path}: git's version is already on Tether (v${state.version})`);
1254
+ };
942
1255
  if (r.status === "changed") {
943
- conflict();
1256
+ if (r.state.blob === hash && (relPath === void 0 || r.state.relPath === relPath)) adopt(r.state);
1257
+ else conflict();
944
1258
  continue;
945
1259
  }
946
1260
  try {
947
- await this.api.uploadBlobs([{ sha256: local.hash, bytes: local.canonical }]);
948
- this.cache.put(local.hash, local.canonical);
949
- const c = await this.api.call("POST", `/artifacts/${inst.artifactId}/commit`, { baseVersion: inst.baseVersion, change: { blob: local.hash, size: local.canonical.length } });
950
- this.db.update(inst.instanceId, { baseVersion: c.version, remoteVersion: c.version, baseHash: local.hash, size: local.size, mtimeMs: local.mtimeMs, ...styleOf(local), lastError: null });
951
- this.log(`${inst.path}: published git's version as v${c.version}`);
1261
+ const change = { gitCommit: commit ?? null };
1262
+ if (local) Object.assign(change, await this.uploadContent(local, inst.artifactId));
1263
+ if (relPath !== void 0) change.relPath = relPath;
1264
+ const c = await this.api.call("POST", `/artifacts/${inst.artifactId}/commit`, { baseVersion: inst.baseVersion, change });
1265
+ this.db.update(inst.instanceId, {
1266
+ baseVersion: c.version,
1267
+ remoteVersion: c.version,
1268
+ baseHash: hash,
1269
+ relPath: relPath ?? inst.relPath,
1270
+ gitWaiting: false,
1271
+ lastError: null,
1272
+ ...local ? { size: local.size, mtimeMs: local.mtimeMs, ...styleOf(local) } : {}
1273
+ });
1274
+ const sent = local ? describeTransfer(this.api.transfers.get(local.hash), "sent") : "";
1275
+ this.log(`${inst.path}: published git's version as v${c.version}${sent ? ` (${sent})` : ""}`);
952
1276
  this.notify({ kind: "git-published", level: "info", title: "Published git's changes", body: `${name} is now v${c.version} for everyone who has it.`, short: name, artifactId: inst.artifactId });
953
- published++;
954
1277
  } catch (e) {
955
1278
  if (e instanceof ApiError && e.status === 409) conflict();
956
1279
  else if (e instanceof ApiError) {
@@ -959,7 +1282,11 @@ var Engine = class {
959
1282
  } else throw e;
960
1283
  }
961
1284
  }
962
- return { published };
1285
+ }
1286
+ /** Hook calls from Tether 0.2.0 installs, and explicit syncs. */
1287
+ async gitReconcile(root) {
1288
+ await this.locateAll();
1289
+ await this.gitPass({ only: root });
963
1290
  }
964
1291
  // ---------- realtime editing ----------
965
1292
  /** Files this client is editing live (every save is pushed). */
@@ -1008,7 +1335,7 @@ var Engine = class {
1008
1335
  const local = readLocal(cur.path, cur.eolMode);
1009
1336
  if (r.status === "changed") {
1010
1337
  if (!local || local.hash === cur.baseHash) {
1011
- await this.ensureBlobs([{ artifactId: cur.artifactId, version: r.state.version, sha: r.state.blob }]);
1338
+ await this.ensureBlobs([{ artifactId: cur.artifactId, version: r.state.version, sha: r.state.blob, base: cur.baseHash }]);
1012
1339
  this.applyIncoming(cur, r.state);
1013
1340
  } else {
1014
1341
  if (!cur.lastError?.startsWith("conflict")) {
@@ -1020,12 +1347,11 @@ var Engine = class {
1020
1347
  continue;
1021
1348
  }
1022
1349
  if (!local || local.hash === cur.baseHash) continue;
1023
- await this.api.uploadBlobs([{ sha256: local.hash, bytes: local.canonical }]);
1024
- this.cache.put(local.hash, local.canonical);
1350
+ const content = await this.uploadContent(local, cur.artifactId);
1025
1351
  try {
1026
- const c = await this.api.call("POST", `/artifacts/${cur.artifactId}/commit`, { baseVersion: cur.baseVersion, change: { blob: local.hash, size: local.canonical.length } });
1352
+ const c = await this.api.call("POST", `/artifacts/${cur.artifactId}/commit`, { baseVersion: cur.baseVersion, change: content });
1027
1353
  this.db.update(cur.instanceId, { baseVersion: c.version, remoteVersion: c.version, baseHash: local.hash, size: local.size, mtimeMs: local.mtimeMs, ...styleOf(local), lastError: null });
1028
- this.log(`${cur.path}: pushed v${c.version} (realtime)`);
1354
+ this.log(`${cur.path}: pushed v${c.version} (realtime${this.sentNote(local.hash)})`);
1029
1355
  } catch (e) {
1030
1356
  if (!(e instanceof ApiError && e.status === 409)) throw e;
1031
1357
  this.db.update(cur.instanceId, { lastError: "conflict: the server changed; run `tether done` to resolve" });
@@ -1098,7 +1424,7 @@ var Engine = class {
1098
1424
  this.log(`${inst.path}: folder deleted locally; no longer managed here`);
1099
1425
  return;
1100
1426
  }
1101
- if (inst.gitMode === "follow" && inst.mode === "normal" && !inst.folderInstanceId) {
1427
+ if (inst.gitMode && inst.mode === "normal") {
1102
1428
  this.db.update(inst.instanceId, { detached: true, missingSince: null });
1103
1429
  this.log(`${inst.path}: removed (by git?); waiting for git to bring it back`);
1104
1430
  } else if (inst.mode === "editing") {
@@ -1114,11 +1440,36 @@ var Engine = class {
1114
1440
  }
1115
1441
  orphan(inst, reason) {
1116
1442
  const targets = inst.kind === "folder" ? [inst, ...this.db.childrenOf(inst.instanceId)] : [inst];
1117
- for (const t of targets) this.db.update(t.instanceId, { mode: "orphaned", orphanReason: reason });
1443
+ for (const t of targets) {
1444
+ this.releaseGit(t);
1445
+ this.db.update(t.instanceId, { mode: "orphaned", orphanReason: reason });
1446
+ }
1118
1447
  const what = reason === "revoked" ? "access revoked" : reason === "deleted" ? "deleted on the server" : "removed from the server";
1119
1448
  this.log(`${inst.path}: ${what}; file kept, no longer tracked`);
1120
1449
  this.notify({ kind: "orphaned", level: "warn", title: "No longer synced", body: `${path4.basename(inst.path)}: ${what}. Your copy stays on disk but Tether no longer keeps it up to date.`, short: path4.basename(inst.path) });
1121
1450
  }
1451
+ /** What a check asks about; a change cursor is only valid for the same question. */
1452
+ checkKey(insts) {
1453
+ return insts.map((i) => `${i.artifactId}@${i.baseVersion}:${i.mode}`).join(",");
1454
+ }
1455
+ /**
1456
+ * The change cursor from the last full check, if it can be reused: same instances, nothing
1457
+ * pending, no live sessions, and a full check at least every FULL_CHECK_MS as a safety net.
1458
+ */
1459
+ reusableCursor(insts) {
1460
+ const f = this.feed;
1461
+ if (!f || this.realtimeActive || Date.now() - f.at > FULL_CHECK_MS) return void 0;
1462
+ return f.key === this.checkKey(insts) ? f.cursor : void 0;
1463
+ }
1464
+ /**
1465
+ * Keeps the server's cursor only when nothing is pending here: an incoming version held back
1466
+ * (editing, unmanaged, waiting for git) must be offered again by a full check once it can apply.
1467
+ */
1468
+ rememberCursor(cursor) {
1469
+ const insts = this.db.all().filter(ACTIVE);
1470
+ const settled = insts.every((i) => i.remoteVersion == null || i.remoteVersion === i.baseVersion);
1471
+ this.feed = cursor !== void 0 && settled ? { cursor, key: this.checkKey(insts), at: Date.now() } : null;
1472
+ }
1122
1473
  /** One batched call for all instances (DESIGN §6.1 step 2). */
1123
1474
  async checkRemote() {
1124
1475
  const insts = this.db.all().filter(ACTIVE);
@@ -1126,10 +1477,15 @@ var Engine = class {
1126
1477
  this.offline = null;
1127
1478
  return;
1128
1479
  }
1129
- let results;
1480
+ let results, cursor;
1130
1481
  try {
1131
- ({ results } = await this.api.call("POST", "/sync/check", { items: insts.map((i) => ({ artifactId: i.artifactId, version: i.baseVersion })) }));
1482
+ const rootOf = (i) => i.folderInstanceId ? this.db.get(i.folderInstanceId)?.artifactId : void 0;
1483
+ const items = insts.map((i) => ({ artifactId: i.artifactId, version: i.baseVersion, root: rootOf(i) }));
1484
+ const body = { items, cursor: this.reusableCursor(insts) };
1485
+ const res = await this.api.call("POST", "/sync/check", body);
1132
1486
  this.offline = null;
1487
+ if (res.unchanged) return;
1488
+ ({ results, cursor } = res);
1133
1489
  } catch (e) {
1134
1490
  if (e instanceof OfflineError || e instanceof ApiError && e.status === 401) {
1135
1491
  this.offline = e.message;
@@ -1142,6 +1498,7 @@ var Engine = class {
1142
1498
  const folders = [];
1143
1499
  insts.forEach((inst, idx) => {
1144
1500
  const r = results[idx];
1501
+ if (!r) return;
1145
1502
  const cur = this.db.get(inst.instanceId);
1146
1503
  if (!cur || cur.mode === "orphaned") return;
1147
1504
  if (r.status === "gone") return this.orphan(cur, r.reason);
@@ -1169,20 +1526,36 @@ var Engine = class {
1169
1526
  else if (!now.detached) incoming.push({ inst: now, state: r.state });
1170
1527
  });
1171
1528
  const newChildren = [];
1529
+ const waiting = /* @__PURE__ */ new Set();
1172
1530
  for (const f of folders) {
1173
1531
  const have = new Set(this.db.childrenOf(f.inst.instanceId).map((c) => c.artifactId));
1174
- for (const c of f.children) if (!c.deleted && !have.has(c.artifactId)) newChildren.push({ folder: f.inst, child: c });
1532
+ for (const c of f.children) {
1533
+ if (c.deleted || have.has(c.artifactId)) continue;
1534
+ if (f.inst.gitMode === "follow" && c.gitCommit && !isAncestor(f.inst.gitRoot, c.gitCommit)) {
1535
+ waiting.add(f.inst.instanceId);
1536
+ continue;
1537
+ }
1538
+ newChildren.push({ folder: f.inst, child: c });
1539
+ }
1175
1540
  }
1176
1541
  try {
1177
1542
  await this.ensureBlobs([
1178
- ...incoming.map(({ state }) => ({ artifactId: state.artifactId, version: state.version, sha: state.blob })),
1543
+ ...incoming.map(({ inst, state }) => ({ artifactId: state.artifactId, version: state.version, sha: state.blob, base: inst.baseHash })),
1179
1544
  ...newChildren.map(({ child }) => ({ artifactId: child.artifactId, version: child.version, sha: child.blob }))
1180
1545
  ]);
1181
1546
  } catch (e) {
1182
1547
  if (e instanceof OfflineError) return this.offline = e.message;
1183
1548
  throw e;
1184
1549
  }
1185
- for (const { inst, state } of incoming) if (!this.isGitBusy(inst)) this.applyIncoming(inst, state);
1550
+ for (const { inst, state } of incoming) {
1551
+ if (this.isGitBusy(inst)) continue;
1552
+ if (inst.gitMode === "follow" && state.gitCommit && !isAncestor(inst.gitRoot, state.gitCommit)) {
1553
+ if (!inst.gitWaiting) this.log(`${inst.path}: v${state.version} came from git commit ${state.gitCommit.slice(0, 8)}; it will arrive with your next git pull`);
1554
+ this.db.update(inst.instanceId, { gitWaiting: true });
1555
+ continue;
1556
+ }
1557
+ this.applyIncoming(inst, state);
1558
+ }
1186
1559
  for (const { folder, child } of newChildren) {
1187
1560
  this.db.insert({
1188
1561
  instanceId: newInstanceId(),
@@ -1204,10 +1577,13 @@ var Engine = class {
1204
1577
  });
1205
1578
  this.log(`${folder.path}: new file ${child.relPath}`);
1206
1579
  }
1207
- for (const f of folders) this.db.update(f.inst.instanceId, { baseVersion: f.state.version });
1580
+ for (const f of folders) if (!waiting.has(f.inst.instanceId)) this.db.update(f.inst.instanceId, { baseVersion: f.state.version });
1581
+ if (results.some((r) => r && r.status !== "ok")) this.activityAt = Date.now();
1582
+ this.rememberCursor(cursor);
1208
1583
  }
1209
1584
  /** Normal mode: write an incoming version (content and/or layout). */
1210
1585
  applyIncoming(inst, state) {
1586
+ if (!statOrNull(inst.path)) return;
1211
1587
  try {
1212
1588
  let p = inst.path;
1213
1589
  if (inst.folderInstanceId && state.relPath !== inst.relPath) {
@@ -1219,11 +1595,18 @@ var Engine = class {
1219
1595
  p = to;
1220
1596
  this.log(`${inst.path} -> ${to}: moved by the server`);
1221
1597
  }
1222
- const patch = { path: p, relPath: state.relPath, baseVersion: state.version, baseHash: state.blob, eolMode: state.eol, exec: state.exec, lastError: null };
1598
+ const patch = { path: p, relPath: state.relPath, baseVersion: state.version, baseHash: state.blob, eolMode: state.eol, exec: state.exec, lastError: null, gitWaiting: false };
1223
1599
  const localEdit = !this.matchesBase(inst, p) && readLocal(p, inst.eolMode)?.hash !== state.blob;
1224
1600
  if (state.blob !== inst.baseHash || localEdit) {
1225
1601
  Object.assign(patch, this.writeInstance(inst, p, state.blob, state.eol, state.exec));
1226
- this.log(`${p}: updated to version ${state.version}${localEdit ? " (local change replaced)" : ""}`);
1602
+ const got = describeTransfer(this.api.transfers.get(state.blob), "received");
1603
+ this.api.transfers.delete(state.blob);
1604
+ this.log(`${p}: updated to version ${state.version}${localEdit ? " (local change replaced)" : ""}${got ? ` (${got})` : ""}`);
1605
+ if (inst.gitMode === "follow" && inst.gitRoot && !state.gitCommit && blobAt(inst.gitRoot, "HEAD", p) !== fileBlob(inst.gitRoot, p)) {
1606
+ setKeep(inst.gitRoot, p, true);
1607
+ patch.gitOverlay = true;
1608
+ this.notify({ kind: "git-overlay", level: "info", short: path4.basename(p), artifactId: inst.artifactId, title: "Changed through Tether", body: `${path4.basename(p)} now has Tether's v${state.version}, which is not in git. Git does not see this change; the next change to the file in git replaces it.` });
1609
+ }
1227
1610
  if (localEdit) {
1228
1611
  const name = path4.basename(p);
1229
1612
  this.notify({
@@ -1263,6 +1646,22 @@ var Engine = class {
1263
1646
  const local = readLocal(p, inst.eolMode);
1264
1647
  return local?.hash === inst.baseHash;
1265
1648
  }
1649
+ /**
1650
+ * Follow mode, just before a revert: is this content git's own (a git operation that started
1651
+ * after this pass checked, e.g. a cherry-pick between writing the file and moving HEAD)? Asks git
1652
+ * afresh, since the pass's busy check may be stale by now.
1653
+ */
1654
+ gitWroteIt(inst) {
1655
+ if (inst.gitMode !== "follow" || !inst.gitRoot) return false;
1656
+ if (gitBusy(inst.gitDir)) return true;
1657
+ const blob = fileBlob(inst.gitRoot, inst.path);
1658
+ if (!blob) return false;
1659
+ if (blob === indexBlob(inst.gitRoot, inst.path) || blob === blobAt(inst.gitRoot, "HEAD", inst.path)) {
1660
+ this.gitPassDue = true;
1661
+ return true;
1662
+ }
1663
+ return false;
1664
+ }
1266
1665
  /** Normal mode: restore missing files, put moved files back, revert local edits. */
1267
1666
  async enforceLocal() {
1268
1667
  const restores = this.db.all().filter((i) => ACTIVE(i) && i.needsRestore && i.mode === "normal");
@@ -1293,6 +1692,7 @@ var Engine = class {
1293
1692
  continue;
1294
1693
  }
1295
1694
  if (!this.cache.has(cur.baseHash)) continue;
1695
+ if (this.gitWroteIt(cur)) continue;
1296
1696
  this.db.update(cur.instanceId, { ...this.writeInstance(cur, cur.path, cur.baseHash), lastError: null });
1297
1697
  this.log(`${cur.path}: local change reverted (use \`tether edit\` to change it)`);
1298
1698
  const byGit = cur.gitMode === "keep";
@@ -1383,7 +1783,7 @@ var Engine = class {
1383
1783
  if (st.isFile()) {
1384
1784
  const f = this.prepareFile(abs, name, rules, eol);
1385
1785
  await this.api.uploadBlobs([f]);
1386
- const r2 = await this.api.call("POST", "/artifacts", { repoId: repo.repoId, kind: "file", name, blob: f.sha256, size: f.size, eol: f.eol, exec: f.exec });
1786
+ const r2 = await this.api.call("POST", "/artifacts", { repoId: repo.repoId, kind: "file", name, blob: f.sha256, size: f.size, chunks: this.api.manifestFor(f.sha256), eol: f.eol, exec: f.exec });
1387
1787
  this.cache.put(f.sha256, f.bytes);
1388
1788
  const local = readLocal(abs, f.eol);
1389
1789
  this.db.insert({
@@ -1422,7 +1822,7 @@ var Engine = class {
1422
1822
  repoId: repo.repoId,
1423
1823
  kind: "folder",
1424
1824
  name,
1425
- files: prepared.map((f) => ({ relPath: f.rel, blob: f.sha256, size: f.size, eol: f.eol, exec: f.exec }))
1825
+ files: prepared.map((f) => ({ relPath: f.rel, blob: f.sha256, size: f.size, chunks: this.api.manifestFor(f.sha256), eol: f.eol, exec: f.exec }))
1426
1826
  });
1427
1827
  const folderInst = this.db.insert({
1428
1828
  instanceId: newInstanceId(),
@@ -1469,7 +1869,7 @@ var Engine = class {
1469
1869
  return { artifactId: r.artifact.artifactId, shareId: r.artifact.shareId, kind: "folder", path: abs, files: files2.length, skippedSymlinks: symlinks };
1470
1870
  }
1471
1871
  /** `tether add <path>`: owner adds a new file inside a managed folder. */
1472
- async add({ path: target }) {
1872
+ async add({ path: target, gitCommit }) {
1473
1873
  const abs = canonicalPath(path4.resolve(target));
1474
1874
  if (!statOrNull(abs)?.isFile()) throw new UserError(`${abs} is not a file`);
1475
1875
  if (this.instanceAt(abs)) throw new UserError(`${abs} is already managed`);
@@ -1482,7 +1882,7 @@ var Engine = class {
1482
1882
  const repo = (await this.api.call("GET", "/repos")).find((r) => r.repoId === folder.repoId);
1483
1883
  const f = this.prepareFile(abs, rel, repo?.eolDefaults ?? {});
1484
1884
  await this.api.uploadBlobs([f]);
1485
- const child = await this.api.call("POST", `/artifacts/${folder.artifactId}/children`, { relPath: rel, blob: f.sha256, size: f.size, eol: f.eol, exec: f.exec });
1885
+ const child = await this.api.call("POST", `/artifacts/${folder.artifactId}/children`, { relPath: rel, blob: f.sha256, size: f.size, chunks: this.api.manifestFor(f.sha256), eol: f.eol, exec: f.exec, gitCommit });
1486
1886
  this.cache.put(f.sha256, f.bytes);
1487
1887
  const local = readLocal(abs, f.eol);
1488
1888
  this.db.insert({
@@ -1533,8 +1933,21 @@ var Engine = class {
1533
1933
  const base = { repoId: a.repoId, role: r.role, canDelete: r.canDelete };
1534
1934
  if (a.kind === "file") {
1535
1935
  await this.ensureBlobs([{ artifactId: a.artifactId, version: a.version, sha: a.blob }]);
1536
- const existing = readLocal(target, a.eol);
1537
- if (existing && existing.hash !== a.blob) throw new UserError(`${target} already exists with different content; choose another destination`);
1936
+ let existing = readLocal(target, a.eol);
1937
+ let mode = "normal";
1938
+ let lastError = null;
1939
+ let note;
1940
+ if (existing && existing.hash !== a.blob) {
1941
+ const clash = this.gitClash(target, gitMode, a.relPath ?? a.name);
1942
+ if (clash === "replace") {
1943
+ existing = null;
1944
+ note = "replaced git's version with Tether's (git still has it)";
1945
+ } else {
1946
+ mode = "editing";
1947
+ lastError = "git's version differs from Tether's; run `tether done` to publish it or accept Tether's";
1948
+ note = "kept git's version; run `tether done` to publish it or accept Tether's";
1949
+ }
1950
+ }
1538
1951
  const st2 = existing ? { size: existing.size, mtimeMs: existing.mtimeMs, ...styleOf(existing) } : writeLocal(target, this.blobFor(a.blob), a.eol, { exec: a.exec });
1539
1952
  this.db.insert({
1540
1953
  ...base,
@@ -1544,7 +1957,8 @@ var Engine = class {
1544
1957
  name: a.name,
1545
1958
  path: canonicalPath(target),
1546
1959
  ...this.identity(target),
1547
- mode: "normal",
1960
+ mode,
1961
+ lastError,
1548
1962
  baseVersion: a.version,
1549
1963
  baseHash: a.blob,
1550
1964
  ...st2,
@@ -1552,18 +1966,17 @@ var Engine = class {
1552
1966
  exec: a.exec,
1553
1967
  remoteVersion: a.version
1554
1968
  });
1555
- return { kind: "file", path: canonicalPath(target), role: r.role, version: a.version };
1969
+ return { kind: "file", path: canonicalPath(target), role: r.role, version: a.version, note };
1556
1970
  }
1557
1971
  const st = statOrNull(target);
1558
1972
  if (st && !st.isDirectory()) throw new UserError(`${target} exists and is not a folder`);
1559
1973
  await this.ensureBlobs(r.children.map((c) => ({ artifactId: c.artifactId, version: c.version, sha: c.blob })));
1560
- const clashes = [];
1974
+ const clashes = /* @__PURE__ */ new Map();
1561
1975
  for (const c of r.children) {
1562
1976
  const p = path4.join(target, ...c.relPath.split("/"));
1563
1977
  const ex = readLocal(p, c.eol);
1564
- if (ex && ex.hash !== c.blob) clashes.push(c.relPath);
1978
+ if (ex && ex.hash !== c.blob) clashes.set(c.relPath, this.gitClash(p, gitMode, c.relPath));
1565
1979
  }
1566
- if (clashes.length) throw new UserError(`${target} already has different files: ${clashes.join(", ")}; choose another destination`);
1567
1980
  fs6.mkdirSync(target, { recursive: true });
1568
1981
  const folderInst = this.db.insert({
1569
1982
  ...base,
@@ -1579,7 +1992,8 @@ var Engine = class {
1579
1992
  });
1580
1993
  for (const c of r.children) {
1581
1994
  const p = path4.join(folderInst.path, ...c.relPath.split("/"));
1582
- const ex = readLocal(p, c.eol);
1995
+ const clash = clashes.get(c.relPath);
1996
+ const ex = clash === "replace" ? null : readLocal(p, c.eol);
1583
1997
  const cst = ex ? { size: ex.size, mtimeMs: ex.mtimeMs, ...styleOf(ex) } : writeLocal(p, this.blobFor(c.blob), c.eol, { exec: c.exec });
1584
1998
  this.db.insert({
1585
1999
  ...base,
@@ -1591,10 +2005,11 @@ var Engine = class {
1591
2005
  relPath: c.relPath,
1592
2006
  path: canonicalPath(p),
1593
2007
  ...this.identity(p),
1594
- mode: "normal",
1595
2008
  baseVersion: c.version,
1596
2009
  baseHash: c.blob,
1597
2010
  ...cst,
2011
+ mode: clash === "keep-git" ? "editing" : "normal",
2012
+ lastError: clash === "keep-git" ? "git's version differs from Tether's; run `tether done`" : null,
1598
2013
  eolMode: c.eol,
1599
2014
  exec: c.exec,
1600
2015
  remoteVersion: c.version
@@ -1602,6 +2017,21 @@ var Engine = class {
1602
2017
  }
1603
2018
  return { kind: "folder", path: folderInst.path, role: r.role, files: r.children.length, version: a.version };
1604
2019
  }
2020
+ /**
2021
+ * A pulled file already exists with different content. Only allowed when git holds that
2022
+ * content safely (tracked, no uncommitted changes): keep mode replaces it with Tether's version,
2023
+ * follow mode keeps git's version in editing mode. Otherwise the pull is refused.
2024
+ */
2025
+ gitClash(p, gitMode, label) {
2026
+ const repo = gitMode && findRepo(p);
2027
+ if (repo && gitBinary() && isTracked(repo.root, p)) {
2028
+ const blob = fileBlob(repo.root, p);
2029
+ const clean = blob && (blob === blobAt(repo.root, "HEAD", p) || blob === indexBlob(repo.root, p));
2030
+ if (clean) return gitMode === "keep" ? "replace" : "keep-git";
2031
+ throw new UserError(`${label} differs from Tether's version and has uncommitted git changes; commit or stash them first`);
2032
+ }
2033
+ throw new UserError(`${p} already exists with different content; choose another destination`);
2034
+ }
1605
2035
  /** Instances addressed by a path: a file, a managed folder (with its files), or any directory. */
1606
2036
  targets(p) {
1607
2037
  const abs = canonicalPath(path4.resolve(p));
@@ -1669,6 +2099,7 @@ You can still run \`tether edit\` without --realtime, but expect merge conflicts
1669
2099
  }
1670
2100
  async unmanage({ path: p }) {
1671
2101
  for (const i of this.targets(p).filter((x) => x.realtime)) await this.releaseRealtime(i);
2102
+ for (const i of this.targets(p)) this.releaseGit(i);
1672
2103
  return { changed: this.setMode(p, "unmanaged", ["normal", "editing"]) };
1673
2104
  }
1674
2105
  /**
@@ -1796,16 +2227,16 @@ You can still run \`tether edit\` without --realtime, but expect merge conflicts
1796
2227
  case "push": {
1797
2228
  const local = readLocal(inst.path, inst.eolMode);
1798
2229
  const change = {};
1799
- if (local && local.hash !== inst.baseHash) {
1800
- await this.api.uploadBlobs([{ sha256: local.hash, bytes: local.canonical }]);
1801
- this.cache.put(local.hash, local.canonical);
1802
- Object.assign(change, { blob: local.hash, size: local.canonical.length });
1803
- }
2230
+ if (local && local.hash !== inst.baseHash) Object.assign(change, await this.uploadContent(local, inst.artifactId));
1804
2231
  if (folder) {
1805
2232
  const rel = this.localRel(inst, folder);
1806
2233
  if (rel && rel !== inst.relPath) change.relPath = rel;
1807
2234
  }
1808
2235
  const baseVersion = item.incoming ? item.serverVersion : inst.baseVersion;
2236
+ if (inst.gitMode === "follow" && inst.gitRoot && change.blob) {
2237
+ const g = changeFromGit(inst.gitRoot, inst.path);
2238
+ if (g.fromGit && g.commit) change.gitCommit = g.commit;
2239
+ }
1809
2240
  const r = await this.api.call("POST", `/artifacts/${inst.artifactId}/commit`, { baseVersion, change });
1810
2241
  const state = r.state;
1811
2242
  this.db.update(inst.instanceId, {
@@ -1818,7 +2249,7 @@ You can still run \`tether edit\` without --realtime, but expect merge conflicts
1818
2249
  mtimeMs: local?.mtimeMs ?? null,
1819
2250
  ...local ? styleOf(local) : {}
1820
2251
  });
1821
- return { version: r.version };
2252
+ return { version: r.version, sent: change.blob ? describeTransfer(this.api.transfers.get(change.blob), "sent") : "" };
1822
2253
  }
1823
2254
  case "delete": {
1824
2255
  await this.api.call("POST", `/artifacts/${inst.artifactId}/commit`, { baseVersion: item.serverVersion, change: { deleted: true } });
@@ -1844,7 +2275,7 @@ You can still run \`tether edit\` without --realtime, but expect merge conflicts
1844
2275
  if (!inst || inst.kind !== "file") throw new UserError("diff needs a managed file");
1845
2276
  const { artifact } = await this.api.call("GET", `/artifacts/${inst.artifactId}`);
1846
2277
  if (artifact.deleted) throw new UserError("the artifact has been deleted on the server");
1847
- await this.ensureBlobs([{ artifactId: inst.artifactId, version: artifact.version, sha: artifact.blob }]);
2278
+ await this.ensureBlobs([{ artifactId: inst.artifactId, version: artifact.version, sha: artifact.blob, base: inst.baseHash }]);
1848
2279
  const server = this.cache.get(artifact.blob);
1849
2280
  const local = inst.detached ? null : readLocal(inst.path, inst.eolMode);
1850
2281
  const label = path4.basename(inst.path);
@@ -1878,6 +2309,8 @@ You can still run \`tether edit\` without --realtime, but expect merge conflicts
1878
2309
  if (i.orphanReason) flags.push(i.orphanReason);
1879
2310
  if (i.realtime && i.mode === "editing") flags.push("realtime: your saves are pushed live");
1880
2311
  if (i.gitMode) flags.push(`git: ${i.gitMode}`);
2312
+ if (i.gitWaiting) flags.push("newer version arrives with your next git pull");
2313
+ if (i.gitOverlay) flags.push("Tether-only version (hidden from git)");
1881
2314
  if (i.remoteRealtime && i.mode !== "orphaned") flags.push(`realtime edits by ${JSON.parse(i.remoteRealtime).email}`);
1882
2315
  return {
1883
2316
  instanceId: i.instanceId,
@@ -1933,6 +2366,68 @@ import { spawn } from "node:child_process";
1933
2366
  var AUMID = "Tether.Sync";
1934
2367
  var FLUSH_MS = 3e3;
1935
2368
  var LEVELS = { info: 0, warn: 1, error: 2 };
2369
+ var SHORTCUT_SCRIPT = String.raw`Add-Type -TypeDefinition @'
2370
+ using System;
2371
+ using System.Text;
2372
+ using System.Runtime.InteropServices;
2373
+ using System.Runtime.InteropServices.ComTypes;
2374
+
2375
+ namespace TetherShortcut {
2376
+ [ComImport, Guid("00021401-0000-0000-C000-000000000046")] class CShellLink {}
2377
+
2378
+ [ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("000214F9-0000-0000-C000-000000000046")]
2379
+ interface IShellLinkW {
2380
+ void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder f, int cch, IntPtr fd, uint flags);
2381
+ void GetIDList(out IntPtr pidl);
2382
+ void SetIDList(IntPtr pidl);
2383
+ void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder s, int cch);
2384
+ void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string s);
2385
+ void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder s, int cch);
2386
+ void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string s);
2387
+ void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder s, int cch);
2388
+ void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string s);
2389
+ void GetHotkey(out short k);
2390
+ void SetHotkey(short k);
2391
+ void GetShowCmd(out int c);
2392
+ void SetShowCmd(int c);
2393
+ void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder s, int cch, out int i);
2394
+ void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string s, int i);
2395
+ void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string s, uint r);
2396
+ void Resolve(IntPtr hwnd, uint flags);
2397
+ void SetPath([MarshalAs(UnmanagedType.LPWStr)] string s);
2398
+ }
2399
+
2400
+ [StructLayout(LayoutKind.Sequential, Pack = 4)] public struct PropertyKey { public Guid fmtid; public uint pid; }
2401
+ [StructLayout(LayoutKind.Explicit)] public struct PropVariant { [FieldOffset(0)] public ushort vt; [FieldOffset(8)] public IntPtr p; }
2402
+
2403
+ [ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99")]
2404
+ interface IPropertyStore {
2405
+ void GetCount(out uint c);
2406
+ void GetAt(uint i, out PropertyKey k);
2407
+ void GetValue(ref PropertyKey k, out PropVariant v);
2408
+ void SetValue(ref PropertyKey k, ref PropVariant v);
2409
+ void Commit();
2410
+ }
2411
+
2412
+ public static class Maker {
2413
+ public static void Create(string lnk, string aumid, string url) {
2414
+ var link = (IShellLinkW)new CShellLink();
2415
+ link.SetPath(Environment.ExpandEnvironmentVariables("%SystemRoot%\\explorer.exe"));
2416
+ link.SetArguments(url);
2417
+ link.SetDescription("Tether");
2418
+ link.SetIconLocation(Environment.ExpandEnvironmentVariables("%SystemRoot%\\System32\\imageres.dll"), 228);
2419
+ var store = (IPropertyStore)link;
2420
+ var key = new PropertyKey { fmtid = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"), pid = 5 };
2421
+ var v = new PropVariant { vt = 31, p = Marshal.StringToCoTaskMemUni(aumid) };
2422
+ store.SetValue(ref key, ref v);
2423
+ store.Commit();
2424
+ ((IPersistFile)link).Save(lnk, true);
2425
+ Marshal.FreeCoTaskMem(v.p);
2426
+ }
2427
+ }
2428
+ }
2429
+ '@
2430
+ `;
1936
2431
  var Notifier = class {
1937
2432
  /** setting: 'all' | 'errors' | 'off' (config key `notifications`). */
1938
2433
  constructor({ setting = () => "all", consoleUrl = () => "", log = () => {
@@ -1992,7 +2487,12 @@ Also: ${events.slice(1, 4).map((e) => e.short ?? e.title).join("; ")}${more > 3
1992
2487
  const esc = (s) => s.replace(/[<>&"']/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;", "'": "&apos;" })[c]);
1993
2488
  const launch = url ? ` activationType="protocol" launch="${esc(url)}"` : "";
1994
2489
  const xml = `<toast${launch}><visual><binding template="ToastGeneric"><text>${esc(title)}</text><text>${esc(body)}</text></binding></visual></toast>`;
1995
- const register = this.registered ? "" : `$k='HKCU:\\Software\\Classes\\AppUserModelId\\${AUMID}'; if (-not (Test-Path $k)) { New-Item -Path $k -Force | Out-Null }; Set-ItemProperty -Path $k -Name DisplayName -Value 'Tether';`;
2490
+ const consoleUrl = (this.consoleUrl() || "https://tether-b10cf.web.app").replace(/'/g, "''");
2491
+ const register = this.registered ? "" : `$k='HKCU:\\Software\\Classes\\AppUserModelId\\${AUMID}'; if (-not (Test-Path $k)) { New-Item -Path $k -Force | Out-Null }; Set-ItemProperty -Path $k -Name DisplayName -Value 'Tether';
2492
+ $lnk = Join-Path $env:APPDATA 'Microsoft\\Windows\\Start Menu\\Programs\\Tether.lnk'
2493
+ if (-not (Test-Path $lnk)) { try {
2494
+ ${SHORTCUT_SCRIPT}
2495
+ [TetherShortcut.Maker]::Create($lnk, '${AUMID}', '${consoleUrl}') } catch {} }`;
1996
2496
  this.registered = true;
1997
2497
  const script = `${register}
1998
2498
  [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
@@ -2004,9 +2504,90 @@ $x = New-Object Windows.Data.Xml.Dom.XmlDocument; $x.LoadXml('${xml.replace(/'/g
2004
2504
  }
2005
2505
  };
2006
2506
 
2507
+ // packages/client/src/feed.js
2508
+ var RETRY_MS = 5 * 60 * 1e3;
2509
+ var FeedListener = class {
2510
+ /** onChange(seq) is called with the feed counter on every snapshot from the server. */
2511
+ constructor({ api, log, onChange }) {
2512
+ this.api = api;
2513
+ this.log = log;
2514
+ this.onChange = onChange;
2515
+ this.live = false;
2516
+ this.unsupported = false;
2517
+ this.session = 0;
2518
+ }
2519
+ async start() {
2520
+ if (this.unsupported || this.unsub || this.starting) return;
2521
+ this.starting = true;
2522
+ const session = ++this.session;
2523
+ try {
2524
+ const t = await this.api.call("POST", "/feed/token");
2525
+ const [{ initializeApp }, A, F] = await Promise.all([import("firebase/app"), import("firebase/auth"), import("firebase/firestore")]);
2526
+ if (session !== this.session) return;
2527
+ this.app = initializeApp(t.firebase, `tether-feed-${session}-${Date.now()}`);
2528
+ const auth = A.getAuth(this.app);
2529
+ const db = F.getFirestore(this.app);
2530
+ if (t.emulators?.auth) A.connectAuthEmulator(auth, `http://${t.emulators.auth}`, { disableWarnings: true });
2531
+ if (t.emulators?.firestore) {
2532
+ const [host, port] = t.emulators.firestore.split(":");
2533
+ F.connectFirestoreEmulator(db, host, Number(port));
2534
+ }
2535
+ await A.signInWithCustomToken(auth, t.customToken);
2536
+ if (session !== this.session) return this.dispose();
2537
+ this.unsub = F.onSnapshot(F.doc(db, "feeds", t.uid), { includeMetadataChanges: true }, (snap) => {
2538
+ const wasLive = this.live;
2539
+ this.live = !snap.metadata.fromCache;
2540
+ if (this.live && !wasLive) this.log("push channel connected");
2541
+ if (this.live) this.onChange(snap.get("seq") ?? 0);
2542
+ }, (e) => this.fail(e, session));
2543
+ } catch (e) {
2544
+ this.fail(e, session);
2545
+ } finally {
2546
+ this.starting = false;
2547
+ }
2548
+ }
2549
+ fail(e, session) {
2550
+ if (session !== this.session) return;
2551
+ if (e instanceof ApiError && e.status === 404) {
2552
+ this.unsupported = true;
2553
+ this.stop();
2554
+ return;
2555
+ }
2556
+ this.log(`push channel unavailable (${e.message}); polling until it reconnects`);
2557
+ this.stop();
2558
+ clearTimeout(this.retry);
2559
+ this.retry = setTimeout(() => this.start(), RETRY_MS);
2560
+ this.retry.unref?.();
2561
+ }
2562
+ stop() {
2563
+ this.session++;
2564
+ clearTimeout(this.retry);
2565
+ this.unsub?.();
2566
+ this.unsub = null;
2567
+ if (this.live) this.log("push channel closed");
2568
+ this.live = false;
2569
+ this.dispose();
2570
+ }
2571
+ dispose() {
2572
+ const app = this.app;
2573
+ this.app = null;
2574
+ if (app) import("firebase/app").then(({ deleteApp }) => deleteApp(app)).catch(() => {
2575
+ });
2576
+ }
2577
+ /** After a login, logout or server change: start over (or stay off when signed out). */
2578
+ restart() {
2579
+ this.unsupported = false;
2580
+ this.stop();
2581
+ this.start();
2582
+ }
2583
+ };
2584
+
2007
2585
  // packages/client/src/daemon.js
2008
2586
  import { fileURLToPath } from "node:url";
2009
2587
  var REALTIME_TICK_SECONDS = 3;
2588
+ var IDLE_AFTER_MS = 10 * 60 * 1e3;
2589
+ var IDLE_MAX_SECONDS = 180;
2590
+ var PUSH_TICK_SECONDS = 15 * 60;
2010
2591
  var UPDATE_CHECK_MS = 24 * 60 * 60 * 1e3;
2011
2592
  async function runDaemon() {
2012
2593
  const home = tetherHome();
@@ -2044,23 +2625,54 @@ async function runDaemon() {
2044
2625
  let realtimeTimer = null;
2045
2626
  let stopping = false;
2046
2627
  let latest = null;
2047
- async function runTick(opts) {
2628
+ let remoteAt = 0;
2629
+ async function runTick({ remote } = {}) {
2630
+ remote ??= !feed.live || engine.realtimeActive || Date.now() - remoteAt >= PUSH_TICK_SECONDS * 1e3;
2631
+ if (remote) remoteAt = Date.now();
2048
2632
  try {
2049
- await engine.locked(() => engine.tick(opts));
2633
+ await engine.locked(() => engine.tick({ remote }));
2050
2634
  } catch (e) {
2051
2635
  log(`tick failed: ${e.stack || e.message}`);
2052
2636
  notifier.notify({ kind: "error", level: "error", key: "tick-failed", cooldownMs: 3600 * 1e3, title: "Tether hit an error", body: `${e.message}. Run "tether log" for details.` });
2053
2637
  }
2638
+ if (engine.gitPassDue) {
2639
+ engine.gitPassDue = false;
2640
+ gitPassSoon();
2641
+ }
2054
2642
  refreshWatchers();
2055
2643
  scheduleGrace();
2056
2644
  schedule();
2057
2645
  }
2646
+ let activeAt = Date.now();
2647
+ let timerDue = 0;
2648
+ function interval() {
2649
+ const base = Math.max(MIN_TICK_SECONDS, config.get("tickSeconds"));
2650
+ if (engine.realtimeActive) return Math.min(base, REALTIME_TICK_SECONDS);
2651
+ const idle = Date.now() - Math.max(activeAt, engine.activityAt ?? 0);
2652
+ if (idle < IDLE_AFTER_MS) return base;
2653
+ return Math.max(base, Math.min(IDLE_MAX_SECONDS, base * 2 ** Math.floor(idle / IDLE_AFTER_MS)));
2654
+ }
2058
2655
  function schedule() {
2059
2656
  clearTimeout(timer);
2060
2657
  if (stopping) return;
2061
- const seconds = engine.realtimeActive ? Math.min(config.get("tickSeconds"), REALTIME_TICK_SECONDS) : config.get("tickSeconds");
2658
+ const seconds = interval();
2659
+ timerDue = Date.now() + seconds * 1e3;
2062
2660
  timer = setTimeout(() => runTick(), seconds * 1e3);
2063
2661
  }
2662
+ const feed = new FeedListener({
2663
+ api,
2664
+ log,
2665
+ onChange: (seq) => {
2666
+ if (stopping || engine.feed?.cursor === seq) return;
2667
+ clearTimeout(timer);
2668
+ timerDue = Date.now() + 300;
2669
+ timer = setTimeout(() => runTick({ remote: true }), 300);
2670
+ }
2671
+ });
2672
+ function markActive() {
2673
+ activeAt = Date.now();
2674
+ if (timer && timerDue - activeAt > interval() * 1e3) schedule();
2675
+ }
2064
2676
  function realtimePass(delay = 1e3) {
2065
2677
  if (stopping || !engine.realtimeInstances().length) return;
2066
2678
  clearTimeout(realtimeTimer);
@@ -2101,10 +2713,29 @@ async function runDaemon() {
2101
2713
  if (engine.graceDue) quickPass(Math.max(50, engine.graceDue - Date.now() + 50));
2102
2714
  }
2103
2715
  const watchers = /* @__PURE__ */ new Map();
2716
+ let gitTimer = null;
2717
+ function gitPassSoon() {
2718
+ clearTimeout(gitTimer);
2719
+ gitTimer = setTimeout(async () => {
2720
+ gitTimer = null;
2721
+ try {
2722
+ await engine.locked(async () => {
2723
+ engine.busyCache = /* @__PURE__ */ new Map();
2724
+ await engine.locateAll();
2725
+ await engine.gitPass();
2726
+ await engine.enforceLocal();
2727
+ });
2728
+ } catch (e) {
2729
+ log(`git pass failed: ${e.message}`);
2730
+ }
2731
+ }, 600);
2732
+ }
2104
2733
  function refreshWatchers() {
2105
2734
  const want = /* @__PURE__ */ new Map();
2106
2735
  for (const i of db.all()) {
2107
- if (i.mode === "orphaned" || i.detached) continue;
2736
+ if (i.mode === "orphaned") continue;
2737
+ if (i.gitDir && !want.has(i.gitDir)) want.set(i.gitDir, "git");
2738
+ if (i.detached) continue;
2108
2739
  if (i.kind === "folder") want.set(i.path, true);
2109
2740
  else if (!i.folderInstanceId) want.set(path5.dirname(i.path), false);
2110
2741
  }
@@ -2114,10 +2745,11 @@ async function runDaemon() {
2114
2745
  watchers.delete(dir);
2115
2746
  }
2116
2747
  }
2117
- for (const [dir, recursive] of want) {
2748
+ for (const [dir, kind] of want) {
2118
2749
  if (watchers.has(dir)) continue;
2119
2750
  try {
2120
- const w = fs8.watch(dir, { recursive, persistent: false }, () => {
2751
+ const w = kind === "git" ? fs8.watch(dir, { persistent: false }, () => gitPassSoon()) : fs8.watch(dir, { recursive: kind, persistent: false }, () => {
2752
+ markActive();
2121
2753
  quickPass(250);
2122
2754
  realtimePass();
2123
2755
  });
@@ -2162,7 +2794,7 @@ async function runDaemon() {
2162
2794
  return { state, url: `${config.consoleUrl.replace(/\/$/, "")}/cli-login?port=${port}&state=${state}` };
2163
2795
  }
2164
2796
  const L = (fn) => (params) => engine.locked(() => fn(params));
2165
- const soon = () => setTimeout(() => runTick(), 10);
2797
+ const soon = () => setTimeout(() => runTick({ remote: true }), 10);
2166
2798
  const handlers = {
2167
2799
  ping: () => ({ pid: process.pid, version: VERSION, home, tracker: tracker.name, server: config.get("server") }),
2168
2800
  async status(p) {
@@ -2178,11 +2810,13 @@ async function runDaemon() {
2178
2810
  };
2179
2811
  },
2180
2812
  sync: async () => {
2181
- await runTick();
2813
+ await runTick({ remote: true });
2182
2814
  return { offline: engine.offline };
2183
2815
  },
2184
2816
  async "login.password"({ email, password, signup }) {
2185
2817
  const user = await api.passwordLogin({ email, password, signup });
2818
+ engine.feed = null;
2819
+ feed.restart();
2186
2820
  soon();
2187
2821
  return user;
2188
2822
  },
@@ -2192,13 +2826,19 @@ async function runDaemon() {
2192
2826
  if (!p) throw new UserError("no login in progress");
2193
2827
  try {
2194
2828
  const user = await p;
2829
+ engine.feed = null;
2830
+ feed.restart();
2195
2831
  soon();
2196
2832
  return user;
2197
2833
  } finally {
2198
2834
  logins.delete(state);
2199
2835
  }
2200
2836
  },
2201
- logout: async () => api.logout(),
2837
+ logout: async () => {
2838
+ engine.feed = null;
2839
+ feed.stop();
2840
+ return api.logout();
2841
+ },
2202
2842
  whoami: () => api.call("GET", "/me"),
2203
2843
  manage: L((p) => engine.manage(p)),
2204
2844
  add: L((p) => engine.add(p)),
@@ -2258,6 +2898,10 @@ async function runDaemon() {
2258
2898
  "config.get": ({ key }) => key ? { [key]: config.get(key) } : config.values,
2259
2899
  "config.set": ({ key, value }) => {
2260
2900
  config.set(key, value);
2901
+ if (key === "server") {
2902
+ engine.feed = null;
2903
+ feed.restart();
2904
+ }
2261
2905
  if (key === "tickSeconds") schedule();
2262
2906
  return { [key]: config.get(key) };
2263
2907
  },
@@ -2267,11 +2911,15 @@ async function runDaemon() {
2267
2911
  return { stopping: true };
2268
2912
  }
2269
2913
  };
2914
+ const CHANGES_WATCHED = /* @__PURE__ */ new Set(["manage", "add", "pull", "edit", "unmanage", "git.mode", "resolve", "locate"]);
2270
2915
  async function handle(method, params) {
2271
2916
  const h = handlers[method];
2272
2917
  if (!h) throw new UserError(`unknown command ${method}`);
2918
+ if (method !== "ping") markActive();
2273
2919
  try {
2274
- return await h(params);
2920
+ const result = await h(params);
2921
+ if (CHANGES_WATCHED.has(method)) refreshWatchers();
2922
+ return result;
2275
2923
  } catch (e) {
2276
2924
  if (e instanceof ApiError || e instanceof UserError) throw e;
2277
2925
  if (e.name === "OfflineError") throw e;
@@ -2289,6 +2937,9 @@ async function runDaemon() {
2289
2937
  clearTimeout(quickTimer);
2290
2938
  clearTimeout(realtimeTimer);
2291
2939
  clearInterval(updateTimer);
2940
+ clearInterval(repoTimer);
2941
+ clearTimeout(gitTimer);
2942
+ feed.stop();
2292
2943
  for (const w of watchers.values()) w.close();
2293
2944
  server.close();
2294
2945
  engine.locked(() => db.close()).finally(() => {
@@ -2299,10 +2950,16 @@ async function runDaemon() {
2299
2950
  }
2300
2951
  process.on("SIGINT", () => shutdown(0));
2301
2952
  process.on("SIGTERM", () => shutdown(0));
2302
- await engine.locked(() => engine.syncGitHooks()).catch((e) => log(`git hooks: ${e.message}`));
2953
+ await engine.locked(() => {
2954
+ engine.cleanupLegacyHooks();
2955
+ engine.detectNewRepos();
2956
+ }).catch((e) => log(`git setup: ${e.message}`));
2957
+ const repoTimer = setInterval(() => engine.locked(() => engine.detectNewRepos()).catch(() => {
2958
+ }), 60 * 60 * 1e3);
2303
2959
  const updateTimer = setInterval(checkForUpdate, UPDATE_CHECK_MS);
2304
2960
  setTimeout(checkForUpdate, 5e3);
2305
2961
  await runTick();
2962
+ feed.start();
2306
2963
  }
2307
2964
  export {
2308
2965
  VERSION,