tether-sync 0.2.0 → 0.4.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-NMLHD2KU.js";
16
27
 
17
28
  // packages/client/src/daemon.js
18
29
  import fs8 from "node:fs";
@@ -113,11 +124,16 @@ var COLUMNS = {
113
124
  gitRoot: "TEXT",
114
125
  gitDir: "TEXT",
115
126
  gitHooks: "TEXT",
127
+ // unused since 0.2.1 (hooks were replaced by watching the git directory)
128
+ gitWaiting: "INTEGER DEFAULT 0",
129
+ // follow: a newer Tether version came from a git commit not pulled here yet
130
+ gitOverlay: "INTEGER DEFAULT 0",
131
+ // follow: holds a Tether-only version that git does not have (hidden from git)
116
132
  lastError: "TEXT",
117
133
  createdAt: "REAL",
118
134
  updatedAt: "REAL"
119
135
  };
120
- var BOOL = /* @__PURE__ */ new Set(["realtime", "hasBom", "exec", "canDelete", "detached", "needsRestore"]);
136
+ var BOOL = /* @__PURE__ */ new Set(["gitOverlay", "gitWaiting", "realtime", "hasBom", "exec", "canDelete", "detached", "needsRestore"]);
121
137
  var LocalDb = class {
122
138
  constructor(file) {
123
139
  this.db = new DatabaseSync(file);
@@ -342,7 +358,73 @@ function randomString(len) {
342
358
  }
343
359
  var newInstanceId = () => "i_" + randomString(10);
344
360
 
361
+ // packages/shared/src/chunks.js
362
+ var CHUNK_MIN = 2 * 1024;
363
+ var CHUNK_AVG = 8 * 1024;
364
+ var CHUNK_MAX = 64 * 1024;
365
+ var MASK_HARD = 4294836224;
366
+ var MASK_EASY = 4292870144;
367
+ var GEAR = (() => {
368
+ let a = 2122219134;
369
+ const t = new Uint32Array(256);
370
+ for (let i = 0; i < 256; i++) {
371
+ a = a + 1831565813 | 0;
372
+ let x = Math.imul(a ^ a >>> 15, 1 | a);
373
+ x = x + Math.imul(x ^ x >>> 7, 61 | x) ^ x;
374
+ t[i] = (x ^ x >>> 14) >>> 0;
375
+ }
376
+ return t;
377
+ })();
378
+ function chunkBoundaries(buf) {
379
+ const out = [];
380
+ const n = buf.length;
381
+ let start = 0;
382
+ while (start < n) {
383
+ if (n - start <= CHUNK_MIN) {
384
+ out.push({ o: start, s: n - start });
385
+ break;
386
+ }
387
+ const end = Math.min(n, start + CHUNK_MAX);
388
+ const normal = Math.min(end, start + CHUNK_AVG);
389
+ let h = 0;
390
+ let cut = end;
391
+ let i = start + CHUNK_MIN;
392
+ for (; i < normal; i++) {
393
+ h = (h << 1) + GEAR[buf[i]] >>> 0;
394
+ if ((h & MASK_HARD) === 0) {
395
+ cut = i + 1;
396
+ break;
397
+ }
398
+ }
399
+ if (cut === end) {
400
+ for (; i < end; i++) {
401
+ h = (h << 1) + GEAR[buf[i]] >>> 0;
402
+ if ((h & MASK_EASY) === 0) {
403
+ cut = i + 1;
404
+ break;
405
+ }
406
+ }
407
+ }
408
+ out.push({ o: start, s: cut - start });
409
+ start = cut;
410
+ }
411
+ return out;
412
+ }
413
+ function chunkContent(buf) {
414
+ return chunkBoundaries(buf).map(({ o, s }) => {
415
+ const bytes = buf.subarray(o, o + s);
416
+ return { h: sha256(bytes), s, bytes };
417
+ });
418
+ }
419
+
345
420
  // packages/client/src/api.js
421
+ var PUT_BATCH_BYTES = 1024 * 1024;
422
+ var PUT_PARALLEL = 4;
423
+ var PUT_BATCH_CHUNKS = 100;
424
+ var FETCH_BATCH_BYTES = 1024 * 1024;
425
+ var FETCH_PARALLEL = 4;
426
+ var kb = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${Math.round(n / 1024)} KB` : `${(n / 1048576).toFixed(1)} MB`;
427
+ var describeTransfer = (t, verb) => t ? `${verb} ${t.moved} of ${t.total} chunk${t.total === 1 ? "" : "s"}, ${kb(t.bytes)}` : "";
346
428
  var ApiError = class extends Error {
347
429
  constructor(status, body) {
348
430
  super(body?.message ?? `HTTP ${status}`);
@@ -375,6 +457,39 @@ var Api = class {
375
457
  this.config = config;
376
458
  this.credentials = credentials;
377
459
  this.accessToken = null;
460
+ this.manifests = /* @__PURE__ */ new Map();
461
+ this.transfers = /* @__PURE__ */ new Map();
462
+ }
463
+ /** Authenticated POST with a binary body or a binary response; refreshes the token once on 401. */
464
+ async binary(p, { json, body, headers = {} }) {
465
+ const send = async (token) => {
466
+ let res;
467
+ try {
468
+ res = await fetch(this.base + "/v1" + p, {
469
+ method: "POST",
470
+ headers: { authorization: `Bearer ${token}`, "content-type": json ? "application/json" : "application/octet-stream", ...headers },
471
+ body: json ? JSON.stringify(json) : body,
472
+ signal: AbortSignal.timeout(18e4)
473
+ });
474
+ } catch (e) {
475
+ throw new OfflineError(`cannot reach the Tether server at ${this.base} (${e.cause?.code ?? e.message})`);
476
+ }
477
+ if (!res.ok) throw new ApiError(res.status, (await res.json().catch(() => ({}))).error);
478
+ return Buffer.from(await res.arrayBuffer());
479
+ };
480
+ try {
481
+ return await send(await this.token());
482
+ } catch (e) {
483
+ if (e instanceof ApiError && e.status === 401 && this.accessToken) {
484
+ this.accessToken = null;
485
+ return send(await this.token());
486
+ }
487
+ throw e;
488
+ }
489
+ }
490
+ /** The chunk list to commit for content uploaded by uploadBlobs (undefined on old servers). */
491
+ manifestFor(sha) {
492
+ return this.manifests.get(sha);
378
493
  }
379
494
  get base() {
380
495
  return this.config.get("server").replace(/\/$/, "");
@@ -485,10 +600,58 @@ var Api = class {
485
600
  this.credentials.clear();
486
601
  this.accessToken = null;
487
602
  }
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;
603
+ /**
604
+ * Uploads content as chunks (delta sync): every file is split into content-defined chunks and
605
+ * only the chunks the server does not have are sent. blobs: [{ sha256, bytes }] (canonical).
606
+ * Afterwards manifestFor(sha) gives each file's chunk list for the commit.
607
+ */
608
+ async uploadBlobs(blobs, { artifactId } = {}) {
609
+ const files2 = [...new Map(blobs.map((b) => [b.sha256, b])).values()];
610
+ if (!files2.length) return;
611
+ const all = /* @__PURE__ */ new Map();
612
+ for (const f of files2) {
613
+ const parts = chunkContent(f.bytes);
614
+ this.manifests.set(f.sha256, parts.map(({ h, s }) => ({ h, s })));
615
+ for (const c of parts) all.set(c.h, c.bytes);
616
+ }
617
+ let missing;
618
+ try {
619
+ ({ missing } = await this.call("POST", "/chunks/missing", { hashes: [...all.keys()], artifactId }));
620
+ } catch (e) {
621
+ if (!(e instanceof ApiError && e.status === 404)) throw e;
622
+ for (const f of files2) this.manifests.delete(f.sha256);
623
+ return this.uploadWhole(files2);
624
+ }
625
+ const batches = [];
626
+ let batch = [];
627
+ let size = 0;
628
+ for (const h of missing) {
629
+ const bytes = all.get(h);
630
+ if (batch.length && (batch.length >= PUT_BATCH_CHUNKS || size + bytes.length > PUT_BATCH_BYTES)) {
631
+ batches.push(batch);
632
+ batch = [];
633
+ size = 0;
634
+ }
635
+ batch.push({ h, bytes });
636
+ size += bytes.length;
637
+ }
638
+ if (batch.length) batches.push(batch);
639
+ let next = 0;
640
+ await Promise.all(Array.from({ length: Math.min(PUT_PARALLEL, batches.length) }, async () => {
641
+ while (next < batches.length) {
642
+ const b = batches[next++];
643
+ 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(",") } });
644
+ }
645
+ }));
646
+ const sent = new Set(missing);
647
+ for (const f of files2) {
648
+ const m = this.manifests.get(f.sha256);
649
+ const moved = m.filter((c) => sent.has(c.h));
650
+ this.transfers.set(f.sha256, { moved: moved.length, total: m.length, bytes: moved.reduce((a, c) => a + c.s, 0) });
651
+ }
652
+ }
653
+ /** Whole-file upload through signed URLs, for servers without delta sync. */
654
+ async uploadWhole(unique) {
492
655
  const { upload } = await this.call("POST", "/blobs/upload", { blobs: unique.map((b) => ({ sha256: b.sha256, size: b.bytes.length })) });
493
656
  const bySha = new Map(unique.map((b) => [b.sha256, b]));
494
657
  for (const u of upload) {
@@ -496,10 +659,63 @@ var Api = class {
496
659
  if (!res.ok && res.status !== 412) throw new ApiError(res.status, { message: `upload failed (${res.status}): ${(await res.text().catch(() => "")).slice(0, 200)}` });
497
660
  }
498
661
  }
499
- /** Downloads blobs for [{ artifactId, version? }] into the cache; skips blobs already cached. */
662
+ /**
663
+ * Downloads content into the cache, by chunks (delta sync). items: [{ artifactId, version, sha,
664
+ * base? }] where base is content already cached (usually the version on disk): its chunks are
665
+ * reused, so only chunks that changed are downloaded.
666
+ */
500
667
  async fetchBlobs(cache, items) {
501
- if (!items.length) return [];
502
- const { downloads } = await this.call("POST", "/blobs/download", { items });
668
+ const need = [...new Map(items.filter((i) => !i.sha || !cache.has(i.sha)).map((i) => [`${i.artifactId}@${i.version}`, i])).values()];
669
+ if (!need.length) return;
670
+ let manifests;
671
+ try {
672
+ ({ manifests } = await this.call("POST", "/chunks/manifest", { items: need.map(({ artifactId, version }) => ({ artifactId, version })) }));
673
+ } catch (e) {
674
+ if (!(e instanceof ApiError && e.status === 404)) throw e;
675
+ return this.fetchWhole(cache, need);
676
+ }
677
+ for (const [k, m] of manifests.entries()) {
678
+ if (cache.has(m.blob)) continue;
679
+ const local = /* @__PURE__ */ new Map();
680
+ const base = need[k].base;
681
+ if (base && cache.has(base)) for (const c of chunkContent(cache.get(base))) local.set(c.h, c.bytes);
682
+ const size = new Map(m.chunks.map((c) => [c.h, c.s]));
683
+ const want = [...size.keys()].filter((h) => !local.has(h));
684
+ let bytesIn = 0;
685
+ const batches = [];
686
+ for (let i = 0; i < want.length; ) {
687
+ const batch = [];
688
+ let total = 0;
689
+ while (i < want.length && (!batch.length || total + size.get(want[i]) <= FETCH_BATCH_BYTES)) {
690
+ batch.push(want[i]);
691
+ total += size.get(want[i++]);
692
+ }
693
+ batches.push(batch);
694
+ bytesIn += total;
695
+ }
696
+ let next = 0;
697
+ await Promise.all(Array.from({ length: Math.min(FETCH_PARALLEL, batches.length) }, async () => {
698
+ while (next < batches.length) {
699
+ const batch = batches[next++];
700
+ const buf = await this.binary("/chunks/fetch", { json: { artifactId: m.artifactId, version: m.version, hashes: batch } });
701
+ let off = 0;
702
+ for (const h of batch) {
703
+ const bytes = buf.subarray(off, off + size.get(h));
704
+ off += size.get(h);
705
+ if (sha256(bytes) !== h) throw new Error("downloaded chunk does not match its hash");
706
+ local.set(h, Buffer.from(bytes));
707
+ }
708
+ }
709
+ }));
710
+ const content = Buffer.concat(m.chunks.map((c) => local.get(c.h)));
711
+ if (sha256(content) !== m.blob) throw new Error("downloaded content does not match its hash");
712
+ cache.put(m.blob, content);
713
+ this.transfers.set(m.blob, { moved: want.length, total: size.size, bytes: bytesIn });
714
+ }
715
+ }
716
+ /** Whole-file downloads through signed URLs, for servers without delta sync. */
717
+ async fetchWhole(cache, items) {
718
+ const { downloads } = await this.call("POST", "/blobs/download", { items: items.map(({ artifactId, version }) => ({ artifactId, version })) });
503
719
  for (const d of downloads) {
504
720
  if (cache.has(d.sha256)) continue;
505
721
  const res = await fetch(d.url);
@@ -819,20 +1035,38 @@ var Engine = class {
819
1035
  if (!this.cache.has(sha)) throw new Error(`content ${sha.slice(0, 12)} is not in the local cache`);
820
1036
  return this.cache.get(sha);
821
1037
  }
1038
+ /** Downloads content not yet cached; `base` (content already here) lets only changed chunks travel. */
822
1039
  async ensureBlobs(pairs) {
823
1040
  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 })));
1041
+ if (missing.length) await this.api.fetchBlobs(this.cache, missing);
1042
+ }
1043
+ /** Uploads content (only chunks the server lacks) and returns the commit fields for it. */
1044
+ async uploadContent(local, artifactId) {
1045
+ await this.api.uploadBlobs([{ sha256: local.hash, bytes: local.canonical }], { artifactId });
1046
+ this.cache.put(local.hash, local.canonical);
1047
+ return { blob: local.hash, size: local.canonical.length, chunks: this.api.manifestFor(local.hash) };
1048
+ }
1049
+ /** ", sent 2 of 540 chunks, 17 KB" for log lines (empty when nothing is known). */
1050
+ sentNote(sha) {
1051
+ const t = describeTransfer(this.api.transfers.get(sha), "sent");
1052
+ return t ? `; ${t}` : "";
825
1053
  }
826
1054
  /** Writes content for an instance at `p`; records the result for the quick change check. */
827
1055
  writeInstance(inst, p, sha, eolMode = inst.eolMode, exec = inst.exec) {
828
1056
  const style = inst.eolStyle ? { eol: inst.eolStyle, bom: !!inst.hasBom } : null;
829
- return writeLocal(p, this.blobFor(sha), eolMode, { exec, style });
1057
+ const st = writeLocal(p, this.blobFor(sha), eolMode, { exec, style });
1058
+ if (inst.gitMode === "keep" && inst.gitRoot) {
1059
+ restage(inst.gitRoot, p);
1060
+ const after = statOrNull(p);
1061
+ if (after) Object.assign(st, { size: after.size, mtimeMs: after.mtimeMs });
1062
+ }
1063
+ return st;
830
1064
  }
831
1065
  // ---------- tick ----------
832
1066
  async tick({ remote = true } = {}) {
833
1067
  this.busyCache = /* @__PURE__ */ new Map();
834
1068
  await this.locateAll();
835
- if (remote) await this.gitTransitions();
1069
+ await this.gitPass();
836
1070
  if (remote) {
837
1071
  await this.checkRemote();
838
1072
  await this.syncRealtime();
@@ -847,16 +1081,26 @@ var Engine = class {
847
1081
  if (!this.busyCache.has(inst.gitDir)) this.busyCache.set(inst.gitDir, gitBusy(inst.gitDir));
848
1082
  return !!this.busyCache.get(inst.gitDir);
849
1083
  }
1084
+ gitInstances() {
1085
+ return this.db.all().filter((i) => i.gitMode && i.gitRoot && i.mode !== "orphaned");
1086
+ }
850
1087
  /** Sets the git mode on an instance (and a folder's files). Throws if follow is not allowed. */
851
1088
  applyGitMode(root, mode) {
852
1089
  if (!root) return;
853
1090
  if (!GIT_MODES.includes(mode)) throw new UserError(`git mode must be one of ${GIT_MODES.join(", ")}`);
854
1091
  if (mode === "follow" && root.role === "viewer") throw new UserError("git follow mode needs editor access");
1092
+ if (mode === "follow" && !gitBinary()) throw new UserError("git follow mode needs git to be installed");
855
1093
  const repo = findRepo(root.path);
856
1094
  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();
1095
+ const fields = { gitMode: mode, gitRoot: repo.root, gitDir: repo.gitDir, gitWaiting: false };
1096
+ for (const i of [root, ...root.kind === "folder" ? this.db.childrenOf(root.instanceId) : []]) {
1097
+ this.db.update(i.instanceId, fields);
1098
+ if (i.kind === "file") setKeep(repo.root, i.path, mode === "keep");
1099
+ }
1100
+ }
1101
+ /** Lets git see the file normally again (after unmanage, orphaning, or leaving keep mode). */
1102
+ releaseGit(inst) {
1103
+ if (inst.gitMode === "keep" && inst.kind === "file" && inst.gitRoot) setKeep(inst.gitRoot, inst.path, false);
860
1104
  }
861
1105
  /** `tether git <path> [--mode]`: show or change the git mode of managed files. */
862
1106
  gitMode({ path: p, mode }) {
@@ -865,73 +1109,135 @@ var Engine = class {
865
1109
  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
1110
  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
1111
  }
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]);
1112
+ /** Tether 0.2.0 used git hooks; they are no longer needed. */
1113
+ cleanupLegacyHooks() {
1114
+ const dirs = this.db.getMeta("gitHooks") ?? [];
1115
+ for (const dir of dirs) if (this.home) removeHooks(dir, this.home);
1116
+ if (dirs.length) this.db.setMeta("gitHooks", null);
882
1117
  }
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);
1118
+ /** Managed files that ended up inside a git repository later (git init, moved into one) get keep mode. */
1119
+ detectNewRepos() {
1120
+ for (const i of this.db.all().filter((x) => !x.gitMode && !x.folderInstanceId && x.mode !== "orphaned" && !x.detached)) {
1121
+ const repo = findRepo(i.path);
1122
+ if (!repo) continue;
1123
+ this.applyGitMode(i, "keep");
1124
+ this.log(`${i.path}: now inside the git repository ${repo.root}; using keep mode`);
1125
+ 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
1126
  }
893
1127
  }
894
1128
  /**
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`.
1129
+ * Runs after git may have changed files (watcher on the git directory, and every tick):
1130
+ * follows renames git made, brings back files git restored, keeps keep-mode files hidden from
1131
+ * git, and publishes follow-mode files whose content now matches git's committed or staged state.
897
1132
  */
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)`);
1133
+ async gitPass({ only } = {}) {
1134
+ const repos = /* @__PURE__ */ new Map();
1135
+ for (const i of this.gitInstances()) if (!only || samePath(i.gitRoot, only)) repos.set(i.gitDir, i.gitRoot);
1136
+ if (!repos.size) return;
1137
+ const heads = this.db.getMeta("gitHeads") ?? {};
1138
+ const publish = [];
1139
+ for (const [gitDir, root] of repos) {
1140
+ if (gitBusy(gitDir)) continue;
1141
+ const h = gitBinary() ? head(root) : null;
1142
+ const prev = heads[gitDir];
1143
+ const headMoved = !!h && h !== prev;
1144
+ if (h && prev && h !== prev) await this.followGitHistory(root, prev, h);
1145
+ if (h) heads[gitDir] = h;
1146
+ for (const i of this.gitInstances().filter((x) => x.gitDir === gitDir)) {
1147
+ if (i.kind !== "file") continue;
1148
+ if (i.detached) {
1149
+ if (statOrNull(i.path)?.isFile()) {
1150
+ this.db.update(i.instanceId, { ...this.identity(i.path), detached: false, missingSince: null, size: null, mtimeMs: null });
1151
+ if (i.gitMode === "keep") setKeep(root, i.path, true);
1152
+ this.log(`${i.path}: back (restored by git)`);
1153
+ }
1154
+ continue;
1155
+ }
1156
+ if (i.mode !== "normal") continue;
1157
+ if (i.gitMode === "keep") {
1158
+ if ((headMoved || !prev) && gitBinary() && !isKept(root, i.path)) setKeep(root, i.path, true);
1159
+ continue;
1160
+ }
1161
+ if (!gitBinary()) continue;
1162
+ let local;
1163
+ try {
1164
+ local = readLocal(i.path, i.eolMode);
1165
+ } catch {
1166
+ continue;
1167
+ }
1168
+ if (i.gitOverlay) {
1169
+ if (fileBlob(root, i.path) !== blobAt(root, "HEAD", i.path)) continue;
1170
+ setKeep(root, i.path, false);
1171
+ this.db.update(i.instanceId, { gitOverlay: false });
1172
+ i.gitOverlay = false;
1173
+ }
1174
+ const folder = i.folderInstanceId ? this.folderOf(i) : null;
1175
+ const rel = folder ? this.localRel(i, folder) : null;
1176
+ const moved = folder && rel && rel !== i.relPath;
1177
+ const changed = local && local.hash !== i.baseHash;
1178
+ if (!changed && !moved) continue;
1179
+ const { fromGit, commit } = changed ? changeFromGit(root, i.path) : { fromGit: true, commit: null };
1180
+ if (fromGit) publish.push({ inst: i, local: changed ? local : null, commit, relPath: moved ? rel : void 0 });
905
1181
  }
906
1182
  }
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 {
1183
+ this.db.setMeta("gitHeads", heads);
1184
+ if (publish.length) await this.publishGitChanges(publish);
1185
+ }
1186
+ /** Renames and new files git brought in between two commits. */
1187
+ async followGitHistory(root, from, to) {
1188
+ for (const c of changesBetween(root, from, to)) {
1189
+ if (c.status === "R") {
1190
+ const inst = this.gitInstances().find((i) => i.kind === "file" && samePath(i.path, c.from));
1191
+ if (inst && !statOrNull(c.from) && statOrNull(c.to)?.isFile()) {
1192
+ this.db.update(inst.instanceId, { path: canonicalPath(c.to), ...this.identity(c.to), detached: false, missingSince: null, size: null, mtimeMs: null });
1193
+ if (inst.gitMode === "keep") {
1194
+ setKeep(root, c.from, false);
1195
+ setKeep(root, c.to, true);
1196
+ }
1197
+ this.log(`${c.from} -> ${c.to}: followed a rename made in git`);
1198
+ }
1199
+ } else if (c.status === "A" && !this.instanceAt(c.to) && statOrNull(c.to)?.isFile()) {
1200
+ const folder = this.gitInstances().find((i) => i.kind === "folder" && i.gitMode === "follow" && isUnder(c.to, i.path) && !samePath(c.to, i.path));
1201
+ if (!folder) continue;
1202
+ const name = path4.basename(c.to);
1203
+ if (folder.role === "owner") {
1204
+ try {
1205
+ const r = await this.add({ path: c.to, gitCommit: to });
1206
+ this.log(`${c.to}: added to ${folder.path} (new in git)`);
1207
+ 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.` });
1208
+ } catch (e) {
1209
+ this.log(`${c.to}: could not add (${e.message})`);
1210
+ }
1211
+ } else {
1212
+ 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.` });
1213
+ }
914
1214
  }
915
1215
  }
916
- if (!changed.length) return { published: 0 };
1216
+ }
1217
+ /**
1218
+ * Publishes git's version of follow-mode files. If Tether already has exactly that content it is
1219
+ * adopted; if Tether moved on to something else, the file stays in editing mode for `tether done`.
1220
+ */
1221
+ async publishGitChanges(list) {
917
1222
  let results;
918
1223
  try {
919
- ({ results } = await this.api.call("POST", "/sync/check", { items: changed.map(({ inst }) => ({ artifactId: inst.artifactId, version: inst.baseVersion })) }));
1224
+ ({ results } = await this.api.call("POST", "/sync/check", { items: list.map(({ inst }) => ({ artifactId: inst.artifactId, version: inst.baseVersion })) }));
920
1225
  } catch (e) {
921
1226
  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 };
1227
+ 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" });
1228
+ return;
924
1229
  }
925
- let published = 0;
926
- for (const [k, { inst, local }] of changed.entries()) {
1230
+ for (const [k, { inst, local, commit, relPath }] of list.entries()) {
927
1231
  const r = results[k];
928
1232
  const name = path4.basename(inst.path);
1233
+ const hash = local?.hash ?? inst.baseHash;
929
1234
  if (r.status === "gone" || r.state?.deleted || r.folderDeleted) {
930
1235
  this.orphan(inst, r.reason ?? "deleted");
931
1236
  continue;
932
1237
  }
933
1238
  if (r.role === "viewer") {
934
1239
  this.db.update(inst.instanceId, { gitMode: "keep", role: "viewer" });
1240
+ setKeep(inst.gitRoot, inst.path, true);
935
1241
  continue;
936
1242
  }
937
1243
  const conflict = () => {
@@ -939,18 +1245,32 @@ var Engine = class {
939
1245
  this.log(`${inst.path}: git changed it but Tether also has a newer version; left in editing mode`);
940
1246
  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
1247
  };
1248
+ const adopt = (state) => {
1249
+ 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) } : {} });
1250
+ this.log(`${inst.path}: git's version is already on Tether (v${state.version})`);
1251
+ };
942
1252
  if (r.status === "changed") {
943
- conflict();
1253
+ if (r.state.blob === hash && (relPath === void 0 || r.state.relPath === relPath)) adopt(r.state);
1254
+ else conflict();
944
1255
  continue;
945
1256
  }
