aethel 0.3.5 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.7 (2026-04-05)
4
+
5
+ - Fix orphan checker not recognizing My Drive root — all files under synced folders were silently dropped
6
+
7
+ ## 0.3.6 (2026-04-05)
8
+
9
+ - Optimize status and saveSnapshot performance: parallelize loadState, skip redundant fetches, increase hash concurrency
10
+
3
11
  ## 0.3.5 (2026-04-05)
4
12
 
5
13
  - Add progress bars and spinners for all time-consuming CLI operations
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aethel",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Git-style Google Drive sync CLI with interactive TUI",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.js CHANGED
@@ -60,12 +60,36 @@ async function openRepo(options, { requireWorkspace = true, silent = false } = {
60
60
  return repo;
61
61
  }
62
62
 
63
+ function fmtMs(ms) {
64
+ return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
65
+ }
66
+
63
67
  async function loadStateWithProgress(repo, opts) {
64
68
  const spinner = createSpinner("Loading workspace state...");
65
69
  try {
66
- const state = await repo.loadState(opts);
67
- const n = state.diff.changes.length;
68
- spinner.succeed(n ? `Loaded state — ${n} change(s) detected` : "Loaded state — everything up to date");
70
+ const state = await repo.loadState({
71
+ ...opts,
72
+ onPhase(phase, ms) {
73
+ if (phase === "local") spinner.update(`Scanned local files (${fmtMs(ms)}), waiting for remote...`);
74
+ else if (phase === "remote") spinner.update(`Fetched remote state (${fmtMs(ms)}), computing diff...`);
75
+ },
76
+ });
77
+ const { timings, diff } = state;
78
+ const n = diff.changes.length;
79
+
80
+ const parts = [
81
+ `${timings.localFiles} local`,
82
+ `${timings.remoteFiles} remote`,
83
+ ];
84
+ const times = [
85
+ `scan ${fmtMs(timings.localMs)}`,
86
+ timings.remoteCached ? `remote cache hit` : `fetch ${fmtMs(timings.remoteMs)}`,
87
+ `diff ${fmtMs(timings.diffMs)}`,
88
+ `total ${fmtMs(timings.totalMs)}`,
89
+ ];
90
+
91
+ const summary = n ? `${n} change(s)` : "up to date";
92
+ spinner.succeed(`${summary} (${parts.join(", ")}) [${times.join(" | ")}]`);
69
93
  return state;
70
94
  } catch (err) {
71
95
  spinner.fail("Failed to load workspace state");
@@ -385,7 +409,7 @@ function handleReset(paths, options) {
385
409
  }
386
410
  }
387
411
 
388
- async function handleCommit(options, { repo: existingRepo } = {}) {
412
+ async function handleCommit(options, { repo: existingRepo, snapshotHint } = {}) {
389
413
  const repo = existingRepo || await openRepo(options);
390
414
  const staged = repo.getStagedEntries();
391
415
 
@@ -408,9 +432,16 @@ async function handleCommit(options, { repo: existingRepo } = {}) {
408
432
  }
409
433
  }
410
434
 
435
+ const snapshotStart = Date.now();
411
436
  const spinner = createSpinner("Saving snapshot...");
412
- await repo.saveSnapshot(message);
413
- spinner.succeed(`Snapshot saved: "${message}"`);
437
+ // snapshotHint lets callers (pull/push) pass pre-loaded state
438
+ // so saveSnapshot skips redundant API calls / fs scans.
439
+ await repo.saveSnapshot(message, snapshotHint);
440
+ const skipped = [];
441
+ if (snapshotHint?.remote) skipped.push("remote reused");
442
+ if (snapshotHint?.local) skipped.push("local reused");
443
+ const hint = skipped.length ? ` (${skipped.join(", ")})` : "";
444
+ spinner.succeed(`Snapshot saved in ${fmtMs(Date.now() - snapshotStart)}${hint}`);
414
445
  }
415
446
 
416
447
  function handleLog(options) {
@@ -434,10 +465,11 @@ async function handleFetch(options) {
434
465
  const repo = await openRepo(options);
435
466
 
436
467
  repo.invalidateRemoteCache();
468
+ const fetchStart = Date.now();
437
469
  const spinner = createSpinner("Fetching remote file list...");
438
470
  const remoteState = await repo.getRemoteState({ useCache: false });
439
471
  const remote = remoteState.files;
440
- spinner.succeed(`Found ${remote.length} file(s) on Drive`);
472
+ spinner.succeed(`Found ${remote.length} file(s) on Drive [${fmtMs(Date.now() - fetchStart)}]`);
441
473
 
442
474
  const snapshot = repo.getSnapshot();
443
475
  if (snapshot) {
@@ -470,7 +502,7 @@ async function handleFetch(options) {
470
502
 
471
503
  async function handlePull(paths, options) {
472
504
  const repo = await openRepo(options);
473
- const { diff } = await loadStateWithProgress(repo, { useCache: false });
505
+ const { diff, remoteState } = await loadStateWithProgress(repo, { useCache: false });
474
506
 
475
507
  let remoteChanges = diff.changes.filter((change) =>
476
508
  [
@@ -514,12 +546,16 @@ async function handlePull(paths, options) {
514
546
 
515
547
  const count = repo.stageChanges(remoteChanges);
516
548
  console.log(`Staged ${count} remote change(s). Committing...`);
517
- await handleCommit({ ...options, message: options.message || "pull" }, { repo });
549
+ // Pull downloads remote→local: remote state unchanged, only re-scan local
550
+ await handleCommit({ ...options, message: options.message || "pull" }, {
551
+ repo,
552
+ snapshotHint: { remote: remoteState },
553
+ });
518
554
  }
519
555
 
520
556
  async function handlePush(paths, options) {
521
557
  const repo = await openRepo(options);
522
- const { diff } = await loadStateWithProgress(repo, { useCache: false });
558
+ const { diff, local } = await loadStateWithProgress(repo, { useCache: false });
523
559
 
524
560
  let localChanges = diff.changes.filter((change) =>
525
561
  [
@@ -563,7 +599,11 @@ async function handlePush(paths, options) {
563
599
 
564
600
  const count = repo.stageChanges(localChanges);
565
601
  console.log(`Staged ${count} local change(s). Committing...`);
566
- await handleCommit({ ...options, message: options.message || "push" }, { repo });
602
+ // Push uploads local→remote: local state unchanged, only re-fetch remote
603
+ await handleCommit({ ...options, message: options.message || "push" }, {
604
+ repo,
605
+ snapshotHint: { local },
606
+ });
567
607
  }
568
608
 
569
609
  async function handleResolve(paths, options) {
@@ -113,5 +113,6 @@ export function writeSnapshot(root, snapshot) {
113
113
  fs.copyFileSync(latest, path.join(snapDir, HISTORY_DIR, `${ts}.json`));
114
114
  }
115
115
 
116
- fs.writeFileSync(latest, JSON.stringify(snapshot, null, 2) + "\n");
116
+ // Compact JSON — snapshots can be large, pretty-printing is slow + wastes disk
117
+ fs.writeFileSync(latest, JSON.stringify(snapshot) + "\n");
117
118
  }
@@ -226,6 +226,15 @@ async function fetchAllItems(drive, { fields, includeSharedDrives = false } = {}
226
226
  const files = [];
227
227
  let pageToken = null;
228
228
 
229
+ // Fetch the real My Drive root folder metadata in parallel with
230
+ // the main list. Google Drive API returns actual folder IDs in
231
+ // `parents` (e.g. "0AJ…"), NOT the alias "root". Without this,
232
+ // the orphan checker can't recognise the root and marks every
233
+ // top-level folder as orphaned — silently dropping all files.
234
+ const rootPromise = drive.files
235
+ .get({ fileId: "root", fields: "id,name,mimeType,parents,createdTime" })
236
+ .catch(() => null);
237
+
229
238
  const listOpts = {
230
239
  q: "trashed = false",
231
240
  fields: allFields,
@@ -252,6 +261,12 @@ async function fetchAllItems(drive, { fields, includeSharedDrives = false } = {}
252
261
  pageToken = response.data.nextPageToken;
253
262
  } while (pageToken);
254
263
 
264
+ // Add the real root folder so the orphan checker can walk up to it.
265
+ const rootRes = await rootPromise;
266
+ if (rootRes?.data?.id) {
267
+ folders.set(rootRes.data.id, rootRes.data);
268
+ }
269
+
255
270
  return { folders, files };
256
271
  }
257
272
 
@@ -45,6 +45,7 @@ export function readRemoteCache(root, rootFolderId = null, ttlMs = DEFAULT_TTL_M
45
45
 
46
46
  export function writeRemoteCache(root, remoteState, rootFolderId = null) {
47
47
  const p = cachePath(root);
48
+ // Compact JSON — cache can be large with many files
48
49
  fs.writeFileSync(
49
50
  p,
50
51
  JSON.stringify({
@@ -109,23 +109,48 @@ export class Repository {
109
109
  * Load full workspace state in parallel, replacing the old
110
110
  * loadWorkspaceState() helper from cli.js.
111
111
  */
112
- async loadState({ useCache = true } = {}) {
112
+ async loadState({ useCache = true, onPhase } = {}) {
113
113
  const config = this.getConfig();
114
-
115
- const [local, snapshot] = await Promise.all([
116
- scanLocal(this._root),
117
- Promise.resolve(readLatestSnapshot(this._root)),
114
+ const t0 = Date.now();
115
+
116
+ // Run all three in parallel — remote fetch is the slowest, overlap it
117
+ // with local scan and snapshot read.
118
+ const timings = {};
119
+
120
+ const [local, snapshot, remoteState] = await Promise.all([
121
+ scanLocal(this._root).then((r) => {
122
+ timings.localMs = Date.now() - t0;
123
+ onPhase?.("local", timings.localMs);
124
+ return r;
125
+ }),
126
+ Promise.resolve(readLatestSnapshot(this._root)).then((r) => {
127
+ timings.snapshotMs = Date.now() - t0;
128
+ return r;
129
+ }),
130
+ this._loadRemoteState({ useCache }).then((r) => {
131
+ timings.remoteMs = Date.now() - t0;
132
+ timings.remoteCached = useCache && timings.remoteMs < 100;
133
+ onPhase?.("remote", timings.remoteMs);
134
+ return r;
135
+ }),
118
136
  ]);
119
-
120
- const remoteState = await this._loadRemoteState({ useCache });
121
137
  const remote = remoteState.files;
122
138
 
139
+ const diffStart = Date.now();
140
+ const diff = computeDiff(snapshot, remote, local, { root: this._root });
141
+ timings.diffMs = Date.now() - diffStart;
142
+ timings.totalMs = Date.now() - t0;
143
+ timings.localFiles = Object.keys(local).length;
144
+ timings.remoteFiles = remote.length;
145
+
123
146
  return {
124
147
  config,
125
148
  remote,
149
+ remoteState,
126
150
  local,
127
151
  snapshot,
128
- diff: computeDiff(snapshot, remote, local, { root: this._root }),
152
+ diff,
153
+ timings,
129
154
  };
130
155
  }
131
156
 
@@ -178,20 +203,31 @@ export class Repository {
178
203
  }
179
204
 
180
205
  /**
181
- * Invalidate cache, re-fetch remote + re-scan local, write snapshot.
206
+ * Build and persist a new snapshot.
207
+ *
208
+ * @param {string} message
209
+ * @param {object} [preloaded]
210
+ * @param {object} [preloaded.remote] Reuse this remote state (skip API call)
211
+ * @param {object} [preloaded.local] Reuse this local scan (skip fs walk)
182
212
  */
183
- async saveSnapshot(message = "sync") {
213
+ async saveSnapshot(message = "sync", { remote, local } = {}) {
184
214
  const config = this.getConfig();
185
215
  const rootFolderId = config.drive_folder_id || null;
186
216
 
187
- invalidateRemoteCache(this._root);
188
- const [remoteState, local] = await Promise.all([
189
- getRemoteState(this.drive, rootFolderId),
190
- scanLocal(this._root),
217
+ // Only fetch what wasn't pre-loaded, in parallel.
218
+ const needRemote = !remote;
219
+ const needLocal = !local;
220
+
221
+ if (needRemote) invalidateRemoteCache(this._root);
222
+
223
+ const [remoteState, localFiles] = await Promise.all([
224
+ needRemote ? getRemoteState(this.drive, rootFolderId) : remote,
225
+ needLocal ? scanLocal(this._root) : local,
191
226
  ]);
227
+
192
228
  assertNoDuplicateFolders(remoteState.duplicateFolders);
193
229
  writeRemoteCache(this._root, remoteState, rootFolderId);
194
- writeSnapshot(this._root, buildSnapshot(remoteState.files, local, message));
230
+ writeSnapshot(this._root, buildSnapshot(remoteState.files, localFiles, message));
195
231
  }
196
232
 
197
233
  // ── Cache management ────────────────────────────────────────────────
@@ -42,7 +42,7 @@ function saveHashCache(root, cache) {
42
42
 
43
43
  // ── Scanning ─────────────────────────────────────────────────────────
44
44
 
45
- const PARALLEL_HASH_LIMIT = 32;
45
+ const PARALLEL_HASH_LIMIT = 128;
46
46
 
47
47
  export async function scanLocal(root, { respectIgnore = true } = {}) {
48
48
  const resolvedRoot = path.resolve(root);
@@ -54,7 +54,8 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
54
54
  const filesToHash = [];
55
55
  // Track directories and their child counts to detect empty folders
56
56
  const dirChildCount = new Map();
57
- const dirStats = new Map();
57
+ // Map relative dir path → absolute path (for deferred stat on empty dirs only)
58
+ const dirAbsPath = new Map();
58
59
 
59
60
  async function walk(currentPath) {
60
61
  let entries;
@@ -72,6 +73,7 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
72
73
  if (relativeDirPath !== null) {
73
74
  if (!dirChildCount.has(relativeDirPath)) {
74
75
  dirChildCount.set(relativeDirPath, 0);
76
+ dirAbsPath.set(relativeDirPath, currentPath);
75
77
  }
76
78
  }
77
79
 
@@ -110,12 +112,6 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
110
112
 
111
113
  if (relativeDirPath !== null) {
112
114
  dirChildCount.set(relativeDirPath, trackedChildren);
113
- try {
114
- const stat = await fs.promises.stat(currentPath);
115
- dirStats.set(relativeDirPath, stat);
116
- } catch {
117
- // ignore
118
- }
119
115
  }
120
116
 
121
117
  await Promise.all([
@@ -171,16 +167,24 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
171
167
  }
172
168
  }
173
169
 
174
- for (const dirPath of emptyDirs) {
175
- const stat = dirStats.get(dirPath);
170
+ // Only stat the empty directories (not all directories)
171
+ await Promise.all([...emptyDirs].map(async (dirPath) => {
172
+ let mtime = new Date().toISOString();
173
+ const absPath = dirAbsPath.get(dirPath);
174
+ if (absPath) {
175
+ try {
176
+ const stat = await fs.promises.stat(absPath);
177
+ mtime = new Date(stat.mtimeMs).toISOString();
178
+ } catch { /* ignore */ }
179
+ }
176
180
  result[dirPath] = {
177
181
  localPath: dirPath,
178
182
  isFolder: true,
179
183
  size: 0,
180
184
  md5: null,
181
- modifiedTime: stat ? new Date(stat.mtimeMs).toISOString() : new Date().toISOString(),
185
+ modifiedTime: mtime,
182
186
  };
183
- }
187
+ }));
184
188
 
185
189
  // Persist updated cache
186
190
  saveHashCache(resolvedRoot, nextCache);