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/Readme.md +23 -3
- package/docs/PERFORMANCE_DECISIONS.md +142 -0
- package/package.json +1 -1
- package/run.js +12 -0
- package/src/envSync.js +638 -73
- package/src/gdriveCmd.js +156 -3
- package/src/versionCheck.js +100 -0
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
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
|
939
|
-
s.message(
|
|
940
|
-
|
|
941
|
-
|
|
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
|
-
//
|
|
946
|
-
|
|
947
|
-
|
|
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
|
|
958
|
-
const
|
|
959
|
-
const
|
|
960
|
-
|
|
961
|
-
if (
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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(
|
|
1350
|
+
changes.localOnly.push(localFile.relativePath);
|
|
969
1351
|
}
|
|
970
1352
|
}
|
|
971
1353
|
|
|
972
1354
|
// Check Drive-only files
|
|
973
|
-
for (const
|
|
974
|
-
if (!
|
|
975
|
-
changes.driveOnly.push(
|
|
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 ||
|
|
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
|
|
995
|
-
const
|
|
996
|
-
const
|
|
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 "${
|
|
1000
|
-
: shell.exec(`diff -u "${
|
|
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(`--- ${
|
|
1404
|
+
console.log(color.cyan(`--- ${relativePath} (${isUpload ? 'Drive' : 'Local'})`));
|
|
1007
1405
|
} else if (line.startsWith('+++')) {
|
|
1008
|
-
console.log(color.cyan(`+++ ${
|
|
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
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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.
|
|
1464
|
+
summaryLines.push(`${color.cyan('Modified:')} ${changes.modified.length} file(s)`);
|
|
1041
1465
|
}
|
|
1042
1466
|
if (changes.localOnly.length > 0) {
|
|
1043
|
-
|
|
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
|
-
|
|
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
|
|
1068
|
-
if (
|
|
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
|
|
1074
|
-
|
|
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
|
|
1085
|
-
|
|
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
|
|
1090
|
-
|
|
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
|
-
|
|
1103
|
-
|
|
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,
|
|
1559
|
+
gdrive.update(driveFile.id, path.join(envDir, relativePath));
|
|
1106
1560
|
replaced++;
|
|
1107
1561
|
}
|
|
1108
1562
|
}
|
|
1109
1563
|
|
|
1110
|
-
for (const
|
|
1111
|
-
|
|
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
|
};
|