946
1257
  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}`);
1258
+ const change = { gitCommit: commit ?? null };
1259
+ if (local) Object.assign(change, await this.uploadContent(local, inst.artifactId));
1260
+ if (relPath !== void 0) change.relPath = relPath;
1261
+ const c = await this.api.call("POST", `/artifacts/${inst.artifactId}/commit`, { baseVersion: inst.baseVersion, change });
1262
+ this.db.update(inst.instanceId, {
1263
+ baseVersion: c.version,
1264
+ remoteVersion: c.version,
1265
+ baseHash: hash,
1266
+ relPath: relPath ?? inst.relPath,
1267
+ gitWaiting: false,
1268
+ lastError: null,
1269
+ ...local ? { size: local.size, mtimeMs: local.mtimeMs, ...styleOf(local) } : {}
1270
+ });
1271
+ const sent = local ? describeTransfer(this.api.transfers.get(local.hash), "sent") : "";
1272
+ this.log(`${inst.path}: published git's version as v${c.version}${sent ? ` (${sent})` : ""}`);
952
1273
  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
1274
  } catch (e) {
955
1275
  if (e instanceof ApiError && e.status === 409) conflict();
956
1276
  else if (e instanceof ApiError) {
@@ -959,7 +1279,11 @@ var Engine = class {
959
1279
  } else throw e;
960
1280
  }
961
1281
  }
