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 +8 -0
- package/package.json +1 -1
- package/src/cli.js +51 -11
- package/src/core/config.js +2 -1
- package/src/core/drive-api.js +15 -0
- package/src/core/remote-cache.js +1 -0
- package/src/core/repository.js +51 -15
- package/src/core/snapshot.js +16 -12
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
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(
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
413
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/core/config.js
CHANGED
|
@@ -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
|
-
|
|
116
|
+
// Compact JSON — snapshots can be large, pretty-printing is slow + wastes disk
|
|
117
|
+
fs.writeFileSync(latest, JSON.stringify(snapshot) + "\n");
|
|
117
118
|
}
|
package/src/core/drive-api.js
CHANGED
|
@@ -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
|
|
package/src/core/remote-cache.js
CHANGED
|
@@ -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({
|
package/src/core/repository.js
CHANGED
|
@@ -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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
152
|
+
diff,
|
|
153
|
+
timings,
|
|
129
154
|
};
|
|
130
155
|
}
|
|
131
156
|
|
|
@@ -178,20 +203,31 @@ export class Repository {
|
|
|
178
203
|
}
|
|
179
204
|
|
|
180
205
|
/**
|
|
181
|
-
*
|
|
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
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
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,
|
|
230
|
+
writeSnapshot(this._root, buildSnapshot(remoteState.files, localFiles, message));
|
|
195
231
|
}
|
|
196
232
|
|
|
197
233
|
// ── Cache management ────────────────────────────────────────────────
|
package/src/core/snapshot.js
CHANGED
|
@@ -42,7 +42,7 @@ function saveHashCache(root, cache) {
|
|
|
42
42
|
|
|
43
43
|
// ── Scanning ─────────────────────────────────────────────────────────
|
|
44
44
|
|
|
45
|
-
const PARALLEL_HASH_LIMIT =
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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:
|
|
185
|
+
modifiedTime: mtime,
|
|
182
186
|
};
|
|
183
|
-
}
|
|
187
|
+
}));
|
|
184
188
|
|
|
185
189
|
// Persist updated cache
|
|
186
190
|
saveHashCache(resolvedRoot, nextCache);
|