aethel 0.3.3 → 0.3.5

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.5 (2026-04-05)
4
+
5
+ - Add progress bars and spinners for all time-consuming CLI operations
6
+
7
+ ## 0.3.4 (2026-04-05)
8
+
9
+ - Add empty folder sync support between local and Google Drive
10
+
3
11
  ## 0.3.3 (2026-04-05)
4
12
 
5
13
  - Persist credentials to ~/.config/aethel/ after auth for seamless init
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aethel",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
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
@@ -31,6 +31,7 @@ import {
31
31
  DuplicateFoldersError,
32
32
  } from "./core/drive-api.js";
33
33
  import { createDefaultIgnoreFile, loadIgnoreRules } from "./core/ignore.js";
34
+ import { createProgressBar, createSpinner } from "./core/progress.js";
34
35
  import { Repository } from "./core/repository.js";
35
36
  import { runTui } from "./tui/index.js";
36
37
 
@@ -42,16 +43,36 @@ function addAuthOptions(command) {
42
43
  .option("--token <path>", "Path to cached OAuth token JSON");
43
44
  }
44
45
 
45
- async function openRepo(options, { requireWorkspace = true } = {}) {
46
+ async function openRepo(options, { requireWorkspace = true, silent = false } = {}) {
46
47
  const root = requireWorkspace ? requireRoot() : null;
47
48
  const repo = new Repository(root, {
48
49
  credentials: options.credentials,
49
50
  token: options.token,
50
51
  });
51
- await repo.connect();
52
+ const spinner = silent ? null : createSpinner("Connecting to Google Drive...");
53
+ try {
54
+ await repo.connect();
55
+ spinner?.succeed("Connected to Google Drive");
56
+ } catch (err) {
57
+ spinner?.fail("Connection failed");
58
+ throw err;
59
+ }
52
60
  return repo;
53
61
  }
54
62
 
63
+ async function loadStateWithProgress(repo, opts) {
64
+ const spinner = createSpinner("Loading workspace state...");
65
+ 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");
69
+ return state;
70
+ } catch (err) {
71
+ spinner.fail("Failed to load workspace state");
72
+ throw err;
73
+ }
74
+ }
75
+
55
76
  function matchesPattern(targetPath, pattern) {
56
77
  if (targetPath === pattern) {
57
78
  return true;
@@ -117,7 +138,9 @@ function requireConfirmation(options) {
117
138
 
118
139
  async function handleAuth(options) {
119
140
  const repo = await openRepo(options, { requireWorkspace: false });
141
+ const spinner = createSpinner("Fetching account info...");
120
142
  const account = await repo.getAccountInfo();
143
+ spinner.succeed(`Authenticated as ${account.email}`);
121
144
 
122
145
  const credentialsPath = resolveCredentialsPath(options.credentials);
123
146
  await persistCredentials(credentialsPath);
@@ -134,7 +157,9 @@ async function handleAuth(options) {
134
157
  async function handleClean(options) {
135
158
  requireConfirmation(options);
136
159
  const repo = await openRepo(options, { requireWorkspace: false });
160
+ const spinner = createSpinner("Listing remote files...");
137
161
  const files = await repo.listRemoteFiles({ includeSharedDrives: Boolean(options.sharedDrives) });
162
+ spinner.succeed(`Found ${files.length} file(s) on Drive`);
138
163
 
139
164
  printCleanerPlan(files, options);
140
165
 
@@ -148,13 +173,15 @@ async function handleClean(options) {
148
173
  return;
149
174
  }
150
175
 
176
+ const bar = createProgressBar(`Cleaning ${files.length} file(s)`, files.length);
151
177
  const result = await repo.batchOperateFiles(files, {
152
178
  permanent: Boolean(options.permanent),
153
179
  includeSharedDrives: Boolean(options.sharedDrives),
154
- onProgress: (done, total, verb, name) => {
155
- console.log(`[${done}/${total}] ${verb}: ${name}`);
180
+ onProgress: (done) => {
181
+ bar.update(done);
156
182
  },
157
183
  });
184
+ bar.done(`Cleaned ${files.length} file(s)`);
158
185
 
159
186
  if (result.errors) {
160
187
  console.log(`Completed with ${result.errors} error(s) out of ${files.length} file(s).`);
@@ -171,8 +198,9 @@ async function handleInit(options) {
171
198
  // Interactive folder selection when no --drive-folder is provided
172
199
  if (!driveFolderId) {
173
200
  const repo = await openRepo(options, { requireWorkspace: false });
174
- console.log("Fetching root-level Drive folders...");
201
+ const spinner = createSpinner("Fetching root-level Drive folders...");
175
202
  const folders = await repo.listRootFolders();
203
+ spinner.succeed(`Found ${folders.length} folder(s) in Drive root`);
176
204
 
177
205
  if (folders.length === 0) {
178
206
  console.log("No folders found in Drive root. Syncing entire My Drive.");
@@ -225,7 +253,7 @@ async function handleInit(options) {
225
253
 
226
254
  async function handleStatus(options) {
227
255
  const repo = await openRepo(options);
228
- const { diff } = await repo.loadState();
256
+ const { diff } = await loadStateWithProgress(repo);
229
257
  const staged = repo.getStagedEntries();
230
258
 
231
259
  if (diff.isClean && staged.length === 0) {
@@ -264,7 +292,7 @@ async function handleStatus(options) {
264
292
 
265
293
  async function handleDiff(options) {
266
294
  const repo = await openRepo(options);
267
- const { diff } = await repo.loadState();
295
+ const { diff } = await loadStateWithProgress(repo);
268
296
 
269
297
  if (diff.isClean) {
270
298
  console.log("No changes detected.");
@@ -298,7 +326,7 @@ async function handleDiff(options) {
298
326
 
299
327
  async function handleAdd(paths, options) {
300
328
  const repo = await openRepo(options);
301
- const { diff } = await repo.loadState();
329
+ const { diff } = await loadStateWithProgress(repo);
302
330
 
303
331
  if (options.all) {
304
332
  const toStage = diff.changes.filter(
@@ -367,25 +395,22 @@ async function handleCommit(options, { repo: existingRepo } = {}) {
367
395
  }
368
396
 
369
397
  const message = options.message || "sync";
398
+ const bar = createProgressBar(`Syncing ${staged.length} change(s)`, staged.length);
370
399
 
371
- console.log(`Committing ${staged.length} change(s)...`);
372
-
373
- const result = await repo.executeStaged((done, total, verb, name) => {
374
- if (done < total) {
375
- console.log(` [${done + 1}/${total}] ${verb}: ${name}`);
376
- }
400
+ const result = await repo.executeStaged((done) => {
401
+ bar.update(done + 1);
377
402
  });
378
403
 
379
- console.log(`\nCommit complete: ${result.summary}`);
404
+ bar.done(`Commit complete: ${result.summary}`);
380
405
  if (result.errors.length) {
381
406
  for (const error of result.errors) {
382
407
  console.log(` ERROR: ${error}`);
383
408
  }
384
409
  }
385
410
 
386
- console.log("Saving snapshot...");
411
+ const spinner = createSpinner("Saving snapshot...");
387
412
  await repo.saveSnapshot(message);
388
- console.log(`Snapshot saved: "${message}"`);
413
+ spinner.succeed(`Snapshot saved: "${message}"`);
389
414
  }
390
415
 
391
416
  function handleLog(options) {
@@ -409,10 +434,10 @@ async function handleFetch(options) {
409
434
  const repo = await openRepo(options);
410
435
 
411
436
  repo.invalidateRemoteCache();
412
- console.log("Fetching remote file list...");
437
+ const spinner = createSpinner("Fetching remote file list...");
413
438
  const remoteState = await repo.getRemoteState({ useCache: false });
414
439
  const remote = remoteState.files;
415
- console.log(`Found ${remote.length} file(s) on Drive.`);
440
+ spinner.succeed(`Found ${remote.length} file(s) on Drive`);
416
441
 
417
442
  const snapshot = repo.getSnapshot();
418
443
  if (snapshot) {
@@ -445,7 +470,7 @@ async function handleFetch(options) {
445
470
 
446
471
  async function handlePull(paths, options) {
447
472
  const repo = await openRepo(options);
448
- const { diff } = await repo.loadState({ useCache: false });
473
+ const { diff } = await loadStateWithProgress(repo, { useCache: false });
449
474
 
450
475
  let remoteChanges = diff.changes.filter((change) =>
451
476
  [
@@ -494,7 +519,7 @@ async function handlePull(paths, options) {
494
519
 
495
520
  async function handlePush(paths, options) {
496
521
  const repo = await openRepo(options);
497
- const { diff } = await repo.loadState({ useCache: false });
522
+ const { diff } = await loadStateWithProgress(repo, { useCache: false });
498
523
 
499
524
  let localChanges = diff.changes.filter((change) =>
500
525
  [
@@ -543,7 +568,7 @@ async function handlePush(paths, options) {
543
568
 
544
569
  async function handleResolve(paths, options) {
545
570
  const repo = await openRepo(options);
546
- const { diff } = await repo.loadState();
571
+ const { diff } = await loadStateWithProgress(repo);
547
572
  const conflicts = diff.conflicts;
548
573
 
549
574
  if (conflicts.length === 0) {
@@ -719,7 +744,7 @@ async function handleRestore(paths, options) {
719
744
  }
720
745
 
721
746
  const localDest = path.join(root, entry.localPath || entry.path);
722
- console.log(` Restoring ${targetPath} from Drive...`);
747
+ const spinner = createSpinner(`Restoring ${targetPath}...`);
723
748
 
724
749
  try {
725
750
  const meta = await repo.drive.files.get({
@@ -729,16 +754,16 @@ async function handleRestore(paths, options) {
729
754
 
730
755
  const { downloadFile } = await import("./core/drive-api.js");
731
756
  await downloadFile(repo.drive, { ...meta.data, id: entry.id }, localDest);
732
- console.log(` Restored: ${targetPath}`);
757
+ spinner.succeed(`Restored: ${targetPath}`);
733
758
  } catch (err) {
734
- console.log(` Failed to restore ${targetPath}: ${err.message}`);
759
+ spinner.fail(`Failed to restore ${targetPath}: ${err.message}`);
735
760
  }
736
761
  }
737
762
  }
738
763
 
739
764
  async function handleRm(paths, options) {
740
765
  const repo = await openRepo(options);
741
- const { diff } = await repo.loadState();
766
+ const { diff } = await loadStateWithProgress(repo);
742
767
  const root = repo.root;
743
768
 
744
769
  for (const targetPath of paths) {
@@ -784,7 +809,7 @@ async function handleMv(source, dest, options) {
784
809
  }
785
810
 
786
811
  async function handleTui(options) {
787
- const repo = await openRepo(options, { requireWorkspace: false });
812
+ const repo = await openRepo(options, { requireWorkspace: false, silent: true });
788
813
  const cliArgs = [];
789
814
  if (options.credentials) {
790
815
  cliArgs.push("--credentials", options.credentials);
@@ -819,6 +844,7 @@ async function handleDedupeFolders(options) {
819
844
  const config = repo.getConfig();
820
845
  const rootFolderId = config.drive_folder_id || null;
821
846
  const ignoreRules = loadIgnoreRules(repo.root);
847
+ const dedupeSpinner = createSpinner("Scanning for duplicate folders...");
822
848
  const result = await dedupeDuplicateFolders(repo.drive, rootFolderId, {
823
849
  execute: Boolean(options.execute),
824
850
  ignoreRules,
@@ -848,8 +874,9 @@ async function handleDedupeFolders(options) {
848
874
  },
849
875
  });
850
876
 
877
+ dedupeSpinner.succeed(`Scan complete — ${result.duplicateFolders.length} duplicate group(s) found`);
878
+
851
879
  if (result.duplicateFolders.length === 0) {
852
- console.log("No duplicate folders detected.");
853
880
  return;
854
881
  }
855
882
 
package/src/core/diff.js CHANGED
@@ -87,6 +87,11 @@ function buildDiffResult(changes) {
87
87
  }
88
88
 
89
89
  function remoteChanged(snapshotEntry, remoteEntry) {
90
+ // Folders don't change — only their existence matters
91
+ if (remoteEntry.isFolder || remoteEntry.mimeType === "application/vnd.google-apps.folder") {
92
+ return false;
93
+ }
94
+
90
95
  if (isWorkspaceType(remoteEntry.mimeType || "")) {
91
96
  return snapshotEntry.modifiedTime !== remoteEntry.modifiedTime;
92
97
  }
@@ -95,6 +100,8 @@ function remoteChanged(snapshotEntry, remoteEntry) {
95
100
  }
96
101
 
97
102
  function localChanged(snapshotEntry, localEntry) {
103
+ // Folders don't change — only their existence matters
104
+ if (localEntry.isFolder) return false;
98
105
  return snapshotEntry.md5 !== localEntry.md5;
99
106
  }
100
107
 
@@ -153,6 +160,21 @@ function promoteConflicts(changes) {
153
160
  * @param {object} localFiles
154
161
  * @param {{ root?: string, respectIgnore?: boolean }} options
155
162
  */
163
+ /**
164
+ * Collect all implicit folder paths from a set of file paths.
165
+ * e.g. "a/b/c.txt" → {"a", "a/b"}
166
+ */
167
+ function collectFolderPaths(filePaths) {
168
+ const folders = new Set();
169
+ for (const p of filePaths) {
170
+ const parts = p.split("/");
171
+ for (let i = 1; i < parts.length; i++) {
172
+ folders.add(parts.slice(0, i).join("/"));
173
+ }
174
+ }
175
+ return folders;
176
+ }
177
+
156
178
  export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIgnore = true } = {}) {
157
179
  const ignoreRules = root && respectIgnore ? loadIgnoreRules(root) : null;
158
180
 
@@ -164,6 +186,19 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
164
186
  const snapshotFiles = snapshot?.files || {};
165
187
  const snapshotLocalFiles = snapshot?.localFiles || {};
166
188
 
189
+ // Build sets of all folder paths that implicitly exist on each side
190
+ // (from parent directories of files), so we can skip redundant folder additions.
191
+ const remoteFolderPaths = collectFolderPaths(remoteFiles.map((f) => f.path));
192
+ const localFolderPaths = collectFolderPaths(Object.keys(localFiles));
193
+
194
+ // Also include explicit folder entries
195
+ for (const f of remoteFiles) {
196
+ if (f.isFolder) remoteFolderPaths.add(f.path);
197
+ }
198
+ for (const [p, meta] of Object.entries(localFiles)) {
199
+ if (meta.isFolder) localFolderPaths.add(p);
200
+ }
201
+
167
202
  // Build remote lookup and detect additions/modifications in one pass
168
203
  const remoteById = new Map();
169
204
  for (const remoteFile of remoteFiles) {
@@ -171,6 +206,10 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
171
206
  const snapshotEntry = snapshotFiles[remoteFile.id];
172
207
 
173
208
  if (!snapshotEntry) {
209
+ // Skip remote folder if it already exists locally (as parent or explicit dir)
210
+ if (remoteFile.isFolder && localFolderPaths.has(remoteFile.path)) {
211
+ continue;
212
+ }
174
213
  changes.push(
175
214
  createChange({
176
215
  changeType: ChangeType.REMOTE_ADDED,
@@ -199,6 +238,11 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
199
238
  for (const fileId of Object.keys(snapshotFiles)) {
200
239
  if (!remoteById.has(fileId)) {
201
240
  const snapshotEntry = snapshotFiles[fileId];
241
+ // Skip folder deletion if the folder still implicitly exists on Drive
242
+ // (e.g. it became non-empty, or was recreated with a different ID)
243
+ if (snapshotEntry.isFolder && remoteFolderPaths.has(snapshotEntry.path || snapshotEntry.localPath || "")) {
244
+ continue;
245
+ }
202
246
  changes.push(
203
247
  createChange({
204
248
  changeType: ChangeType.REMOTE_DELETED,
@@ -214,6 +258,10 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
214
258
  const snapshotEntry = snapshotLocalFiles[relativePath];
215
259
 
216
260
  if (!snapshotEntry) {
261
+ // Skip local folder if it already exists on Drive (as parent or explicit dir)
262
+ if (localMeta.isFolder && remoteFolderPaths.has(relativePath)) {
263
+ continue;
264
+ }
217
265
  changes.push(
218
266
  createChange({
219
267
  changeType: ChangeType.LOCAL_ADDED,
@@ -238,6 +286,10 @@ export function computeDiff(snapshot, remoteFiles, localFiles, { root, respectIg
238
286
 
239
287
  for (const [relativePath, snapshotEntry] of Object.entries(snapshotLocalFiles)) {
240
288
  if (!(relativePath in localFiles)) {
289
+ // Skip folder deletion if the folder still implicitly exists locally
290
+ if (snapshotEntry.isFolder && localFolderPaths.has(relativePath)) {
291
+ continue;
292
+ }
241
293
  changes.push(
242
294
  createChange({
243
295
  changeType: ChangeType.LOCAL_DELETED,
@@ -302,6 +302,10 @@ function buildRemoteFiles(folders, rawFiles, rootFolderId = null) {
302
302
  const resolve = createFolderResolver(folders, rootFolderId);
303
303
  const isOrphaned = createOrphanChecker(folders);
304
304
  const files = [];
305
+
306
+ // Track which folders have children (files or subfolders)
307
+ const foldersWithChildren = new Set();
308
+
305
309
  for (const file of rawFiles) {
306
310
  const parentId = file.parents?.[0] || "";
307
311
 
@@ -312,6 +316,9 @@ function buildRemoteFiles(folders, rawFiles, rootFolderId = null) {
312
316
 
313
317
  if (rootFolderId && parentPath === null) continue;
314
318
 
319
+ // Mark parent as having children
320
+ if (parentId) foldersWithChildren.add(parentId);
321
+
315
322
  files.push({
316
323
  id: file.id,
317
324
  name: file.name,
@@ -322,6 +329,34 @@ function buildRemoteFiles(folders, rawFiles, rootFolderId = null) {
322
329
  md5Checksum: file.md5Checksum || null,
323
330
  });
324
331
  }
332
+
333
+ // Mark folders that have subfolders as children
334
+ for (const folder of folders.values()) {
335
+ const parentId = folder.parents?.[0] || "";
336
+ if (parentId) foldersWithChildren.add(parentId);
337
+ }
338
+
339
+ // Include empty folders (no file children AND no subfolder children)
340
+ for (const folder of folders.values()) {
341
+ if (foldersWithChildren.has(folder.id)) continue;
342
+ if (isOrphaned(folder.id)) continue;
343
+
344
+ const folderPath = resolve(folder.id);
345
+ if (rootFolderId && folderPath === null) continue;
346
+ if (!folderPath) continue; // skip root-level marker
347
+
348
+ files.push({
349
+ id: folder.id,
350
+ name: folder.name,
351
+ path: folderPath,
352
+ mimeType: FOLDER_MIME,
353
+ size: null,
354
+ modifiedTime: folder.modifiedTime || null,
355
+ md5Checksum: null,
356
+ isFolder: true,
357
+ });
358
+ }
359
+
325
360
  return files;
326
361
  }
327
362
 
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Lightweight terminal progress indicators (spinner + bar).
3
+ * Writes to stderr so stdout stays clean for piped output.
4
+ */
5
+
6
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
7
+ const SPINNER_INTERVAL = 80;
8
+ const BAR_WIDTH = 25;
9
+
10
+ const isTTY = process.stderr.isTTY;
11
+
12
+ function clearLine() {
13
+ if (isTTY) process.stderr.write("\r\x1b[K");
14
+ }
15
+
16
+ export function createSpinner(message) {
17
+ if (!isTTY) {
18
+ process.stderr.write(`${message}\n`);
19
+ return {
20
+ update() {},
21
+ succeed(msg) { if (msg) process.stderr.write(`${msg}\n`); },
22
+ fail(msg) { if (msg) process.stderr.write(`${msg}\n`); },
23
+ stop() {},
24
+ };
25
+ }
26
+
27
+ let frame = 0;
28
+ let currentMessage = message;
29
+
30
+ const timer = setInterval(() => {
31
+ clearLine();
32
+ process.stderr.write(`${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} ${currentMessage}`);
33
+ frame++;
34
+ }, SPINNER_INTERVAL);
35
+
36
+ return {
37
+ update(msg) { currentMessage = msg; },
38
+ succeed(msg) {
39
+ clearInterval(timer);
40
+ clearLine();
41
+ process.stderr.write(`✔ ${msg || currentMessage}\n`);
42
+ },
43
+ fail(msg) {
44
+ clearInterval(timer);
45
+ clearLine();
46
+ process.stderr.write(`✖ ${msg || currentMessage}\n`);
47
+ },
48
+ stop() {
49
+ clearInterval(timer);
50
+ clearLine();
51
+ },
52
+ };
53
+ }
54
+
55
+ export function createProgressBar(label, total) {
56
+ let lastRendered = -1;
57
+
58
+ function render(current) {
59
+ if (current === lastRendered) return;
60
+ lastRendered = current;
61
+
62
+ const ratio = total > 0 ? Math.min(current / total, 1) : 0;
63
+ const filled = Math.round(BAR_WIDTH * ratio);
64
+ const empty = BAR_WIDTH - filled;
65
+ const pct = Math.round(ratio * 100);
66
+ const bar = "█".repeat(filled) + "░".repeat(empty);
67
+ const line = `${label} [${bar}] ${current}/${total} (${pct}%)`;
68
+
69
+ if (isTTY) {
70
+ clearLine();
71
+ process.stderr.write(line);
72
+ }
73
+ }
74
+
75
+ // Initial render
76
+ render(0);
77
+
78
+ return {
79
+ update(current) { render(current); },
80
+ done(msg) {
81
+ render(total);
82
+ if (isTTY) {
83
+ clearLine();
84
+ process.stderr.write(`✔ ${msg || label}\n`);
85
+ } else {
86
+ process.stderr.write(`${msg || label}\n`);
87
+ }
88
+ },
89
+ };
90
+ }
@@ -52,6 +52,9 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
52
52
 
53
53
  // Phase 1: collect all file stats (fast — no hashing yet)
54
54
  const filesToHash = [];
55
+ // Track directories and their child counts to detect empty folders
56
+ const dirChildCount = new Map();
57
+ const dirStats = new Map();
55
58
 
56
59
  async function walk(currentPath) {
57
60
  let entries;
@@ -61,8 +64,20 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
61
64
  return;
62
65
  }
63
66
 
67
+ const relativeDirPath = currentPath === resolvedRoot
68
+ ? null
69
+ : path.relative(resolvedRoot, currentPath).split(path.sep).join("/");
70
+
71
+ // Register this directory (skip root itself)
72
+ if (relativeDirPath !== null) {
73
+ if (!dirChildCount.has(relativeDirPath)) {
74
+ dirChildCount.set(relativeDirPath, 0);
75
+ }
76
+ }
77
+
64
78
  const subdirs = [];
65
79
  const statPromises = [];
80
+ let trackedChildren = 0;
66
81
 
67
82
  for (const entry of entries) {
68
83
  const fullPath = path.join(currentPath, entry.name);
@@ -76,6 +91,7 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
76
91
  }
77
92
 
78
93
  if (entry.isDirectory()) {
94
+ trackedChildren++;
79
95
  subdirs.push(fullPath);
80
96
  continue;
81
97
  }
@@ -84,6 +100,7 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
84
100
  continue;
85
101
  }
86
102
 
103
+ trackedChildren++;
87
104
  statPromises.push(
88
105
  fs.promises.stat(fullPath).then((stat) => {
89
106
  filesToHash.push({ fullPath, relativePath, stat });
@@ -91,6 +108,16 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
91
108
  );
92
109
  }
93
110
 
111
+ if (relativeDirPath !== null) {
112
+ 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
+ }
120
+
94
121
  await Promise.all([
95
122
  ...statPromises,
96
123
  ...subdirs.map((dir) => walk(dir)),
@@ -121,6 +148,40 @@ export async function scanLocal(root, { respectIgnore = true } = {}) {
121
148
  }
122
149
  }
123
150
 
151
+ // Phase 3: detect empty folders (directories with zero tracked children)
152
+ // Walk bottom-up: a dir is "empty" if it has no tracked children AND
153
+ // all its subdirectories are also empty.
154
+ const emptyDirs = new Set();
155
+ // Sort by depth (deepest first) for bottom-up processing
156
+ const sortedDirs = [...dirChildCount.keys()].sort(
157
+ (a, b) => b.split("/").length - a.split("/").length
158
+ );
159
+
160
+ for (const dirPath of sortedDirs) {
161
+ const childCount = dirChildCount.get(dirPath);
162
+ if (childCount === 0) {
163
+ emptyDirs.add(dirPath);
164
+ // Propagate: decrement parent's tracked child count since this child is empty
165
+ const parentDir = dirPath.includes("/")
166
+ ? dirPath.slice(0, dirPath.lastIndexOf("/"))
167
+ : null;
168
+ if (parentDir && dirChildCount.has(parentDir)) {
169
+ dirChildCount.set(parentDir, dirChildCount.get(parentDir) - 1);
170
+ }
171
+ }
172
+ }
173
+
174
+ for (const dirPath of emptyDirs) {
175
+ const stat = dirStats.get(dirPath);
176
+ result[dirPath] = {
177
+ localPath: dirPath,
178
+ isFolder: true,
179
+ size: 0,
180
+ md5: null,
181
+ modifiedTime: stat ? new Date(stat.mtimeMs).toISOString() : new Date().toISOString(),
182
+ };
183
+ }
184
+
124
185
  // Persist updated cache
125
186
  saveHashCache(resolvedRoot, nextCache);
126
187
  return result;
@@ -158,6 +219,7 @@ export function buildSnapshot(remoteFiles, localFiles, message = "") {
158
219
  mimeType: file.mimeType || "",
159
220
  modifiedTime: file.modifiedTime ?? null,
160
221
  localPath: file.path,
222
+ ...(file.isFolder ? { isFolder: true } : {}),
161
223
  };
162
224
  }
163
225
 
@@ -19,6 +19,11 @@ function changeToEntry(change) {
19
19
  entry.remotePath = change.remoteMeta.path;
20
20
  }
21
21
 
22
+ // Propagate folder flag so sync knows to create folder instead of uploading file
23
+ if (change.localMeta?.isFolder || change.remoteMeta?.isFolder) {
24
+ entry.isFolder = true;
25
+ }
26
+
22
27
  return entry;
23
28
  }
24
29
 
package/src/core/sync.js CHANGED
@@ -20,6 +20,7 @@ export class CommitResult {
20
20
  this.uploaded = 0;
21
21
  this.deletedLocal = 0;
22
22
  this.deletedRemote = 0;
23
+ this.foldersCreated = 0;
23
24
  this.errors = [];
24
25
  }
25
26
 
@@ -28,7 +29,8 @@ export class CommitResult {
28
29
  this.downloaded +
29
30
  this.uploaded +
30
31
  this.deletedLocal +
31
- this.deletedRemote
32
+ this.deletedRemote +
33
+ this.foldersCreated
32
34
  );
33
35
  }
34
36
 
@@ -41,6 +43,9 @@ export class CommitResult {
41
43
  if (this.uploaded) {
42
44
  parts.push(`${this.uploaded} uploaded`);
43
45
  }
46
+ if (this.foldersCreated) {
47
+ parts.push(`${this.foldersCreated} folders created`);
48
+ }
44
49
  if (this.deletedLocal) {
45
50
  parts.push(`${this.deletedLocal} deleted locally`);
46
51
  }
@@ -56,9 +61,16 @@ export class CommitResult {
56
61
  }
57
62
 
58
63
  async function downloadStagedFile(drive, entry, root) {
59
- const fileId = entry.fileId;
60
64
  const localRelativePath = entry.localPath || entry.path;
61
65
  const localAbsolutePath = toLocalAbsolutePath(root, localRelativePath);
66
+
67
+ // Empty folder: just create the directory locally
68
+ if (entry.isFolder) {
69
+ fs.mkdirSync(localAbsolutePath, { recursive: true });
70
+ return;
71
+ }
72
+
73
+ const fileId = entry.fileId;
62
74
  const response = await drive.files.get({
63
75
  fileId,
64
76
  fields: "id,name,mimeType",
@@ -69,9 +81,16 @@ async function downloadStagedFile(drive, entry, root) {
69
81
 
70
82
  async function uploadStagedFile(drive, entry, root, driveFolderId) {
71
83
  const localRelativePath = entry.localPath || entry.path;
72
- const localAbsolutePath = toLocalAbsolutePath(root, localRelativePath);
73
84
  const remotePath = entry.remotePath || entry.path;
74
85
 
86
+ // Empty folder: just ensure it exists on Drive
87
+ if (entry.isFolder) {
88
+ await ensureFolder(drive, remotePath, driveFolderId);
89
+ return;
90
+ }
91
+
92
+ const localAbsolutePath = toLocalAbsolutePath(root, localRelativePath);
93
+
75
94
  if (!fs.existsSync(localAbsolutePath)) {
76
95
  throw new Error(`Local file not found: ${localAbsolutePath}`);
77
96
  }
@@ -97,18 +116,25 @@ async function deleteLocalFile(entry, root) {
97
116
  return;
98
117
  }
99
118
 
100
- await fs.promises.unlink(localAbsolutePath);
119
+ // Empty folder: remove the directory itself
120
+ if (entry.isFolder) {
121
+ await fs.promises.rmdir(localAbsolutePath).catch(() => {});
122
+ } else {
123
+ await fs.promises.unlink(localAbsolutePath);
124
+ }
101
125
 
102
- let currentPath = path.dirname(localAbsolutePath);
126
+ // Clean up empty parent directories up to workspace root
127
+ let currentPath = entry.isFolder ? localAbsolutePath : path.dirname(localAbsolutePath);
103
128
  const resolvedRoot = path.resolve(root);
104
129
 
105
130
  while (currentPath !== resolvedRoot) {
106
- const contents = await fs.promises.readdir(currentPath);
107
- if (contents.length > 0) {
131
+ try {
132
+ const contents = await fs.promises.readdir(currentPath);
133
+ if (contents.length > 0) break;
134
+ await fs.promises.rmdir(currentPath);
135
+ } catch {
108
136
  break;
109
137
  }
110
-
111
- await fs.promises.rmdir(currentPath);
112
138
  currentPath = path.dirname(currentPath);
113
139
  }
114
140
  }
@@ -195,10 +221,12 @@ export async function executeStaged(drive, root, progress) {
195
221
  const action = entry.action;
196
222
  if (action === "download") {
197
223
  await downloadStagedFile(drive, entry, root);
198
- result.downloaded++;
224
+ if (entry.isFolder) result.foldersCreated++;
225
+ else result.downloaded++;
199
226
  } else if (action === "upload") {
200
227
  await uploadStagedFile(drive, entry, root, driveFolderId);
201
- result.uploaded++;
228
+ if (entry.isFolder) result.foldersCreated++;
229
+ else result.uploaded++;
202
230
  } else if (action === "delete_remote") {
203
231
  await deleteRemoteFile(drive, entry);
204
232
  result.deletedRemote++;