962
- return { published };
1282
+ }
1283
+ /** Hook calls from Tether 0.2.0 installs, and explicit syncs. */
1284
+ async gitReconcile(root) {
1285
+ await this.locateAll();
1286
+ await this.gitPass({ only: root });
963
1287
  }
964
1288
  // ---------- realtime editing ----------
965
1289
  /** Files this client is editing live (every save is pushed). */
@@ -1008,7 +1332,7 @@ var Engine = class {
1008
1332
  const local = readLocal(cur.path, cur.eolMode);
1009
1333
  if (r.status === "changed") {
1010
1334
  if (!local || local.hash === cur.baseHash) {
1011
- await this.ensureBlobs([{ artifactId: cur.artifactId, version: r.state.version, sha: r.state.blob }]);
1335
+ await this.ensureBlobs([{ artifactId: cur.artifactId, version: r.state.version, sha: r.state.blob, base: cur.baseHash }]);
1012
1336
  this.applyIncoming(cur, r.state);
1013
1337
  } else {
1014
1338
  if (!cur.lastError?.startsWith("conflict")) {
@@ -1020,12 +1344,11 @@ var Engine = class {
1020
1344
  continue;
1021
1345
  }
1022
1346
  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);
1347
+ const content = await this.uploadContent(local, cur.artifactId);
1025
1348
  try {
1026
- const c = await this.api.call("POST", `/artifacts/${cur.artifactId}/commit`, { baseVersion: cur.baseVersion, change: { blob: local.hash, size: local.canonical.length } });
1349
+ const c = await this.api.call("POST", `/artifacts/${cur.artifactId}/commit`, { baseVersion: cur.baseVersion, change: content });
1027
1350
  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)`);
1351
+ this.log(`${cur.path}: pushed v${c.version} (realtime${this.sentNote(local.hash)})`);
1029
1352
  } catch (e) {
1030
1353
  if (!(e instanceof ApiError && e.status === 409)) throw e;
1031
1354
  this.db.update(cur.instanceId, { lastError: "conflict: the server changed; run `tether done` to resolve" });
@@ -1098,7 +1421,7 @@ var Engine = class {
1098
1421
  this.log(`${inst.path}: folder deleted locally; no longer managed here`);
1099
1422
  return;
1100
1423
  }
1101
- if (inst.gitMode === "follow" && inst.mode === "normal" && !inst.folderInstanceId) {
1424
+ if (inst.gitMode && inst.mode === "normal") {
1102
1425
  this.db.update(inst.instanceId, { detached: true, missingSince: null });
1103
1426
  this.log(`${inst.path}: removed (by git?); waiting for git to bring it back`);
1104
1427
  } else if (inst.mode === "editing") {
@@ -1114,7 +1437,10 @@ var Engine = class {
1114
1437
  }
1115
1438
  orphan(inst, reason) {
1116
1439
  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 });
1440
+ for (const t of targets) {
1441
+ this.releaseGit(t);
1442
+ this.db.update(t.instanceId, { mode: "orphaned", orphanReason: reason });
1443
+ }
1118
1444
  const what = reason === "revoked" ? "access revoked" : reason === "deleted" ? "deleted on the server" : "removed from the server";
1119
1445
  this.log(`${inst.path}: ${what}; file kept, no longer tracked`);
1120
1446
  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) });
@@ -1169,20 +1495,36 @@ var Engine = class {
1169
1495
  else if (!now.detached) incoming.push({ inst: now, state: r.state });
1170
1496
  });
1171
1497
  const newChildren = [];
1498
+ const waiting = /* @__PURE__ */ new Set();
1172
1499
  for (const f of folders) {
1173
1500
  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 });
1501
+ for (const c of f.children) {
1502
+ if (c.deleted || have.has(c.artifactId)) continue;
1503
+ if (f.inst.gitMode === "follow" && c.gitCommit && !isAncestor(f.inst.gitRoot, c.gitCommit)) {
1504
+ waiting.add(f.inst.instanceId);
1505
+ continue;
1506
+ }
1507
+ newChildren.push({ folder: f.inst, child: c });
1508
+ }
1175
1509
  }
1176
1510
  try {
1177
1511
  await this.ensureBlobs([
1178
- ...incoming.map(({ state }) => ({ artifactId: state.artifactId, version: state.version, sha: state.blob })),
1512
+ ...incoming.map(({ inst, state }) => ({ artifactId: state.artifactId, version: state.version, sha: state.blob, base: inst.baseHash })),
1179
1513
  ...newChildren.map(({ child }) => ({ artifactId: child.artifactId, version: child.version, sha: child.blob }))
1180
1514
  ]);
1181
1515
  } catch (e) {
1182
1516
  if (e instanceof OfflineError) return this.offline = e.message;
1183
1517
  throw e;
1184
1518
  }
1185
- for (const { inst, state } of incoming) if (!this.isGitBusy(inst)) this.applyIncoming(inst, state);
1519
+ for (const { inst, state } of incoming) {
1520
+ if (this.isGitBusy(inst)) continue;
1521
+ if (inst.gitMode === "follow" && state.gitCommit && !isAncestor(inst.gitRoot, state.gitCommit)) {
1522
+ 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`);
1523
+ this.db.update(inst.instanceId, { gitWaiting: true });
1524
+ continue;
1525
+ }
1526
+ this.applyIncoming(inst, state);
1527
+ }
1186
1528
  for (const { folder, child } of newChildren) {
1187
1529
  this.db.insert({
1188
1530
  instanceId: newInstanceId(),
@@ -1204,10 +1546,11 @@ var Engine = class {
1204
1546
  });
1205
1547
  this.log(`${folder.path}: new file ${child.relPath}`);
1206
1548
  }
1207
- for (const f of folders) this.db.update(f.inst.instanceId, { baseVersion: f.state.version });
1549
+ for (const f of folders) if (!waiting.has(f.inst.instanceId)) this.db.update(f.inst.instanceId, { baseVersion: f.state.version });
1208
1550
  }
