gdrive-syncer 3.0.0 → 3.1.0

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.
Files changed (3) hide show
  1. package/Readme.md +23 -3
  2. package/package.json +1 -1
  3. package/src/envSync.js +428 -74
package/Readme.md CHANGED
@@ -216,15 +216,35 @@ Patterns support glob-style matching with the following features:
216
216
 
217
217
  ### Features
218
218
 
219
+ - **Nested folder support** - Recursively syncs files in subdirectories
219
220
  - **Multiple sync folders** - Configure multiple sync pairs in one config
220
221
  - **Local & Global configs** - Per-project or machine-wide configurations
221
- - **File patterns** - Filter files by pattern
222
+ - **File patterns** - Filter files by pattern (applied to filenames, not paths)
222
223
  - **Auto-discovery** - Finds local config in current or parent directories
223
- - **Git-style diffs** - Colored diff output showing changes
224
- - **Automatic backups** - Creates timestamped backups before download
224
+ - **Git-style diffs** - Colored diff output showing changes with full paths
225
+ - **Automatic backups** - Creates timestamped backups before download (preserves folder structure)
225
226
  - **Two-way sync** - Upload local changes or download from Drive
227
+ - **Optional deletion** - Prompts to delete orphan files/folders after sync
226
228
  - **Sync All** - Run operations on all syncs within selected config
227
229
  - **Registered Local Configs** - Run sync on multiple projects from anywhere
230
+ - **Auto-create folders** - Creates missing folders on Drive during upload
231
+
232
+ ### Sync Behavior
233
+
234
+ The sync compares files between local and Drive, showing:
235
+
236
+ | Status | Diff | Upload | Download |
237
+ |--------|------|--------|----------|
238
+ | **Modified** | Shows diff | Updates on Drive | Updates locally |
239
+ | **Local only** | Listed | Uploads to Drive | Prompts to delete |
240
+ | **Drive only** | Listed | Prompts to delete | Downloads locally |
241
+ | **Empty folders** | Listed | Prompts to delete | — |
242
+
243
+ **Delete prompts:**
244
+ - **Upload**: After uploading, prompts to delete files/folders on Drive that don't exist locally
245
+ - **Download**: After downloading, prompts to delete local files that don't exist on Drive (backup is created first)
246
+
247
+ This ensures you can keep Drive in sync with local (or vice versa) without accumulating orphan files.
228
248
 
229
249
  ### Registered Local Configs
230
250
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdrive-syncer",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Google Drive Syncer",
5
5
  "main": "./index.js",
6
6
  "bin": "./run.js",
package/src/envSync.js CHANGED
@@ -565,6 +565,168 @@ const envShow = async () => {
565
565
  }
566
566
  };
567
567
 
