gdrive-syncer 3.0.0 → 3.1.1

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/src/envSync.js CHANGED
@@ -565,6 +565,313 @@ const envShow = async () => {
565
565
  }
566
566
  };
567
567
 
568
+ // =============================================================================
569
+ // Performance Optimization: Parallel API calls for faster sync operations
570
+ // See docs/PERFORMANCE_DECISIONS.md for design rationale
571
+ // =============================================================================
572
+
573
+ /**
574
+ * Session-scoped folder cache to avoid repeated API calls within a single operation.
575
+ * Structure: parentId -> Map(folderName -> folderId)
576
+ * IMPORTANT: Must be cleared at the start of each sync/diff operation via clearFolderCache()
577
+ */
578
+ let folderCache = new Map();
579
+
580
+ /**
581
+ * Clear folder cache - MUST be called at start of each sync operation
582
+ * This ensures we always start with fresh data from Google Drive
583
+ */
584
+ const clearFolderCache = () => {
585
+ folderCache = new Map();
586
+ };
587
+
588
+ /**
589
+ * Get cached folder ID or null if not cached
590
+ * @param {string} parentId - Parent folder ID
591
+ * @param {string} folderName - Folder name to look up
592
+ * @returns {string|null} - Folder ID if cached, null otherwise
593
+ */
594
+ const getCachedFolderId = (parentId, folderName) => {
595
+ const parentCache = folderCache.get(parentId);
596
+ return parentCache ? parentCache.get(folderName) || null : null;
597
+ };
598
+
599
+ /**
600
+ * Cache a folder ID for later lookups within the same operation
601
+ * @param {string} parentId - Parent folder ID
602
+ * @param {string} folderName - Folder name
603
+ * @param {string} folderId - Folder ID to cache
604
+ */
605
+ const cacheFolderId = (parentId, folderName, folderId) => {
606
+ if (!folderCache.has(parentId)) {
607
+ folderCache.set(parentId, new Map());
608
+ }
609
+ folderCache.get(parentId).set(folderName, folderId);
610
+ };
611
+
612
+ /**
613
+ * Optimized recursive listing using breadth-first traversal with parallel API calls
614
+ * This significantly improves performance for nested folder structures
615
+ * @param {string} folderId - Root folder ID
616
+ * @param {string} pattern - File pattern to match
617
+ * @param {string} [ignore] - Ignore pattern
618
+ * @param {boolean} [includeFolders=false] - Whether to include folders in results
619
+ * @returns {Promise<Array<{id: string, name: string, relativePath: string, isFolder: boolean, sizeBytes: number, modifiedTime: Date|null}>>}
620
+ */
621
+ const listDriveFilesRecursiveOptimized = async (folderId, pattern, ignore, includeFolders = false) => {
622
+ const results = [];
623
+ // Queue of folders to process: { id, prefix }
624
+ let queue = [{ id: folderId, prefix: '' }];
625
+
626
+ while (queue.length > 0) {
627
+ // Process all folders at current level in parallel
628
+ const currentBatch = queue;
629
+ queue = [];
630
+
631
+ // Fetch all folder contents in parallel (limit concurrency to 5)
632
+ const batchSize = 5;
633
+ for (let i = 0; i < currentBatch.length; i += batchSize) {
634
+ const batch = currentBatch.slice(i, i + batchSize);
635
+ const listPromises = batch.map(async ({ id, prefix }) => {
636
+ const listResult = await gdrive.listAsync({
637
+ query: `'${id}' in parents and trashed = false`,
638
+ noHeader: true,
639
+ max: 1000,
640
+ });
641
+
642
+ if (listResult.code !== 0 || !listResult.stdout.trim()) {
643
+ return { items: [], folders: [] };
644
+ }
645
+
646
+ const parsed = gdrive.parseListOutput(listResult.stdout.trim());
647
+ const items = [];
648
+ const folders = [];
649
+
650
+ for (const file of parsed) {
651
+ if (!file.id || !file.name) continue;
652
+
653
+ const relativePath = prefix ? `${prefix}/${file.name}` : file.name;
654
+ const isFolder = file.type && file.type.toLowerCase().includes('folder');
655
+
656
+ if (isFolder) {
657
+ // Queue folder for next level processing
658
+ folders.push({ id: file.id, prefix: relativePath });
659
+ if (includeFolders) {
660
+ items.push({ id: file.id, name: file.name, relativePath, isFolder: true, sizeBytes: 0, modifiedTime: null });
661
+ }
662
+ } else {
663
+ // Only add files that match the pattern
664
+ if (matchPattern(file.name, pattern, ignore)) {
665
+ items.push({
666
+ id: file.id,
667
+ name: file.name,
668
+ relativePath,
669
+ isFolder: false,
670
+ sizeBytes: file.sizeBytes || 0,
671
+ modifiedTime: file.modifiedTime || null,
672
+ });
673
+ }
674
+ }
675
+ }
676
+
677
+ return { items, folders };
678
+ });
679
+
680
+ const batchResults = await Promise.all(listPromises);
681
+ for (const { items, folders } of batchResults) {
682
+ results.push(...items);
683
+ queue.push(...folders);
684
+ }
685
+ }
686
+ }
687
+
688
+ return results;
689
+ };
690
+
691
+ /**
692
+ * Recursively list files and folders from Google Drive
693
+ * @param {string} folderId - Root folder ID
694
+ * @param {string} pattern - File pattern to match
695
+ * @param {string} [ignore] - Ignore pattern
696
+ * @param {string} [prefix=''] - Path prefix for nested files
697
+ * @param {boolean} [includeFolders=false] - Whether to include folders in results
698
+ * @returns {Array<{id: string, name: string, relativePath: string, isFolder: boolean}>}
699
+ */
700
+ const listDriveFilesRecursive = (folderId, pattern, ignore, prefix = '', includeFolders = false) => {
701
+ const results = [];
702
+
703
+ const listResult = gdrive.list({
704
+ query: `'${folderId}' in parents and trashed = false`,
705
+ noHeader: true,
706
+ max: 1000,
707
+ });
708
+
709
+ if (listResult.code !== 0) {
710
+ return results;
711
+ }
712
+
713
+ const stdout = listResult.stdout.trim();
714
+ if (!stdout) return results;
715
+
716
+ const parsed = gdrive.parseListOutput(stdout);
717
+
718
+ for (const file of parsed) {
719
+ if (!file.id || !file.name) continue;
720
+
721
+ const relativePath = prefix ? `${prefix}/${file.name}` : file.name;
722
+ const isFolder = file.type && file.type.toLowerCase().includes('folder');
723
+
724
+ if (isFolder) {
725
+ // Add folder to results if requested
726
+ if (includeFolders) {
727
+ results.push({
728
+ id: file.id,
729
+ name: file.name,
730
+ relativePath,
731
+ isFolder: true,
732
+ });
733
+ }
734
+ // Recursively list folder contents
735
+ const subFiles = listDriveFilesRecursive(file.id, pattern, ignore, relativePath, includeFolders);
736
+ results.push(...subFiles);
737
+ } else {
738
+ // Only add files that match the pattern
739
+ if (matchPattern(file.name, pattern, ignore)) {
740
+ results.push({
741
+ id: file.id,
742
+ name: file.name,
743
+ relativePath,
744
+ isFolder: false,
745
+ });
746
+ }
747
+ }
748
+ }
749
+
750
+ return results;
751
+ };
752
+
753
+ /**
754
+ * Recursively list local files and folders
755
+ * @param {string} dir - Directory to list
756
+ * @param {string} pattern - File pattern to match
757
+ * @param {string} [ignore] - Ignore pattern
758
+ * @param {string} [prefix=''] - Path prefix for nested files
759
+ * @param {boolean} [includeFolders=false] - Whether to include folders in results
760
+ * @returns {Array<{name: string, relativePath: string, isFolder: boolean, sizeBytes: number, modifiedTime: Date|null}>}
761
+ */
762
+ const listLocalFilesRecursive = (dir, pattern, ignore, prefix = '', includeFolders = false) => {
763
+ const results = [];
764
+
765
+ if (!fs.existsSync(dir)) {
766
+ return results;
767
+ }
768
+
769
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
770
+
771
+ for (const entry of entries) {
772
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
773
+ const fullPath = path.join(dir, entry.name);
774
+
775
+ if (entry.isDirectory()) {
776
+ // Add folder to results if requested
777
+ if (includeFolders) {
778
+ results.push({
779
+ name: entry.name,
780
+ relativePath,
781
+ isFolder: true,
782
+ sizeBytes: 0,
783
+ modifiedTime: null,
784
+ });
785
+ }
786
+ // Recursively list subdirectory
787
+ const subFiles = listLocalFilesRecursive(fullPath, pattern, ignore, relativePath, includeFolders);
788
+ results.push(...subFiles);
789
+ } else if (entry.isFile()) {
790
+ // Only add files that match the pattern
791
+ if (matchPattern(entry.name, pattern, ignore)) {
792
+ // Get file stats for delta sync comparison
793
+ let sizeBytes = 0;
794
+ let modifiedTime = null;
795
+ try {
796
+ const stats = fs.statSync(fullPath);
797
+ sizeBytes = stats.size;
798
+ modifiedTime = stats.mtime;
799
+ } catch (e) {
800
+ // Ignore stat errors, use defaults
801
+ }
802
+
803
+ results.push({
804
+ name: entry.name,
805
+ relativePath,
806
+ isFolder: false,
807
+ sizeBytes,
808
+ modifiedTime,
809
+ });
810
+ }
811
+ }
812
+ }
813
+
814
+ return results;
815
+ };
816
+
817
+ /**
818
+ * Find or create a folder in Google Drive by path
819
+ * Uses session-scoped cache to avoid repeated API calls for same folder paths
820
+ * @param {string} parentId - Parent folder ID
821
+ * @param {string} folderPath - Folder path like "a/b/c"
822
+ * @returns {string} - The folder ID of the deepest folder
823
+ */
824
+ const findOrCreateDriveFolder = (parentId, folderPath) => {
825
+ const parts = folderPath.split('/').filter(Boolean);
826
+ let currentParentId = parentId;
827
+
828
+ for (const folderName of parts) {
829
+ // Check cache first to avoid redundant API calls
830
+ let folderId = getCachedFolderId(currentParentId, folderName);
831
+
832
+ if (!folderId) {
833
+ // Not in cache, query the API
834
+ const listResult = gdrive.list({
835
+ query: `'${currentParentId}' in parents and name = '${folderName}' and mimeType = 'application/vnd.google-apps.folder' and trashed = false`,
836
+ noHeader: true,
837
+ max: 1,
838
+ });
839
+
840
+ if (listResult.code === 0 && listResult.stdout.trim()) {
841
+ const parsed = gdrive.parseListOutput(listResult.stdout.trim());
842
+ if (parsed.length > 0 && parsed[0].id) {
843
+ folderId = parsed[0].id;
844
+ }
845
+ }
846
+
847
+ if (!folderId) {
848
+ // Create the folder
849
+ const mkdirResult = gdrive.mkdir(folderName, { parent: currentParentId });
850
+ if (mkdirResult.code === 0) {
851
+ // Parse the created folder ID from output
852
+ // gdrive@3 outputs: "Directory created: ID"
853
+ // gdrive@2 outputs: "Directory ID created"
854
+ const match = mkdirResult.stdout.match(/([a-zA-Z0-9_-]{20,})/);
855
+ if (match) {
856
+ folderId = match[1];
857
+ }
858
+ }
859
+ }
860
+
861
+ if (!folderId) {
862
+ throw new Error(`Failed to find or create folder: ${folderName}`);
863
+ }
864
+
865
+ // Cache the result for subsequent lookups within this operation
866
+ cacheFolderId(currentParentId, folderName, folderId);
867
+ }
868
+
869
+ currentParentId = folderId;
870
+ }
871
+
872
+ return currentParentId;
873
+ };
874
+
568
875
  /**
569
876
  * Convert a glob pattern to a regex pattern
570
877
  * Supports:
@@ -887,6 +1194,9 @@ const envRun = async (presetAction, presetConfigType) => {
887
1194
  * Run a single sync operation
888
1195
  */
