aethel 0.3.3 → 0.3.4

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.4 (2026-04-05)
4
+
5
+ - Add empty folder sync support between local and Google Drive
6
+
3
7
  ## 0.3.3 (2026-04-05)
4
8
 
5
9
  - 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.4",
4
4
  "description": "Git-style Google Drive sync CLI with interactive TUI",
5
5
  "type": "module",
6
6
  "license": "MIT",
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
 
@@ -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++;