568
+ /**
569
+ * Recursively list files and folders from Google Drive
570
+ * @param {string} folderId - Root folder ID
571
+ * @param {string} pattern - File pattern to match
572
+ * @param {string} [ignore] - Ignore pattern
573
+ * @param {string} [prefix=''] - Path prefix for nested files
574
+ * @param {boolean} [includeFolders=false] - Whether to include folders in results
575
+ * @returns {Array<{id: string, name: string, relativePath: string, isFolder: boolean}>}
576
+ */
577
+ const listDriveFilesRecursive = (folderId, pattern, ignore, prefix = '', includeFolders = false) => {
578
+ const results = [];
579
+
580
+ const listResult = gdrive.list({
581
+ query: `'${folderId}' in parents and trashed = false`,
582
+ noHeader: true,
583
+ max: 1000,
584
+ });
585
+
586
+ if (listResult.code !== 0) {
587
+ return results;
588
+ }
589
+
590
+ const stdout = listResult.stdout.trim();
591
+ if (!stdout) return results;
592
+
593
+ const parsed = gdrive.parseListOutput(stdout);
594
+
595
+ for (const file of parsed) {
596
+ if (!file.id || !file.name) continue;
597
+
598
+ const relativePath = prefix ? `${prefix}/${file.name}` : file.name;
599
+ const isFolder = file.type && file.type.toLowerCase().includes('folder');
600
+
601
+ if (isFolder) {
602
+ // Add folder to results if requested
603
+ if (includeFolders) {
604
+ results.push({
605
+ id: file.id,
606
+ name: file.name,
607
+ relativePath,
608
+ isFolder: true,
609
+ });
610
+ }
611
+ // Recursively list folder contents
612
+ const subFiles = listDriveFilesRecursive(file.id, pattern, ignore, relativePath, includeFolders);
613
+ results.push(...subFiles);
614
+ } else {
615
+ // Only add files that match the pattern
616
+ if (matchPattern(file.name, pattern, ignore)) {
617
+ results.push({
618
+ id: file.id,
619
+ name: file.name,
620
+ relativePath,
621
+ isFolder: false,
622
+ });
623
+ }
624
+ }
625
+ }
626
+
627
+ return results;
628
+ };
629
+
630
+ /**
631
+ * Recursively list local files and folders
632
+ * @param {string} dir - Directory to list
633
+ * @param {string} pattern - File pattern to match
634
+ * @param {string} [ignore] - Ignore pattern
635
+ * @param {string} [prefix=''] - Path prefix for nested files
636
+ * @param {boolean} [includeFolders=false] - Whether to include folders in results
637
+ * @returns {Array<{name: string, relativePath: string, isFolder: boolean}>}
638
+ */
639
+ const listLocalFilesRecursive = (dir, pattern, ignore, prefix = '', includeFolders = false) => {
640
+ const results = [];
641
+
642
+ if (!fs.existsSync(dir)) {
643
+ return results;
644
+ }
645
+
646
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
647
+
648
+ for (const entry of entries) {
649
+ const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
650
+
651
+ if (entry.isDirectory()) {
652
+ // Add folder to results if requested
653
+ if (includeFolders) {
654
+ results.push({
655
+ name: entry.name,
656
+ relativePath,
657
+ isFolder: true,
658
+ });
659
+ }
660
+ // Recursively list subdirectory
661
+ const subDir = path.join(dir, entry.name);
662
+ const subFiles = listLocalFilesRecursive(subDir, pattern, ignore, relativePath, includeFolders);
663
+ results.push(...subFiles);
664
+ } else if (entry.isFile()) {
665
+ // Only add files that match the pattern
666
+ if (matchPattern(entry.name, pattern, ignore)) {
667
+ results.push({
668
+ name: entry.name,
669
+ relativePath,
670
+ isFolder: false,
671
+ });
672
+ }
673
+ }
674
+ }
675
+
676
+ return results;
677
+ };
678
+
679
+ /**
680
+ * Find or create a folder in Google Drive by path
681
+ * @param {string} parentId - Parent folder ID
682
+ * @param {string} folderPath - Folder path like "a/b/c"
683
+ * @returns {string} - The folder ID of the deepest folder
684
+ */
685
+ const findOrCreateDriveFolder = (parentId, folderPath) => {
686
+ const parts = folderPath.split('/').filter(Boolean);
687
+ let currentParentId = parentId;
688
+
689
+ for (const folderName of parts) {
690
+ // Check if folder exists
691
+ const listResult = gdrive.list({
692
+ query: `'${currentParentId}' in parents and name = '${folderName}' and mimeType = 'application/vnd.google-apps.folder' and trashed = false`,
693
+ noHeader: true,
694
+ max: 1,
695
+ });
696
+
697
+ let folderId = null;
698
+
699
+ if (listResult.code === 0 && listResult.stdout.trim()) {
700
+ const parsed = gdrive.parseListOutput(listResult.stdout.trim());
701
+ if (parsed.length > 0 && parsed[0].id) {
702
+ folderId = parsed[0].id;
703
+ }
704
+ }
705
+
706
+ if (!folderId) {
707
+ // Create the folder
708
+ const mkdirResult = gdrive.mkdir(folderName, { parent: currentParentId });
709
+ if (mkdirResult.code === 0) {
710
+ // Parse the created folder ID from output
711
+ // gdrive@3 outputs: "Directory created: ID"
712
+ // gdrive@2 outputs: "Directory ID created"
713
+ const match = mkdirResult.stdout.match(/([a-zA-Z0-9_-]{20,})/);
714
+ if (match) {
715
+ folderId = match[1];
716
+ }
717
+ }
718
+ }
719
+
720
+ if (!folderId) {
721
+ throw new Error(`Failed to find or create folder: ${folderName}`);
722
+ }
723
+
724
+ currentParentId = folderId;
725
+ }
726
+
727
+ return currentParentId;
728
+ };
729
+
568
730
  /**
569
731
  * Convert a glob pattern to a regex pattern
570
732
  * Supports:
@@ -908,76 +1070,101 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
908
1070
  fs.ensureDirSync(tempDir);
909
1071
 
910
1072
  try {
911
- // Fetch file list from Drive
1073
+ // Fetch file list from Drive (recursive, including folders for cleanup)
912
1074
  const s = spinner();
913
- s.start('Fetching files from Google Drive...');
914
-
915
- const listResult = gdrive.list({
916
- query: `'${folderId}' in parents`,
917
- noHeader: true,
918
- });
919
-
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;
925
- }
926
-
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 });
934
- }
1075
+ s.start('Fetching files from Google Drive (including nested folders)...');
1076
+
1077
+ const driveItems = listDriveFilesRecursive(folderId, pattern, ignore, '', true);
1078
+ const driveFiles = driveItems.filter((f) => !f.isFolder);
1079
+ const driveFolders = driveItems.filter((f) => f.isFolder);
1080
+
1081
+ if (driveFiles.length === 0) {
1082
+ // Check if the folder exists at all
1083
+ const testResult = gdrive.list({
1084
+ query: `'${folderId}' in parents and trashed = false`,
1085
+ noHeader: true,
1086
+ max: 1,
1087
+ });
1088
+ if (testResult.code !== 0) {
1089
+ s.stop(color.red('Failed to fetch from Drive'));
1090
+ log.error('Could not list files from Google Drive. Check folder ID and permissions.');
1091
+ if (testResult.stderr) log.error(testResult.stderr);
1092
+ return;
935
1093
  }
936
1094
  }
937
1095
 
938
- // Download Drive files to temp for comparison
1096
+ // Download Drive files to temp for comparison (preserving folder structure)
939
1097
  s.message('Downloading Drive files for comparison...');
940
1098
  for (const file of driveFiles) {
941
- gdrive.download(file.id, { destination: tempDir, overwrite: true });
1099
+ const destPath = path.join(tempDir, path.dirname(file.relativePath));
1100
+ fs.ensureDirSync(destPath);
1101
+ gdrive.download(file.id, { destination: destPath, overwrite: true });
942
1102
  }
943
1103
  s.stop(color.green(`Found ${driveFiles.length} matching file(s) on Drive`));
944
1104
 
945
- // Get local files
1105
+ // Get local files (recursive, including folders for cleanup)
946
1106
  fs.ensureDirSync(envDir);
947
- const localFiles = fs.readdirSync(envDir).filter((f) => matchPattern(f, pattern, ignore));
1107
+ const localItems = listLocalFilesRecursive(envDir, pattern, ignore, '', true);
1108
+ const localFiles = localItems.filter((f) => !f.isFolder);
1109
+ const localFolders = localItems.filter((f) => f.isFolder);
948
1110
 
949
- // Compare files
1111
+ // Compare files using relativePath
950
1112
  const changes = {
951
1113
  modified: [],
952
1114
  localOnly: [],
953
1115
  driveOnly: [],
1116
+ orphanDriveFolders: [], // Folders on Drive with no local equivalent
954
1117
  };
955
1118
 
1119
+ // Build a set of drive file paths for quick lookup
1120
+ const driveFilePaths = new Set(driveFiles.map((f) => f.relativePath));
1121
+ const localFilePaths = new Set(localFiles.map((f) => f.relativePath));
1122
+ const localFolderPaths = new Set(localFolders.map((f) => f.relativePath));
1123
+
956
1124
  // 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);
1125
+ for (const localFile of localFiles) {
1126
+ const localFilePath = path.join(envDir, localFile.relativePath);
1127
+ const driveFilePath = path.join(tempDir, localFile.relativePath);
1128
+
1129
+ if (driveFilePaths.has(localFile.relativePath)) {
1130
+ // File exists on both - check for modifications
1131
+ if (fs.existsSync(driveFilePath)) {
1132
+ const localContent = fs.readFileSync(localFilePath, 'utf-8');
1133
+ const driveContent = fs.readFileSync(driveFilePath, 'utf-8');
1134
+ if (localContent !== driveContent) {
1135
+ changes.modified.push(localFile.relativePath);
1136
+ }
966
1137
  }
967
1138
  } else {
968
- changes.localOnly.push(filename);
1139
+ changes.localOnly.push(localFile.relativePath);
969
1140
  }
970
1141
  }
971
1142
 
972
1143
  // Check Drive-only files
973
- for (const file of driveFiles) {
974
- if (!localFiles.includes(file.name)) {
975
- changes.driveOnly.push(file.name);
1144
+ for (const driveFile of driveFiles) {
1145
+ if (!localFilePaths.has(driveFile.relativePath)) {
1146
+ changes.driveOnly.push(driveFile.relativePath);
1147
+ }
1148
+ }
1149
+
1150
+ // Check for orphan Drive folders (folders on Drive with no local equivalent)
1151
+ for (const driveFolder of driveFolders) {
1152
+ if (!localFolderPaths.has(driveFolder.relativePath)) {
1153
+ // Only mark as orphan if no local files exist under this folder path
1154
+ const hasLocalFilesUnder = localFiles.some((f) =>
1155
+ f.relativePath.startsWith(driveFolder.relativePath + '/')
1156
+ );
1157
+ if (!hasLocalFilesUnder) {
1158
+ changes.orphanDriveFolders.push(driveFolder);
1159
+ }
976
1160
  }
977
1161
  }
978
1162
 
979
1163
  const hasChanges =
980
- changes.modified.length > 0 || changes.localOnly.length > 0 || changes.driveOnly.length > 0;
1164
+ changes.modified.length > 0 ||
1165
+ changes.localOnly.length > 0 ||
1166
+ changes.driveOnly.length > 0 ||
1167
+ changes.orphanDriveFolders.length > 0;
981
1168
 
982
1169
  if (!hasChanges) {
983
1170
  log.success('No changes detected. Local and Drive are in sync.');
@@ -991,21 +1178,21 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
991
1178
  log.info(color.bold('Changes Preview:'));
992
1179
  console.log('');
993
1180
 
994
- for (const filename of changes.modified) {
995
- const localFile = path.join(envDir, filename);
996
- const driveFile = path.join(tempDir, filename);
1181
+ for (const relativePath of changes.modified) {
1182
+ const localFilePath = path.join(envDir, relativePath);
1183
+ const driveFilePath = path.join(tempDir, relativePath);
997
1184
  // Swap order based on action: upload shows drive->local, download shows local->drive
998
1185
  const diffResult = isUpload
999
- ? shell.exec(`diff -u "${driveFile}" "${localFile}"`, { silent: true })
1000
- : shell.exec(`diff -u "${localFile}" "${driveFile}"`, { silent: true });
1186
+ ? shell.exec(`diff -u "${driveFilePath}" "${localFilePath}"`, { silent: true })
1187
+ : shell.exec(`diff -u "${localFilePath}" "${driveFilePath}"`, { silent: true });
1001
1188
 
1002
1189
  if (diffResult.stdout) {
1003
1190
  const lines = diffResult.stdout.split('\n');
1004
1191
  lines.forEach((line) => {
1005
1192
  if (line.startsWith('---')) {
1006
- console.log(color.cyan(`--- ${filename} (${isUpload ? 'Drive' : 'Local'})`));
1193
+ console.log(color.cyan(`--- ${relativePath} (${isUpload ? 'Drive' : 'Local'})`));
1007
1194
  } else if (line.startsWith('+++')) {
1008
- console.log(color.cyan(`+++ ${filename} (${isUpload ? 'Local' : 'Drive'})`));
1195
+ console.log(color.cyan(`+++ ${relativePath} (${isUpload ? 'Local' : 'Drive'})`));
1009
1196
  } else if (line.startsWith('@@')) {
1010
1197
  console.log(color.cyan(line));
1011
1198
  } else if (line.startsWith('-')) {
@@ -1020,30 +1207,71 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
1020
1207
  }
1021
1208
  }
1022
1209
 
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]`));
1210
+ for (const relativePath of changes.localOnly) {
1211
+ if (action === 'diff') {
1212
+ console.log(color.green(`+ ${relativePath}`));
1213
+ console.log(color.dim(` Local only`));
1214
+ } else if (isUpload) {
1215
+ console.log(color.green(`+ ${relativePath}`));
1216
+ console.log(color.dim(` Local only → will be uploaded to Drive`));
1217
+ } else {
1218
+ console.log(color.red(`- ${relativePath}`));
1219
+ console.log(color.dim(` Local only → can be deleted (not on Drive)`));
1220
+ }
1221
+ console.log('');
1222
+ }
1223
+
1224
+ for (const relativePath of changes.driveOnly) {
1225
+ if (action === 'diff') {
1226
+ console.log(color.yellow(`+ ${relativePath}`));
1227
+ console.log(color.dim(` Drive only`));
1228
+ } else if (isUpload) {
1229
+ console.log(color.red(`- ${relativePath}`));
1230
+ console.log(color.dim(` Drive only → can be deleted from Drive (not local)`));
1231
+ } else {
1232
+ console.log(color.green(`+ ${relativePath}`));
1233
+ console.log(color.dim(` Drive only → will be downloaded to local`));
1234
+ }
1027
1235
  console.log('');
1028
1236
  }
1029
1237
 
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]`));
1238
+ // Show orphan Drive folders
1239
+ for (const folder of changes.orphanDriveFolders) {
1240
+ if (action === 'diff') {
1241
+ console.log(color.yellow(`📁 ${folder.relativePath}/`));
1242
+ console.log(color.dim(` Empty folder on Drive only`));
1243
+ } else if (isUpload) {
1244
+ console.log(color.red(`- 📁 ${folder.relativePath}/`));
1245
+ console.log(color.dim(` Empty folder on Drive → can be deleted`));
1246
+ }
1034
1247
  console.log('');
1035
1248
  }
1036
1249
 
1037
1250
  // Summary
1038
1251
  const summaryLines = [];
1039
1252
  if (changes.modified.length > 0) {
1040
- summaryLines.push(`${color.cyan('Modified:')} ${changes.modified.join(', ')}`);
1253
+ summaryLines.push(`${color.cyan('Modified:')} ${changes.modified.length} file(s)`);
1041
1254
  }
1042
1255
  if (changes.localOnly.length > 0) {
1043
- summaryLines.push(`${color.green('Local only:')} ${changes.localOnly.join(', ')}`);
1256
+ if (action === 'diff') {
1257
+ summaryLines.push(`${color.green('Local only:')} ${changes.localOnly.length} file(s)`);
1258
+ } else if (isUpload) {
1259
+ summaryLines.push(`${color.green('To upload:')} ${changes.localOnly.length} file(s)`);
1260
+ } else {
1261
+ summaryLines.push(`${color.red('Local only (can delete):')} ${changes.localOnly.length} file(s)`);
1262
+ }
1044
1263
  }
1045
1264
  if (changes.driveOnly.length > 0) {
1046
- summaryLines.push(`${color.yellow('Drive only:')} ${changes.driveOnly.join(', ')}`);
1265
+ if (action === 'diff') {
1266
+ summaryLines.push(`${color.yellow('Drive only:')} ${changes.driveOnly.length} file(s)`);
1267
+ } else if (isUpload) {
1268
+ summaryLines.push(`${color.red('Drive only (can delete):')} ${changes.driveOnly.length} file(s)`);
1269
+ } else {
1270
+ summaryLines.push(`${color.green('To download:')} ${changes.driveOnly.length} file(s)`);
1271
+ }
1272
+ }
1273
+ if (changes.orphanDriveFolders.length > 0 && (action === 'diff' || isUpload)) {
1274
+ summaryLines.push(`${color.red('Empty folders on Drive:')} ${changes.orphanDriveFolders.length} folder(s)`);
1047
1275
  }
1048
1276
  note(summaryLines.join('\n'), 'Summary');
1049
1277
 
@@ -1063,15 +1291,18 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
1063
1291
  }
1064
1292
 
1065
1293
  if (action === 'download') {
1066
- // Create backup first
1067
- const existingFiles = fs.readdirSync(envDir).filter((f) => matchPattern(f, pattern, ignore));
1068
- if (existingFiles.length > 0) {
1294
+ // Create backup first (recursive)
1295
+ const existingLocalFiles = listLocalFilesRecursive(envDir, pattern, ignore);
1296
+ if (existingLocalFiles.length > 0) {
1069
1297
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
1070
1298
  const backupSubdir = path.join(backupPath, `${name}_${timestamp}`);
1071
1299
  fs.ensureDirSync(backupSubdir);
1072
1300
 
1073
- for (const file of existingFiles) {
1074
- fs.copyFileSync(path.join(envDir, file), path.join(backupSubdir, file));
1301
+ for (const file of existingLocalFiles) {
1302
+ const srcPath = path.join(envDir, file.relativePath);
1303
+ const destPath = path.join(backupSubdir, file.relativePath);
1304
+ fs.ensureDirSync(path.dirname(destPath));
1305
+ fs.copyFileSync(srcPath, destPath);
1075
1306
  }
1076
1307
  log.info(`Backup created: ${backupSubdir}`);
1077
1308
  }
@@ -1081,13 +1312,19 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
1081
1312
 
1082
1313
  let downloaded = 0;
1083
1314
 
1084
- for (const filename of changes.modified) {
1085
- fs.copyFileSync(path.join(tempDir, filename), path.join(envDir, filename));
1315
+ for (const relativePath of changes.modified) {
1316
+ const srcPath = path.join(tempDir, relativePath);
1317
+ const destPath = path.join(envDir, relativePath);
1318
+ fs.ensureDirSync(path.dirname(destPath));
1319
+ fs.copyFileSync(srcPath, destPath);
1086
1320
  downloaded++;
1087
1321
  }
1088
1322
 
1089
- for (const filename of changes.driveOnly) {
1090
- fs.copyFileSync(path.join(tempDir, filename), path.join(envDir, filename));
1323
+ for (const relativePath of changes.driveOnly) {
1324
+ const srcPath = path.join(tempDir, relativePath);
1325
+ const destPath = path.join(envDir, relativePath);
1326
+ fs.ensureDirSync(path.dirname(destPath));
1327
+ fs.copyFileSync(srcPath, destPath);
1091
1328
  downloaded++;
1092
1329
  }
1093
1330
 
@@ -1099,20 +1336,134 @@ const runSyncOperation = async (syncConfig, action, projectRoot, backupPath, con
1099
1336
  let uploaded = 0;
1100
1337
  let replaced = 0;
1101
1338
 
1102
- for (const filename of changes.modified) {
1103
- const driveFile = driveFiles.find((f) => f.name === filename);
1339
+ // Build a map of relativePath -> driveFile for quick lookup
1340
+ const driveFileMap = new Map();
1341
+ for (const df of driveFiles) {
1342
+ driveFileMap.set(df.relativePath, df);
1343
+ }
1344
+
1345
+ for (const relativePath of changes.modified) {
1346
+ const driveFile = driveFileMap.get(relativePath);
1104
1347
  if (driveFile) {
1105
- gdrive.update(driveFile.id, path.join(envDir, filename));
1348
+ gdrive.update(driveFile.id, path.join(envDir, relativePath));
1106
1349
  replaced++;
1107
1350
  }
1108
1351
  }
1109
1352
 
1110
- for (const filename of changes.localOnly) {
1111
- gdrive.upload(path.join(envDir, filename), { parent: folderId });
1353
+ for (const relativePath of changes.localOnly) {
1354
+ const localFilePath = path.join(envDir, relativePath);
1355
+ const folderPath = path.dirname(relativePath);
1356
+
1357
+ // Find or create the parent folder on Drive
1358
+ let parentId = folderId;
1359
+ if (folderPath && folderPath !== '.') {
1360
+ parentId = findOrCreateDriveFolder(folderId, folderPath);
1361
+ }
1362
+
1363
+ gdrive.upload(localFilePath, { parent: parentId });
1112
1364
  uploaded++;
1113
1365
  }
1114
1366
 
1115
1367
  uploadSpinner.stop(color.green(`Replaced: ${replaced}, New: ${uploaded}`));
1368
+
1369
+ // Ask about deleting Drive-only files and orphan folders
1370
+ const hasOrphanItems = changes.driveOnly.length > 0 || changes.orphanDriveFolders.length > 0;
1371
+ if (hasOrphanItems) {
1372
+ console.log('');
1373
+ const totalItems = changes.driveOnly.length + changes.orphanDriveFolders.length;
1374
+ log.warn(`${totalItems} item(s) exist on Drive but not locally:`);
1375
+ for (const relativePath of changes.driveOnly) {
1376
+ console.log(color.red(` - ${relativePath}`));
1377
+ }
1378
+ for (const folder of changes.orphanDriveFolders) {
1379
+ console.log(color.red(` - 📁 ${folder.relativePath}/`));
1380
+ }
1381
+ console.log('');
1382
+
1383
+ const deleteFromDrive = await confirm({
1384
+ message: `Delete these ${totalItems} item(s) from Drive?`,
1385
+ initialValue: false,
1386
+ });
1387
+
1388
+ if (!isCancel(deleteFromDrive) && deleteFromDrive) {
1389
+ const deleteSpinner = spinner();
1390
+ deleteSpinner.start('Deleting items from Drive...');
1391
+
1392
+ let deletedFiles = 0;
1393
+ let deletedFolders = 0;
1394
+
1395
+ // Delete orphan files first
1396
+ for (const relativePath of changes.driveOnly) {
1397
+ const driveFile = driveFileMap.get(relativePath);
1398
+ if (driveFile) {
1399
+ gdrive.remove(driveFile.id);
1400
+ deletedFiles++;
1401
+ }
1402
+ }
1403
+
1404
+ // Delete orphan folders (sort by depth, deepest first)
1405
+ const sortedFolders = [...changes.orphanDriveFolders].sort((a, b) =>
1406
+ b.relativePath.split('/').length - a.relativePath.split('/').length
1407
+ );
1408
+
1409
+ for (const folder of sortedFolders) {
1410
+ gdrive.remove(folder.id, { recursive: true });
1411
+ deletedFolders++;
1412
+ }
1413
+
1414
+ const resultParts = [];
1415
+ if (deletedFiles > 0) resultParts.push(`${deletedFiles} file(s)`);
1416
+ if (deletedFolders > 0) resultParts.push(`${deletedFolders} folder(s)`);
1417
+ deleteSpinner.stop(color.green(`Deleted ${resultParts.join(' and ')} from Drive`));
1418
+ }
1419
+ }
1420
+ }
1421
+
1422
+ // Ask about deleting local-only files during download
1423
+ if (action === 'download' && changes.localOnly.length > 0) {
1424
+ console.log('');
1425
+ log.warn(`${changes.localOnly.length} file(s) exist locally but not on Drive:`);
1426
+ for (const relativePath of changes.localOnly) {
1427
+ console.log(color.red(` - ${relativePath}`));
1428
+ }
1429
+ console.log('');
1430
+ log.info(color.dim('(These files are included in the backup)'));
1431
+
1432
+ const deleteLocal = await confirm({
1433
+ message: `Delete these ${changes.localOnly.length} local file(s)?`,
1434
+ initialValue: false,
1435
+ });
1436
+
1437
+ if (!isCancel(deleteLocal) && deleteLocal) {
1438
+ const deleteSpinner = spinner();
1439
+ deleteSpinner.start('Deleting local files...');
1440
+
1441
+ let deleted = 0;
1442
+ for (const relativePath of changes.localOnly) {
1443
+ const filePath = path.join(envDir, relativePath);
1444
+ if (fs.existsSync(filePath)) {
1445
+ fs.removeSync(filePath);
1446
+ deleted++;
1447
+ }
1448
+ }
1449
+
1450
+ // Clean up empty directories
1451
+ const cleanEmptyDirs = (dir) => {
1452
+ if (!fs.existsSync(dir)) return;
1453
+ const entries = fs.readdirSync(dir);
1454
+ if (entries.length === 0 && dir !== envDir) {
1455
+ fs.rmdirSync(dir);
1456
+ cleanEmptyDirs(path.dirname(dir));
1457
+ }
1458
+ };
1459
+
1460
+ for (const relativePath of changes.localOnly) {
1461
+ const dirPath = path.dirname(path.join(envDir, relativePath));
1462
+ cleanEmptyDirs(dirPath);
1463
+ }
1464
+
1465
+ deleteSpinner.stop(color.green(`Deleted ${deleted} local file(s)`));
1466
+ }
1116
1467
  }
1117
1468
  } catch (e) {
1118
1469
  log.error(e.message);
@@ -1347,4 +1698,7 @@ module.exports = {
1347
1698
  // Exported for testing
1348
1699
  globToRegex,
1349
1700
  matchPattern,
1701
+ listDriveFilesRecursive,
1702
+ listLocalFilesRecursive,
1703
+ findOrCreateDriveFolder,
1350
1704
  };