aethel 0.3.5 → 0.3.6

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,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.6 (2026-04-05)
4
+
5
+ - Optimize status and saveSnapshot performance: parallelize loadState, skip redundant fetches, increase hash concurrency
6
+
3
7
  ## 0.3.5 (2026-04-05)
4
8
 
5
9
  - 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.6",
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
@@ -385,7 +385,7 @@ function handleReset(paths, options) {
385
385
  }
386
386
  }
387
387
 
388
- async function handleCommit(options, { repo: existingRepo } = {}) {
388
+ async function handleCommit(options, { repo: existingRepo, snapshotHint } = {}) {
389
389
  const repo = existingRepo || await openRepo(options);
390
390
  const staged = repo.getStagedEntries();
391
391
 
@@ -409,7 +409,9 @@ async function handleCommit(options, { repo: existingRepo } = {}) {
409
409
  }
410
410
 
411
411
  const spinner = createSpinner("Saving snapshot...");
412
- await repo.saveSnapshot(message);
412
+ // snapshotHint lets callers (pull/push) pass pre-loaded state
413
+ // so saveSnapshot skips redundant API calls / fs scans.
414
+ await repo.saveSnapshot(message, snapshotHint);
413
415
  spinner.succeed(`Snapshot saved: "${message}"`);
414
416
  }
415
417
 
@@ -470,7 +472,7 @@ async function handleFetch(options) {
470
472
 
471
473
  async function handlePull(paths, options) {
472
474
  const repo = await openRepo(options);
473
- const { diff } = await loadStateWithProgress(repo, { useCache: false });
475
+ const { diff, remoteState } = await loadStateWithProgress(repo, { useCache: false });
474
476
 
475
477
  let remoteChanges = diff.changes.filter((change) =>
476
478
  [
@@ -514,12 +516,16 @@ async function handlePull(paths, options) {
514
516
 
515
517
  const count = repo.stageChanges(remoteChanges);
516
518
  console.log(`Staged ${count} remote change(s). Committing...`);
517
- await handleCommit({ ...options, message: options.message || "pull" }, { repo });
519
+ // Pull downloads remote→local: remote state unchanged, only re-scan local
520
+ await handleCommit({ ...options, message: options.message || "pull" }, {
521
+ repo,
522
+ snapshotHint: { remote: remoteState },
523
+ });
518
524
  }
519
525
 
520
526
  async function handlePush(paths, options) {
521
527
  const repo = await openRepo(options);
522
- const { diff } = await loadStateWithProgress(repo, { useCache: false });
528
+ const { diff, local } = await loadStateWithProgress(repo, { useCache: false });
523
529
 
524
530
  let localChanges = diff.changes.filter((change) =>
525
531
  [
@@ -563,7 +569,11 @@ async function handlePush(paths, options) {
563
569
 
564
570
  const count = repo.stageChanges(localChanges);
565
571
  console.log(`Staged ${count} local change(s). Committing...`);
566
- await handleCommit({ ...options, message: options.message || "push" }, { repo });
572
+ // Push uploads local→remote: local state unchanged, only re-fetch remote
573
+ await handleCommit({ ...options, message: options.message || "push" }, {
574
+ repo,
575
+ snapshotHint: { local },
576
+ });
567
577
  }
568
578
 
569
579
  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
  }
@@ -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({
@@ -112,17 +112,19 @@ export class Repository {
112
112
  async loadState({ useCache = true } = {}) {
113
113
  const config = this.getConfig();
114
114
 
115
- const [local, snapshot] = await Promise.all([
115
+ // Run all three in parallel — remote fetch is the slowest, overlap it
116
+ // with local scan and snapshot read.
117
+ const [local, snapshot, remoteState] = await Promise.all([
116
118
  scanLocal(this._root),
117
119
  Promise.resolve(readLatestSnapshot(this._root)),
120
+ this._loadRemoteState({ useCache }),
118
121
  ]);
119
-
120
- const remoteState = await this._loadRemoteState({ useCache });
121
122
  const remote = remoteState.files;
122
123
 
123
124
  return {
124
125
  config,
125
126
  remote,
127
+ remoteState,
126
128
  local,
127
129
  snapshot,
128
130
  diff: computeDiff(snapshot, remote, local, { root: this._root }),
@@ -178,20 +180,31 @@ export class Repository {
178
180
  }
179
181
 
180
182
  /**
181
- * Invalidate cache, re-fetch remote + re-scan local, write snapshot.
183
+ * Build and persist a new snapshot.
184
+ *
185
+ * @param {string} message
186
+ * @param {object} [preloaded]
187
+ * @param {object} [preloaded.remote] Reuse this remote state (skip API call)
188
+ * @param {object} [preloaded.local] Reuse this local scan (skip fs walk)
182
189
  */
183
- async saveSnapshot(message = "sync") {
190
+ async saveSnapshot(message = "sync", { remote, local } = {}) {
184
191
  const config = this.getConfig();
185
192
  const rootFolderId = config.drive_folder_id || null;
186
193
 
187
- invalidateRemoteCache(this._root);
188
- const [remoteState, local] = await Promise.all([
189
- getRemoteState(this.drive, rootFolderId),
190
- scanLocal(this._root),
194
+ // Only fetch what wasn't pre-loaded, in parallel.
195
+ const needRemote = !remote;
196
+ const needLocal = !local;
197
+
198
+ if (needRemote) invalidateRemoteCache(this._root);
199
+
200
+ const [remoteState, localFiles] = await Promise.all([
201
+ needRemote ? getRemoteState(this.drive, rootFolderId) : remote,
202
+ needLocal ? scanLocal(this._root) : local,
191
203
  ]);
204
+
192
205
  assertNoDuplicateFolders(remoteState.duplicateFolders);
193
206
  writeRemoteCache(this._root, remoteState, rootFolderId);
194
- writeSnapshot(this._root, buildSnapshot(remoteState.files, local, message));
207
+ writeSnapshot(this._root, buildSnapshot(remoteState.files, localFiles, message));
195
208
  }
196
209
 
197
210
  // ── 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);