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
|
-
|
|
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-
|
|
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
|
-
/**
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
502
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
858
|
-
for (const i of [root, ...root.kind === "folder" ? this.db.childrenOf(root.instanceId) : []])
|
|
859
|
-
|
|
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
|
-
/**
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
const
|
|
872
|
-
|
|
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
|
-
/**
|
|
884
|
-
|
|
885
|
-
const
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
this.
|
|
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
|
-
*
|
|
896
|
-
*
|
|
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
|
|
899
|
-
|
|
900
|
-
const
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
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
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
923
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
948
|
-
this.
|
|
949
|
-
|
|
950
|
-
this.
|
|
951
|
-
this.
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
|
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)
|
|
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)
|
|
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)
|
|
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.
|
|
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
|
-
|
|
1537
|
-
|
|
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
|
|
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.
|
|
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
|
|
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) => ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" })[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
|
|
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"
|
|
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,
|
|
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
|
-
|
|
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(() =>
|
|
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();
|