889
1196
  const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, configType) => {
1197
+ // Clear folder cache at start of each operation to ensure fresh data
1198
+ clearFolderCache();
1199
+
890
1200
  const { folderId, pattern, name, ignore } = syncConfig;
891
1201
  // Strip quotes from paths (in case manually added)
892
1202
  const localDir = syncConfig.localDir.replace(/^['"]|['"]$/g, '');
@@ -908,76 +1218,164 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
908
1218
  fs.ensureDirSync(tempDir);
909
1219
 
910
1220
  try {
911
- // Fetch file list from Drive
1221
+ // Fetch file list from Drive (recursive, including folders for cleanup)
1222
+ // Uses optimized breadth-first parallel listing for better performance
912
1223
  const s = spinner();
913
- s.start('Fetching files from Google Drive...');
1224
+ s.start('Fetching files from Google Drive (including nested folders)...');
1225
+
1226
+ const driveItems = await listDriveFilesRecursiveOptimized(folderId, pattern, ignore, true);
1227
+ const driveFiles = driveItems.filter((f) => !f.isFolder);
1228
+ const driveFolders = driveItems.filter((f) => f.isFolder);
1229
+
1230
+ if (driveFiles.length === 0) {
1231
+ // Check if the folder exists at all
1232
+ const testResult = gdrive.list({
1233
+ query: `'${folderId}' in parents and trashed = false`,
1234
+ noHeader: true,
1235
+ max: 1,
1236
+ });
1237
+ if (testResult.code !== 0) {
1238
+ s.stop(color.red('Failed to fetch from Drive'));
1239
+ log.error('Could not list files from Google Drive. Check folder ID and permissions.');
1240
+ if (testResult.stderr) log.error(testResult.stderr);
1241
+ return;
1242
+ }
1243
+ }
914
1244
 
915
- const listResult = gdrive.list({
916
- query: `'${folderId}' in parents`,
917
- noHeader: true,
918
- });
1245
+ // Get local files first for delta sync comparison
1246
+ fs.ensureDirSync(envDir);
1247
+ const localItems = listLocalFilesRecursive(envDir, pattern, ignore, '', true);
1248
+ const localFiles = localItems.filter((f) => !f.isFolder);
1249
+ const localFolders = localItems.filter((f) => f.isFolder);
919
1250
 
920
- if (listResult.code !== 0) {
921
- s.stop(color.red('Failed to fetch from Drive'));
922
- log.error('Could not list files from Google Drive. Check folder ID and permissions.');
923
- if (listResult.stderr) log.error(listResult.stderr);
924
- return;
1251
+ // Build local file lookup map for delta sync
1252
+ const localFileMap = new Map();
1253
+ for (const file of localFiles) {
1254
+ localFileMap.set(file.relativePath, file);
925
1255
  }
926
1256
 
927
- const driveFiles = [];
928
- const stdout = listResult.stdout.trim();
929
- if (stdout) {
930
- const parsed = gdrive.parseListOutput(stdout);
931
- for (const file of parsed) {
932
- if (file.id && file.name && matchPattern(file.name, pattern, ignore)) {
933
- driveFiles.push({ id: file.id, name: file.name });
1257
+ // Delta sync: Determine which files need to be downloaded for comparison
1258
+ // Skip files where metadata indicates no change (same size AND local is not older)
1259
+ s.message('Analyzing file changes (delta sync)...');
1260
+ const filesToDownload = [];
1261
+ const unchangedFiles = [];
1262
+
1263
+ for (const driveFile of driveFiles) {
1264
+ const localFile = localFileMap.get(driveFile.relativePath);
1265
+
1266
+ if (!localFile) {
1267
+ // File only exists on Drive - need to download for diff
1268
+ filesToDownload.push(driveFile);
1269
+ } else {
1270
+ // File exists in both places - check if metadata indicates change
1271
+ const sizeMatch = driveFile.sizeBytes === localFile.sizeBytes;
1272
+ const driveTime = driveFile.modifiedTime ? driveFile.modifiedTime.getTime() : 0;
1273
+ const localTime = localFile.modifiedTime ? localFile.modifiedTime.getTime() : 0;
1274
+
1275
+ // Consider unchanged if: same size AND Drive is not newer
1276
+ // (allow 1 second tolerance for time comparison)
1277
+ const timeMatch = driveTime <= localTime + 1000;
1278
+
1279
+ if (sizeMatch && timeMatch) {
1280
+ // Metadata suggests unchanged - skip download
1281
+ unchangedFiles.push(driveFile.relativePath);
1282
+ } else {
1283
+ // Metadata differs - need to download for accurate comparison
1284
+ filesToDownload.push(driveFile);
934
1285
  }
935
1286
  }
936
1287
  }
937
1288
 
938
- // Download Drive files to temp for comparison
939
- s.message('Downloading Drive files for comparison...');
940
- for (const file of driveFiles) {
941
- gdrive.download(file.id, { destination: tempDir, overwrite: true });
1289
+ // Download only changed files to temp for comparison
1290
+ s.message(
1291
+ `Downloading ${filesToDownload.length} file(s) for comparison (${unchangedFiles.length} unchanged)...`
1292
+ );
1293
+
1294
+ // Prepare destination directories first
1295
+ for (const file of filesToDownload) {
1296
+ const destPath = path.join(tempDir, path.dirname(file.relativePath));
1297
+ fs.ensureDirSync(destPath);
942
1298
  }
943
- s.stop(color.green(`Found ${driveFiles.length} matching file(s) on Drive`));
944
1299
 
945
- // Get local files
946
- fs.ensureDirSync(envDir);
947
- const localFiles = fs.readdirSync(envDir).filter((f) => matchPattern(f, pattern, ignore));
1300
+ // Download files in parallel batches (5 concurrent downloads) with progress
1301
+ const downloads = filesToDownload.map((file) => ({
1302
+ fileId: file.id,
1303
+ options: {
1304
+ destination: path.join(tempDir, path.dirname(file.relativePath)),
1305
+ overwrite: true,
1306
+ },
1307
+ }));
1308
+ if (downloads.length > 0) {
1309
+ await gdrive.downloadParallel(downloads, 5, (completed, total) => {
1310
+ s.message(`Downloading files for comparison... (${completed}/${total})`);
1311
+ });
1312
+ }
1313
+
1314
+ s.stop(color.green(`Found ${driveFiles.length} file(s) on Drive (${unchangedFiles.length} skipped via delta sync)`));
948
1315
 
949
- // Compare files
1316
+ // Compare files using relativePath
950
1317
  const changes = {
951
1318
  modified: [],
952
1319
  localOnly: [],
953
1320
  driveOnly: [],
1321
+ orphanDriveFolders: [], // Folders on Drive with no local equivalent
954
1322
  };
955
1323
 
1324
+ // Build sets for quick lookup
1325
+ const driveFilePaths = new Set(driveFiles.map((f) => f.relativePath));
1326
+ const localFilePaths = new Set(localFiles.map((f) => f.relativePath));
1327
+ const localFolderPaths = new Set(localFolders.map((f) => f.relativePath));
1328
+ const unchangedSet = new Set(unchangedFiles);
1329
+
956
1330
  // Check local files
957
- for (const filename of localFiles) {
958
- const localFile = path.join(envDir, filename);
959
- const driveFile = path.join(tempDir, filename);
960
-
961
- if (fs.existsSync(driveFile)) {
962
- const localContent = fs.readFileSync(localFile, 'utf-8');
963
- const driveContent = fs.readFileSync(driveFile, 'utf-8');
964
- if (localContent !== driveContent) {
965
- changes.modified.push(filename);
1331
+ for (const localFile of localFiles) {
1332
+ const localFilePath = path.join(envDir, localFile.relativePath);
1333
+ const driveFilePath = path.join(tempDir, localFile.relativePath);
1334
+
1335
+ if (driveFilePaths.has(localFile.relativePath)) {
1336
+ // File exists on both
1337
+ if (unchangedSet.has(localFile.relativePath)) {
1338
+ // Delta sync determined this file is unchanged - skip comparison
1339
+ continue;
1340
+ }
1341
+ // Check for modifications (only for files we downloaded)
1342
+ if (fs.existsSync(driveFilePath)) {
1343
+ const localContent = fs.readFileSync(localFilePath, 'utf-8');
1344
+ const driveContent = fs.readFileSync(driveFilePath, 'utf-8');
1345
+ if (localContent !== driveContent) {
1346
+ changes.modified.push(localFile.relativePath);
1347
+ }
966
1348
  }
967
1349
  } else {
968
- changes.localOnly.push(filename);
1350
+ changes.localOnly.push(localFile.relativePath);
969
1351
  }
970
1352
  }
971
1353
 
972
1354
  // Check Drive-only files
973
- for (const file of driveFiles) {
974
- if (!localFiles.includes(file.name)) {
975
- changes.driveOnly.push(file.name);
1355
+ for (const driveFile of driveFiles) {
1356
+ if (!localFilePaths.has(driveFile.relativePath)) {
1357
+ changes.driveOnly.push(driveFile.relativePath);
1358
+ }
1359
+ }
1360
+
1361
+ // Check for orphan Drive folders (folders on Drive with no local equivalent)
1362
+ for (const driveFolder of driveFolders) {
1363
+ if (!localFolderPaths.has(driveFolder.relativePath)) {
1364
+ // Only mark as orphan if no local files exist under this folder path
1365
+ const hasLocalFilesUnder = localFiles.some((f) =>
1366
+ f.relativePath.startsWith(driveFolder.relativePath + '/')
1367
+ );
1368
+ if (!hasLocalFilesUnder) {
1369
+ changes.orphanDriveFolders.push(driveFolder);
1370
+ }
976
1371
  }
977
1372
  }
978
1373
 
979
1374
  const hasChanges =
980
- changes.modified.length > 0 || changes.localOnly.length > 0 || changes.driveOnly.length > 0;
1375
+ changes.modified.length > 0 ||
1376
+ changes.localOnly.length > 0 ||
1377
+ changes.driveOnly.length > 0 ||
1378
+ changes.orphanDriveFolders.length > 0;
981
1379
 
982
1380
  if (!hasChanges) {
983
1381
  log.success('No changes detected. Local and Drive are in sync.');
@@ -991,21 +1389,21 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
991
1389
  log.info(color.bold('Changes Preview:'));
992
1390
  console.log('');
993
1391
 
994
- for (const filename of changes.modified) {
995
- const localFile = path.join(envDir, filename);
996
- const driveFile = path.join(tempDir, filename);
1392
+ for (const relativePath of changes.modified) {
1393
+ const localFilePath = path.join(envDir, relativePath);
1394
+ const driveFilePath = path.join(tempDir, relativePath);
997
1395
  // Swap order based on action: upload shows drive->local, download shows local->drive
998
1396
  const diffResult = isUpload
999
- ? shell.exec(`diff -u "${driveFile}" "${localFile}"`, { silent: true })
1000
- : shell.exec(`diff -u "${localFile}" "${driveFile}"`, { silent: true });
1397
+ ? shell.exec(`diff -u "${driveFilePath}" "${localFilePath}"`, { silent: true })
1398
+ : shell.exec(`diff -u "${localFilePath}" "${driveFilePath}"`, { silent: true });
1001
1399
 
1002
1400
  if (diffResult.stdout) {
1003
1401
  const lines = diffResult.stdout.split('\n');
1004
1402
  lines.forEach((line) => {
1005
1403
  if (line.startsWith('---')) {
1006
- console.log(color.cyan(`--- ${filename} (${isUpload ? 'Drive' : 'Local'})`));
1404
+ console.log(color.cyan(`--- ${relativePath} (${isUpload ? 'Drive' : 'Local'})`));
1007
1405
  } else if (line.startsWith('+++')) {
1008
- console.log(color.cyan(`+++ ${filename} (${isUpload ? 'Local' : 'Drive'})`));
1406
+ console.log(color.cyan(`+++ ${relativePath} (${isUpload ? 'Local' : 'Drive'})`));
1009
1407
  } else if (line.startsWith('@@')) {
1010
1408
  console.log(color.cyan(line));
1011
1409
  } else if (line.startsWith('-')) {
@@ -1020,30 +1418,71 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
1020
1418
  }
1021
1419
  }
1022
1420
 
1023
- for (const filename of changes.localOnly) {
1024
- console.log(color.cyan(`--- /dev/null`));
1025
- console.log(color.cyan(`+++ ${filename} (Local only)`));
1026
- console.log(color.green(` [New local file - will be uploaded]`));
1421
+ for (const relativePath of changes.localOnly) {
1422
+ if (action === 'diff') {
1423
+ console.log(color.green(`+ ${relativePath}`));
1424
+ console.log(color.dim(` Local only`));
1425
+ } else if (isUpload) {
1426
+ console.log(color.green(`+ ${relativePath}`));
1427
+ console.log(color.dim(` Local only → will be uploaded to Drive`));
1428
+ } else {
1429
+ console.log(color.red(`- ${relativePath}`));
1430
+ console.log(color.dim(` Local only → can be deleted (not on Drive)`));
1431
+ }
1027
1432
  console.log('');
1028
1433
  }
1029
1434
 
1030
- for (const filename of changes.driveOnly) {
1031
- console.log(color.cyan(`--- /dev/null`));
1032
- console.log(color.cyan(`+++ ${filename} (Drive only)`));
1033
- console.log(color.green(` [New Drive file - will be downloaded]`));
1435
+ for (const relativePath of changes.driveOnly) {
1436
+ if (action === 'diff') {
1437
+ console.log(color.yellow(`+ ${relativePath}`));
1438
+ console.log(color.dim(` Drive only`));
1439
+ } else if (isUpload) {
1440
+ console.log(color.red(`- ${relativePath}`));
1441
+ console.log(color.dim(` Drive only → can be deleted from Drive (not local)`));
1442
+ } else {
1443
+ console.log(color.green(`+ ${relativePath}`));
1444
+ console.log(color.dim(` Drive only → will be downloaded to local`));
1445
+ }
1446
+ console.log('');
1447
+ }
1448
+
1449
+ // Show orphan Drive folders
1450
+ for (const folder of changes.orphanDriveFolders) {
1451
+ if (action === 'diff') {
1452
+ console.log(color.yellow(`📁 ${folder.relativePath}/`));
1453
+ console.log(color.dim(` Empty folder on Drive only`));
1454
+ } else if (isUpload) {
1455
+ console.log(color.red(`- 📁 ${folder.relativePath}/`));
1456
+ console.log(color.dim(` Empty folder on Drive → can be deleted`));
1457
+ }
1034
1458
  console.log('');
1035
1459
  }
1036
1460
 
1037
1461
  // Summary
1038
1462
  const summaryLines = [];
1039
1463
  if (changes.modified.length > 0) {
1040
- summaryLines.push(`${color.cyan('Modified:')} ${changes.modified.join(', ')}`);
1464
+ summaryLines.push(`${color.cyan('Modified:')} ${changes.modified.length} file(s)`);
1041
1465
  }
1042
1466
  if (changes.localOnly.length > 0) {
1043
- summaryLines.push(`${color.green('Local only:')} ${changes.localOnly.join(', ')}`);
1467
+ if (action === 'diff') {
1468
+ summaryLines.push(`${color.green('Local only:')} ${changes.localOnly.length} file(s)`);
1469
+ } else if (isUpload) {
1470
+ summaryLines.push(`${color.green('To upload:')} ${changes.localOnly.length} file(s)`);
1471
+ } else {
1472
+ summaryLines.push(`${color.red('Local only (can delete):')} ${changes.localOnly.length} file(s)`);
1473
+ }
1044
1474
  }
1045
1475
  if (changes.driveOnly.length > 0) {
1046
- summaryLines.push(`${color.yellow('Drive only:')} ${changes.driveOnly.join(', ')}`);
1476
+ if (action === 'diff') {
1477
+ summaryLines.push(`${color.yellow('Drive only:')} ${changes.driveOnly.length} file(s)`);
1478
+ } else if (isUpload) {
1479
+ summaryLines.push(`${color.red('Drive only (can delete):')} ${changes.driveOnly.length} file(s)`);
1480
+ } else {
1481
+ summaryLines.push(`${color.green('To download:')} ${changes.driveOnly.length} file(s)`);
1482
+ }
1483
+ }
1484
+ if (changes.orphanDriveFolders.length > 0 && (action === 'diff' || isUpload)) {
1485
+ summaryLines.push(`${color.red('Empty folders on Drive:')} ${changes.orphanDriveFolders.length} folder(s)`);
1047
1486
  }
1048
1487
  note(summaryLines.join('\n'), 'Summary');
1049
1488
 
@@ -1063,15 +1502,18 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
1063
1502
  }
1064
1503
 
1065
1504
  if (action === 'download') {
1066
- // Create backup first
1067
- const existingFiles = fs.readdirSync(envDir).filter((f) => matchPattern(f, pattern, ignore));
1068
- if (existingFiles.length > 0) {
1505
+ // Create backup first (recursive)
1506
+ const existingLocalFiles = listLocalFilesRecursive(envDir, pattern, ignore);
1507
+ if (existingLocalFiles.length > 0) {
1069
1508
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
1070
1509
  const backupSubdir = path.join(backupPath, `${name}_${timestamp}`);
1071
1510
  fs.ensureDirSync(backupSubdir);
1072
1511
 
1073
- for (const file of existingFiles) {
1074
- fs.copyFileSync(path.join(envDir, file), path.join(backupSubdir, file));
1512
+ for (const file of existingLocalFiles) {
1513
+ const srcPath = path.join(envDir, file.relativePath);
1514
+ const destPath = path.join(backupSubdir, file.relativePath);
1515
+ fs.ensureDirSync(path.dirname(destPath));
1516
+ fs.copyFileSync(srcPath, destPath);
1075
1517
  }
1076
1518
  log.info(`Backup created: ${backupSubdir}`);
1077
1519
  }
@@ -1081,13 +1523,19 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
1081
1523
 
1082
1524
  let downloaded = 0;
1083
1525
 
1084
- for (const filename of changes.modified) {
1085
- fs.copyFileSync(path.join(tempDir, filename), path.join(envDir, filename));
1526
+ for (const relativePath of changes.modified) {
1527
+ const srcPath = path.join(tempDir, relativePath);
1528
+ const destPath = path.join(envDir, relativePath);
1529
+ fs.ensureDirSync(path.dirname(destPath));
1530
+ fs.copyFileSync(srcPath, destPath);
1086
1531
  downloaded++;
1087
1532
  }
1088
1533
 
1089
- for (const filename of changes.driveOnly) {
1090
- fs.copyFileSync(path.join(tempDir, filename), path.join(envDir, filename));
1534
+ for (const relativePath of changes.driveOnly) {
1535
+ const srcPath = path.join(tempDir, relativePath);
1536
+ const destPath = path.join(envDir, relativePath);
1537
+ fs.ensureDirSync(path.dirname(destPath));
1538
+ fs.copyFileSync(srcPath, destPath);
1091
1539
  downloaded++;
1092
1540
  }
1093
1541
 
@@ -1099,20 +1547,134 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
1099
1547
  let uploaded = 0;
1100
1548
  let replaced = 0;
1101
1549
 
1102
- for (const filename of changes.modified) {
1103
- const driveFile = driveFiles.find((f) => f.name === filename);
1550
+ // Build a map of relativePath -> driveFile for quick lookup
1551
+ const driveFileMap = new Map();
1552
+ for (const df of driveFiles) {
1553
+ driveFileMap.set(df.relativePath, df);
1554
+ }
1555
+
1556
+ for (const relativePath of changes.modified) {
1557
+ const driveFile = driveFileMap.get(relativePath);
1104
1558
  if (driveFile) {
1105
- gdrive.update(driveFile.id, path.join(envDir, filename));
1559
+ gdrive.update(driveFile.id, path.join(envDir, relativePath));
1106
1560
  replaced++;
1107
1561
  }
1108
1562
  }
1109
1563
 
1110
- for (const filename of changes.localOnly) {
1111
- gdrive.upload(path.join(envDir, filename), { parent: folderId });
1564
+ for (const relativePath of changes.localOnly) {
1565
+ const localFilePath = path.join(envDir, relativePath);
1566
+ const folderPath = path.dirname(relativePath);
1567
+
1568
+ // Find or create the parent folder on Drive
1569
+ let parentId = folderId;
1570
+ if (folderPath && folderPath !== '.') {
1571
+ parentId = findOrCreateDriveFolder(folderId, folderPath);
1572
+ }
1573
+
1574
+ gdrive.upload(localFilePath, { parent: parentId });
1112
1575
  uploaded++;
1113
1576
  }
1114
1577
 
1115
1578
  uploadSpinner.stop(color.green(`Replaced: ${replaced}, New: ${uploaded}`));
1579
+
1580
+ // Ask about deleting Drive-only files and orphan folders
1581
+ const hasOrphanItems = changes.driveOnly.length > 0 || changes.orphanDriveFolders.length > 0;
1582
+ if (hasOrphanItems) {
1583
+ console.log('');
1584
+ const totalItems = changes.driveOnly.length + changes.orphanDriveFolders.length;
1585
+ log.warn(`${totalItems} item(s) exist on Drive but not locally:`);
1586
+ for (const relativePath of changes.driveOnly) {
1587
+ console.log(color.red(` - ${relativePath}`));
1588
+ }
1589
+ for (const folder of changes.orphanDriveFolders) {
1590
+ console.log(color.red(` - 📁 ${folder.relativePath}/`));
1591
+ }
1592
+ console.log('');
1593
+
1594
+ const deleteFromDrive = await confirm({
1595
+ message: `Delete these ${totalItems} item(s) from Drive?`,
1596
+ initialValue: false,
1597
+ });
1598
+
1599
+ if (!isCancel(deleteFromDrive) && deleteFromDrive) {
1600
+ const deleteSpinner = spinner();
1601
+ deleteSpinner.start('Deleting items from Drive...');
1602
+
1603
+ let deletedFiles = 0;
1604
+ let deletedFolders = 0;
1605
+
1606
+ // Delete orphan files first
1607
+ for (const relativePath of changes.driveOnly) {
1608
+ const driveFile = driveFileMap.get(relativePath);
1609
+ if (driveFile) {
1610
+ gdrive.remove(driveFile.id);
1611
+ deletedFiles++;
1612
+ }
1613
+ }
1614
+
1615
+ // Delete orphan folders (sort by depth, deepest first)
1616
+ const sortedFolders = [...changes.orphanDriveFolders].sort((a, b) =>
1617
+ b.relativePath.split('/').length - a.relativePath.split('/').length
1618
+ );
1619
+
1620
+ for (const folder of sortedFolders) {
1621
+ gdrive.remove(folder.id, { recursive: true });
1622
+ deletedFolders++;
1623
+ }
1624
+
1625
+ const resultParts = [];
1626
+ if (deletedFiles > 0) resultParts.push(`${deletedFiles} file(s)`);
1627
+ if (deletedFolders > 0) resultParts.push(`${deletedFolders} folder(s)`);
1628
+ deleteSpinner.stop(color.green(`Deleted ${resultParts.join(' and ')} from Drive`));
1629
+ }
1630
+ }
1631
+ }
1632
+
1633
+ // Ask about deleting local-only files during download
1634
+ if (action === 'download' && changes.localOnly.length > 0) {
1635
+ console.log('');
1636
+ log.warn(`${changes.localOnly.length} file(s) exist locally but not on Drive:`);
1637
+ for (const relativePath of changes.localOnly) {
1638
+ console.log(color.red(` - ${relativePath}`));
1639
+ }
1640
+ console.log('');
1641
+ log.info(color.dim('(These files are included in the backup)'));
1642
+
1643
+ const deleteLocal = await confirm({
1644
+ message: `Delete these ${changes.localOnly.length} local file(s)?`,
1645
+ initialValue: false,
1646
+ });
1647
+
1648
+ if (!isCancel(deleteLocal) && deleteLocal) {
1649
+ const deleteSpinner = spinner();
1650
+ deleteSpinner.start('Deleting local files...');
1651
+
1652
+ let deleted = 0;
1653
+ for (const relativePath of changes.localOnly) {
1654
+ const filePath = path.join(envDir, relativePath);
1655
+ if (fs.existsSync(filePath)) {
1656
+ fs.removeSync(filePath);
1657
+ deleted++;
1658
+ }
1659
+ }
1660
+
1661
+ // Clean up empty directories
1662
+ const cleanEmptyDirs = (dir) => {
1663
+ if (!fs.existsSync(dir)) return;
1664
+ const entries = fs.readdirSync(dir);
1665
+ if (entries.length === 0 && dir !== envDir) {
1666
+ fs.rmdirSync(dir);
1667
+ cleanEmptyDirs(path.dirname(dir));
1668
+ }
1669
+ };
1670
+
1671
+ for (const relativePath of changes.localOnly) {
1672
+ const dirPath = path.dirname(path.join(envDir, relativePath));
1673
+ cleanEmptyDirs(dirPath);
1674
+ }
1675
+
1676
+ deleteSpinner.stop(color.green(`Deleted ${deleted} local file(s)`));
1677
+ }
1116
1678
  }
1117
1679
  } catch (e) {
1118
1680
  log.error(e.message);
@@ -1347,4 +1909,7 @@ module.exports = {
1347
1909
  // Exported for testing
1348
1910
  globToRegex,
1349
1911
  matchPattern,
1912
+ listDriveFilesRecursive,
1913
+ listLocalFilesRecursive,
1914
+ findOrCreateDriveFolder,
1350
1915
  };