1209
1551
  /** Normal mode: write an incoming version (content and/or layout). */
1210
1552
  applyIncoming(inst, state) {
1553
+ if (!statOrNull(inst.path)) return;
1211
1554
  try {
1212
1555
  let p = inst.path;
1213
1556
  if (inst.folderInstanceId && state.relPath !== inst.relPath) {
@@ -1219,11 +1562,18 @@ var Engine = class {
1219
1562
  p = to;
1220
1563
  this.log(`${inst.path} -> ${to}: moved by the server`);
1221
1564
  }
1222
- const patch = { path: p, relPath: state.relPath, baseVersion: state.version, baseHash: state.blob, eolMode: state.eol, exec: state.exec, lastError: null };
1565
+ const patch = { path: p, relPath: state.relPath, baseVersion: state.version, baseHash: state.blob, eolMode: state.eol, exec: state.exec, lastError: null, gitWaiting: false };
1223
1566
  const localEdit = !this.matchesBase(inst, p) && readLocal(p, inst.eolMode)?.hash !== state.blob;
1224
1567
  if (state.blob !== inst.baseHash || localEdit) {
1225
1568
  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)" : ""}`);
1569
+ const got = describeTransfer(this.api.transfers.get(state.blob), "received");
1570
+ this.api.transfers.delete(state.blob);
1571
+ this.log(`${p}: updated to version ${state.version}${localEdit ? " (local change replaced)" : ""}${got ? ` (${got})` : ""}`);
1572
+ if (inst.gitMode === "follow" && inst.gitRoot && !state.gitCommit && blobAt(inst.gitRoot, "HEAD", p) !== fileBlob(inst.gitRoot, p)) {
1573
+ setKeep(inst.gitRoot, p, true);
1574
+ patch.gitOverlay = true;
1575
+ 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.` });
1576
+ }
1227
1577
  if (localEdit) {
1228
1578
  const name = path4.basename(p);
1229
1579
  this.notify({
@@ -1383,7 +1733,7 @@ var Engine = class {
1383
1733
  if (st.isFile()) {
1384
1734
  const f = this.prepareFile(abs, name, rules, eol);
1385
1735
  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 });
1736
+ 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
1737
  this.cache.put(f.sha256, f.bytes);
1388
1738
  const local = readLocal(abs, f.eol);
1389
1739
  this.db.insert({
@@ -1422,7 +1772,7 @@ var Engine = class {
1422
1772
  repoId: repo.repoId,
1423
1773
  kind: "folder",
1424
1774
  name,
1425
- files: prepared.map((f) => ({ relPath: f.rel, blob: f.sha256, size: f.size, eol: f.eol, exec: f.exec }))
1775
+ 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
1776
  });
1427
1777
  const folderInst = this.db.insert({
1428
1778
  instanceId: newInstanceId(),
@@ -1469,7 +1819,7 @@ var Engine = class {
1469
1819
  return { artifactId: r.artifact.artifactId, shareId: r.artifact.shareId, kind: "folder", path: abs, files: files2.length, skippedSymlinks: symlinks };
1470
1820
  }
1471
1821
  /** `tether add <path>`: owner adds a new file inside a managed folder. */
1472
- async add({ path: target }) {
1822
+ async add({ path: target, gitCommit }) {
1473
1823
  const abs = canonicalPath(path4.resolve(target));
1474
1824
  if (!statOrNull(abs)?.isFile()) throw new UserError(`${abs} is not a file`);
1475
1825
  if (this.instanceAt(abs)) throw new UserError(`${abs} is already managed`);
@@ -1482,7 +1832,7 @@ var Engine = class {
1482
1832
  const repo = (await this.api.call("GET", "/repos")).find((r) => r.repoId === folder.repoId);
1483
1833
  const f = this.prepareFile(abs, rel, repo?.eolDefaults ?? {});
1484
1834
  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 });
1835
+ 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
1836
  this.cache.put(f.sha256, f.bytes);
1487
1837
  const local = readLocal(abs, f.eol);
1488
1838
  this.db.insert({
@@ -1533,8 +1883,21 @@ var Engine = class {
1533
1883
  const base = { repoId: a.repoId, role: r.role, canDelete: r.canDelete };
1534
1884
  if (a.kind === "file") {
1535
1885
  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`);
1886
+ let existing = readLocal(target, a.eol);
1887
+ let mode = "normal";
1888
+ let lastError = null;
1889
+ let note;
1890
+ if (existing && existing.hash !== a.blob) {
1891
+ const clash = this.gitClash(target, gitMode, a.relPath ?? a.name);
1892
+ if (clash === "replace") {
1893
+ existing = null;
1894
+ note = "replaced git's version with Tether's (git still has it)";
1895
+ } else {
1896
+ mode = "editing";
1897
+ lastError = "git's version differs from Tether's; run `tether done` to publish it or accept Tether's";
1898
+ note = "kept git's version; run `tether done` to publish it or accept Tether's";
1899
+ }
1900
+ }
1538
1901
  const st2 = existing ? { size: existing.size, mtimeMs: existing.mtimeMs, ...styleOf(existing) } : writeLocal(target, this.blobFor(a.blob), a.eol, { exec: a.exec });
1539
1902
  this.db.insert({
1540
1903
  ...base,
@@ -1544,7 +1907,8 @@ var Engine = class {
1544
1907
  name: a.name,
1545
1908
  path: canonicalPath(target),
1546
1909
  ...this.identity(target),
1547
- mode: "normal",
1910
+ mode,
1911
+ lastError,
1548
1912
  baseVersion: a.version,
1549
1913
  baseHash: a.blob,
1550
1914
  ...st2,
@@ -1552,18 +1916,17 @@ var Engine = class {
1552
1916
  exec: a.exec,
1553
1917
  remoteVersion: a.version
1554
1918
  });
1555
- return { kind: "file", path: canonicalPath(target), role: r.role, version: a.version };
1919
+ return { kind: "file", path: canonicalPath(target), role: r.role, version: a.version, note };
1556
1920
  }
1557
1921
  const st = statOrNull(target);
1558
1922
  if (st && !st.isDirectory()) throw new UserError(`${target} exists and is not a folder`);
1559
1923
  await this.ensureBlobs(r.children.map((c) => ({ artifactId: c.artifactId, version: c.version, sha: c.blob })));
1560
- const clashes = [];
1924
+ const clashes = /* @__PURE__ */ new Map();
1561
1925
  for (const c of r.children) {
1562
1926
  const p = path4.join(target, ...c.relPath.split("/"));
1563
1927
  const ex = readLocal(p, c.eol);
1564
- if (ex && ex.hash !== c.blob) clashes.push(c.relPath);
1928
+ if (ex && ex.hash !== c.blob) clashes.set(c.relPath, this.gitClash(p, gitMode, c.relPath));
1565
1929
  }
1566
- if (clashes.length) throw new UserError(`${target} already has different files: ${clashes.join(", ")}; choose another destination`);
1567
1930
  fs6.mkdirSync(target, { recursive: true });
1568
1931
  const folderInst = this.db.insert({
1569
1932
  ...base,
@@ -1579,7 +1942,8 @@ var Engine = class {
1579
1942
  });
1580
1943
  for (const c of r.children) {
1581
1944
  const p = path4.join(folderInst.path, ...c.relPath.split("/"));
1582
- const ex = readLocal(p, c.eol);
1945
+ const clash = clashes.get(c.relPath);
1946
+ const ex = clash === "replace" ? null : readLocal(p, c.eol);
1583
1947
  const cst = ex ? { size: ex.size, mtimeMs: ex.mtimeMs, ...styleOf(ex) } : writeLocal(p, this.blobFor(c.blob), c.eol, { exec: c.exec });
1584
1948
  this.db.insert({
1585
1949
  ...base,
@@ -1591,10 +1955,11 @@ var Engine = class {
1591
1955
  relPath: c.relPath,
1592
1956
  path: canonicalPath(p),
1593
1957
  ...this.identity(p),
1594
- mode: "normal",
1595
1958
  baseVersion: c.version,
1596
1959
  baseHash: c.blob,
1597
1960
  ...cst,
1961
+ mode: clash === "keep-git" ? "editing" : "normal",
1962
+ lastError: clash === "keep-git" ? "git's version differs from Tether's; run `tether done`" : null,
1598
1963
  eolMode: c.eol,
1599
1964
  exec: c.exec,
1600
1965
  remoteVersion: c.version
@@ -1602,6 +1967,21 @@ var Engine = class {
1602
1967
  }
1603
1968
  return { kind: "folder", path: folderInst.path, role: r.role, files: r.children.length, version: a.version };
1604
1969
  }
1970
+ /**
1971
+ * A pulled file already exists with different content. Only allowed when git holds that
1972
+ * content safely (tracked, no uncommitted changes): keep mode replaces it with Tether's version,
1973
+ * follow mode keeps git's version in editing mode. Otherwise the pull is refused.
1974
+ */
1975
+ gitClash(p, gitMode, label) {
1976
+ const repo = gitMode && findRepo(p);
1977
+ if (repo && gitBinary() && isTracked(repo.root, p)) {
1978
+ const blob = fileBlob(repo.root, p);
1979
+ const clean = blob && (blob === blobAt(repo.root, "HEAD", p) || blob === indexBlob(repo.root, p));
1980
+ if (clean) return gitMode === "keep" ? "replace" : "keep-git";
1981
+ throw new UserError(`${label} differs from Tether's version and has uncommitted git changes; commit or stash them first`);
1982
+ }
1983
+ throw new UserError(`${p} already exists with different content; choose another destination`);
1984
+ }
1605
1985
  /** Instances addressed by a path: a file, a managed folder (with its files), or any directory. */
1606
1986
  targets(p) {
1607
1987
  const abs = canonicalPath(path4.resolve(p));
@@ -1669,6 +2049,7 @@ You can still run \`tether edit\` without --realtime, but expect merge conflicts
1669
2049
  }
1670
2050
  async unmanage({ path: p }) {
1671
2051
  for (const i of this.targets(p).filter((x) => x.realtime)) await this.releaseRealtime(i);
2052
+ for (const i of this.targets(p)) this.releaseGit(i);
1672
2053
  return { changed: this.setMode(p, "unmanaged", ["normal", "editing"]) };
1673
2054
  }
1674
2055
  /**
@@ -1796,16 +2177,16 @@ You can still run \`tether edit\` without --realtime, but expect merge conflicts
1796
2177
  case "push": {
1797
2178
  const local = readLocal(inst.path, inst.eolMode);
1798
2179
  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
- }
2180
+ if (local && local.hash !== inst.baseHash) Object.assign(change, await this.uploadContent(local, inst.artifactId));
1804
2181
  if (folder) {
1805
2182
  const rel = this.localRel(inst, folder);
1806
2183
  if (rel && rel !== inst.relPath) change.relPath = rel;
1807
2184
  }
1808
2185
  const baseVersion = item.incoming ? item.serverVersion : inst.baseVersion;
2186
+ if (inst.gitMode === "follow" && inst.gitRoot && change.blob) {
2187
+ const g = changeFromGit(inst.gitRoot, inst.path);
2188
+ if (g.fromGit && g.commit) change.gitCommit = g.commit;
2189
+ }
1809
2190
  const r = await this.api.call("POST", `/artifacts/${inst.artifactId}/commit`, { baseVersion, change });
1810
2191
  const state = r.state;
1811
2192
  this.db.update(inst.instanceId, {
@@ -1818,7 +2199,7 @@ You can still run \`tether edit\` without --realtime, but expect merge conflicts
1818
2199
  mtimeMs: local?.mtimeMs ?? null,
1819
2200
  ...local ? styleOf(local) : {}
1820
2201
  });
1821
- return { version: r.version };
2202
+ return { version: r.version, sent: change.blob ? describeTransfer(this.api.transfers.get(change.blob), "sent") : "" };
1822
2203
  }
1823
2204
  case "delete": {
1824
2205
  await this.api.call("POST", `/artifacts/${inst.artifactId}/commit`, { baseVersion: item.serverVersion, change: { deleted: true } });
@@ -1844,7 +2225,7 @@ You can still run \`tether edit\` without --realtime, but expect merge conflicts
1844
2225
  if (!inst || inst.kind !== "file") throw new UserError("diff needs a managed file");
1845
2226
  const { artifact } = await this.api.call("GET", `/artifacts/${inst.artifactId}`);
1846
2227
  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 }]);
2228
+ await this.ensureBlobs([{ artifactId: inst.artifactId, version: artifact.version, sha: artifact.blob, base: inst.baseHash }]);
1848
2229
  const server = this.cache.get(artifact.blob);
1849
2230
  const local = inst.detached ? null : readLocal(inst.path, inst.eolMode);
1850
2231
  const label = path4.basename(inst.path);
@@ -1878,6 +2259,8 @@ You can still run \`tether edit\` without --realtime, but expect merge conflicts
1878
2259
  if (i.orphanReason) flags.push(i.orphanReason);
1879
2260
  if (i.realtime && i.mode === "editing") flags.push("realtime: your saves are pushed live");
1880
2261
  if (i.gitMode) flags.push(`git: ${i.gitMode}`);
2262
+ if (i.gitWaiting) flags.push("newer version arrives with your next git pull");
2263
+ if (i.gitOverlay) flags.push("Tether-only version (hidden from git)");
1881
2264
  if (i.remoteRealtime && i.mode !== "orphaned") flags.push(`realtime edits by ${JSON.parse(i.remoteRealtime).email}`);
1882
2265
  return {
1883
2266
  instanceId: i.instanceId,
@@ -1933,6 +2316,68 @@ import { spawn } from "node:child_process";
1933
2316
  var AUMID = "Tether.Sync";
1934
2317
  var FLUSH_MS = 3e3;
1935
2318
  var LEVELS = { info: 0, warn: 1, error: 2 };
2319
+ var SHORTCUT_SCRIPT = String.raw`Add-Type -TypeDefinition @'
2320
+ using System;
2321
+ using System.Text;
2322
+ using System.Runtime.InteropServices;
2323
+ using System.Runtime.InteropServices.ComTypes;
2324
+
2325
+ namespace TetherShortcut {
2326
+ [ComImport, Guid("00021401-0000-0000-C000-000000000046")] class CShellLink {}
2327
+
2328
+ [ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("000214F9-0000-0000-C000-000000000046")]
2329
+ interface IShellLinkW {
2330
+ void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder f, int cch, IntPtr fd, uint flags);
2331
+ void GetIDList(out IntPtr pidl);
2332
+ void SetIDList(IntPtr pidl);
2333
+ void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder s, int cch);
2334
+ void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string s);
2335
+ void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder s, int cch);
2336
+ void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string s);
2337
+ void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder s, int cch);
2338
+ void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string s);
2339
+ void GetHotkey(out short k);
2340
+ void SetHotkey(short k);
2341
+ void GetShowCmd(out int c);
2342
+ void SetShowCmd(int c);
2343
+ void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder s, int cch, out int i);
2344
+ void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string s, int i);
2345
+ void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string s, uint r);
2346
+ void Resolve(IntPtr hwnd, uint flags);
2347
+ void SetPath([MarshalAs(UnmanagedType.LPWStr)] string s);
2348
+ }
2349
+
2350
+ [StructLayout(LayoutKind.Sequential, Pack = 4)] public struct PropertyKey { public Guid fmtid; public uint pid; }
2351
+ [StructLayout(LayoutKind.Explicit)] public struct PropVariant { [FieldOffset(0)] public ushort vt; [FieldOffset(8)] public IntPtr p; }
2352
+
2353
+ [ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99")]
2354
+ interface IPropertyStore {
2355
+ void GetCount(out uint c);
2356
+ void GetAt(uint i, out PropertyKey k);
2357
+ void GetValue(ref PropertyKey k, out PropVariant v);
2358
+ void SetValue(ref PropertyKey k, ref PropVariant v);
2359
+ void Commit();
2360
+ }
2361
+
2362
+ public static class Maker {
2363
+ public static void Create(string lnk, string aumid, string url) {
2364
+ var link = (IShellLinkW)new CShellLink();
2365
+ link.SetPath(Environment.ExpandEnvironmentVariables("%SystemRoot%\\explorer.exe"));
2366
+ link.SetArguments(url);
2367
+ link.SetDescription("Tether");
2368
+ link.SetIconLocation(Environment.ExpandEnvironmentVariables("%SystemRoot%\\System32\\imageres.dll"), 228);
2369
+ var store = (IPropertyStore)link;
2370
+ var key = new PropertyKey { fmtid = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"), pid = 5 };
2371
+ var v = new PropVariant { vt = 31, p = Marshal.StringToCoTaskMemUni(aumid) };
2372
+ store.SetValue(ref key, ref v);
2373
+ store.Commit();
2374
+ ((IPersistFile)link).Save(lnk, true);
2375
+ Marshal.FreeCoTaskMem(v.p);
2376
+ }
2377
+ }
2378
+ }
2379
+ '@
2380
+ `;
1936
2381
  var Notifier = class {
1937
2382
  /** setting: 'all' | 'errors' | 'off' (config key `notifications`). */
1938
2383
  constructor({ setting = () => "all", consoleUrl = () => "", log = () => {
@@ -1992,7 +2437,12 @@ Also: ${events.slice(1, 4).map((e) => e.short ?? e.title).join("; ")}${more > 3
1992
2437
  const esc = (s) => s.replace(/[<>&"']/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;", "'": "&apos;" })[c]);
1993
2438
  const launch = url ? ` activationType="protocol" launch="${esc(url)}"` : "";
1994
2439
  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';`;
2440
+ const consoleUrl = (this.consoleUrl() || "https://tether-b10cf.web.app").replace(/'/g, "''");
2441
+ 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';
2442
+ $lnk = Join-Path $env:APPDATA 'Microsoft\\Windows\\Start Menu\\Programs\\Tether.lnk'
2443
+ if (-not (Test-Path $lnk)) { try {
2444
+ ${SHORTCUT_SCRIPT}
2445
+ [TetherShortcut.Maker]::Create($lnk, '${AUMID}', '${consoleUrl}') } catch {} }`;
1996
2446
  this.registered = true;
1997
2447
  const script = `${register}
1998
2448
  [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
@@ -2101,10 +2551,29 @@ async function runDaemon() {
2101
2551
  if (engine.graceDue) quickPass(Math.max(50, engine.graceDue - Date.now() + 50));
2102
2552
  }
2103
2553
  const watchers = /* @__PURE__ */ new Map();
2554
+ let gitTimer = null;
2555
+ function gitPassSoon() {
2556
+ clearTimeout(gitTimer);
2557
+ gitTimer = setTimeout(async () => {
2558
+ gitTimer = null;
2559
+ try {
2560
+ await engine.locked(async () => {
2561
+ engine.busyCache = /* @__PURE__ */ new Map();
2562
+ await engine.locateAll();
2563
+ await engine.gitPass();
2564
+ await engine.enforceLocal();
2565
+ });
2566
+ } catch (e) {
2567
+ log(`git pass failed: ${e.message}`);
2568
+ }
2569
+ }, 600);
2570
+ }
2104
2571
  function refreshWatchers() {
2105
2572
  const want = /* @__PURE__ */ new Map();
2106
2573
  for (const i of db.all()) {
2107
- if (i.mode === "orphaned" || i.detached) continue;
2574
+ if (i.mode === "orphaned") continue;
2575
+ if (i.gitDir && !want.has(i.gitDir)) want.set(i.gitDir, "git");
2576
+ if (i.detached) continue;
2108
2577
  if (i.kind === "folder") want.set(i.path, true);
2109
2578
  else if (!i.folderInstanceId) want.set(path5.dirname(i.path), false);
2110
2579
  }
@@ -2114,10 +2583,10 @@ async function runDaemon() {
2114
2583
  watchers.delete(dir);
2115
2584
  }
2116
2585
  }
2117
- for (const [dir, recursive] of want) {
2586
+ for (const [dir, kind] of want) {
2118
2587
  if (watchers.has(dir)) continue;
2119
2588
  try {
2120
- const w = fs8.watch(dir, { recursive, persistent: false }, () => {
2589
+ const w = kind === "git" ? fs8.watch(dir, { persistent: false }, () => gitPassSoon()) : fs8.watch(dir, { recursive: kind, persistent: false }, () => {
2121
2590
  quickPass(250);
2122
2591
  realtimePass();
2123
2592
  });
@@ -2267,11 +2736,14 @@ async function runDaemon() {
2267
2736
  return { stopping: true };
2268
2737
  }
2269
2738
  };
2739
+ const CHANGES_WATCHED = /* @__PURE__ */ new Set(["manage", "add", "pull", "edit", "unmanage", "git.mode", "resolve", "locate"]);
2270
2740
  async function handle(method, params) {
2271
2741
  const h = handlers[method];
2272
2742
  if (!h) throw new UserError(`unknown command ${method}`);
2273
2743
  try {
2274
- return await h(params);
2744
+ const result = await h(params);
2745
+ if (CHANGES_WATCHED.has(method)) refreshWatchers();
2746
+ return result;
2275
2747
  } catch (e) {
2276
2748
  if (e instanceof ApiError || e instanceof UserError) throw e;
2277
2749
  if (e.name === "OfflineError") throw e;
@@ -2289,6 +2761,8 @@ async function runDaemon() {
2289
2761
  clearTimeout(quickTimer);
2290
2762
  clearTimeout(realtimeTimer);
2291
2763
  clearInterval(updateTimer);
2764
+ clearInterval(repoTimer);
2765
+ clearTimeout(gitTimer);
2292
2766
  for (const w of watchers.values()) w.close();
2293
2767
  server.close();
2294
2768
  engine.locked(() => db.close()).finally(() => {
@@ -2299,7 +2773,12 @@ async function runDaemon() {
2299
2773
  }
2300
2774
  process.on("SIGINT", () => shutdown(0));
2301
2775
  process.on("SIGTERM", () => shutdown(0));
2302
- await engine.locked(() => engine.syncGitHooks()).catch((e) => log(`git hooks: ${e.message}`));
2776
+ await engine.locked(() => {
2777
+ engine.cleanupLegacyHooks();
2778
+ engine.detectNewRepos();
2779
+ }).catch((e) => log(`git setup: ${e.message}`));
2780
+ const repoTimer = setInterval(() => engine.locked(() => engine.detectNewRepos()).catch(() => {
2781
+ }), 60 * 60 * 1e3);
2303
2782
  const updateTimer = setInterval(checkForUpdate, UPDATE_CHECK_MS);
2304
2783
  setTimeout(checkForUpdate, 5e3);
2305
2784
  await runTick();