git-ripper 1.4.4 → 1.4.6

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 CHANGED
@@ -32,10 +32,14 @@ Have you ever needed just a single component from a massive repository? Or wante
32
32
  ## Features
33
33
 
34
34
  - **Selective Downloads**: Fetch specific folders instead of entire repositories
35
+ - **Resume Interrupted Downloads**: Automatically resume downloads that were interrupted or failed
36
+ - **Progress Tracking**: Visual progress indicators with file-by-file download status
37
+ - **File Integrity Verification**: Ensures downloaded files are complete and uncorrupted
35
38
  - **Directory Structure**: Preserves complete folder structure
36
39
  - **Custom Output**: Specify your preferred output directory
37
40
  - **Branch Support**: Works with any branch, not just the default one
38
41
  - **Archive Export**: Create ZIP archives of downloaded content
42
+ - **Checkpoint Management**: View and manage saved download progress
39
43
  - **Simple Interface**: Clean, intuitive command-line experience
40
44
  - **Lightweight**: Minimal dependencies and fast execution
41
45
  - **No Authentication**: Works with public repositories without requiring credentials
@@ -94,6 +98,9 @@ git-ripper https://github.com/username/repository/tree/branch/folder --zip="my-a
94
98
  | -------------------------- | ---------------------------------------- | ----------------- |
95
99
  | `-o, --output <directory>` | Specify output directory | Current directory |
96
100
  | `--zip [filename]` | Create ZIP archive of downloaded content | - |
101
+ | `--no-resume` | Disable resume functionality | - |
102
+ | `--force-restart` | Ignore existing checkpoints and restart | - |
103
+ | `--list-checkpoints` | List all saved download checkpoints | - |
97
104
  | `-V, --version` | Show version number | - |
98
105
  | `-h, --help` | Show help | - |
99
106
 
@@ -137,14 +144,67 @@ git-ripper https://github.com/facebook/react/tree/main/packages/react-dom --zip
137
144
  git-ripper https://github.com/microsoft/vscode/tree/main/build --zip="vscode-build.zip"
138
145
  ```
139
146
 
147
+ ## Resume Downloads
148
+
149
+ Git-ripper now supports resuming interrupted downloads, making it perfect for large folders or unstable network connections.
150
+
151
+ ### Automatic Resume (Default Behavior)
152
+
153
+ ```bash
154
+ # Start a download
155
+ git-ripper https://github.com/microsoft/vscode/tree/main/src/vs/workbench
156
+
157
+ # If interrupted (Ctrl+C, network issues, etc.), simply run the same command again
158
+ git-ripper https://github.com/microsoft/vscode/tree/main/src/vs/workbench
159
+ # It will automatically resume from where it left off
160
+ ```
161
+
162
+ ### Force Restart
163
+
164
+ ```bash
165
+ # Ignore any existing progress and start fresh
166
+ git-ripper https://github.com/microsoft/vscode/tree/main/src/vs/workbench --force-restart
167
+ ```
168
+
169
+ ### Disable Resume
170
+
171
+ ```bash
172
+ # Use traditional behavior without resume functionality
173
+ git-ripper https://github.com/microsoft/vscode/tree/main/src/vs/workbench --no-resume
174
+ ```
175
+
176
+ ### Manage Checkpoints
177
+
178
+ ```bash
179
+ # List all saved download progress
180
+ git-ripper --list-checkpoints
181
+
182
+ # Output shows:
183
+ # 1. ID: a1b2c3d4
184
+ # URL: https://github.com/microsoft/vscode/tree/main/src/vs/workbench
185
+ # Progress: 45/120 files
186
+ # Last Updated: 2025-06-04T10:30:00Z
187
+ ```
188
+
189
+ ### Resume Features
190
+
191
+ - **Automatic Progress Saving**: Downloads are checkpointed every few files
192
+ - **File Integrity Verification**: Ensures existing files are complete and valid
193
+ - **Smart Recovery**: Detects corrupted or incomplete files and re-downloads them
194
+ - **Multi-Download Support**: Manage multiple concurrent download projects
195
+ - **Progress Indicators**: Visual feedback showing completed vs remaining files
196
+
140
197
  ## How It Works
141
198
 
142
- Git-ripper operates in four stages:
199
+ Git-ripper operates in five stages:
143
200
 
144
201
  1. **URL Parsing**: Extracts repository owner, name, branch, and target folder path
145
- 2. **API Request**: Uses GitHub's API to fetch the folder structure
146
- 3. **Content Download**: Retrieves each file individually while maintaining directory structure
147
- 4. **Local Storage or Archiving**: Saves files to your specified output directory or creates an archive
202
+ 2. **Resume Check**: Looks for existing download progress and validates already downloaded files
203
+ 3. **API Request**: Uses GitHub's API to fetch the folder structure
204
+ 4. **Content Download**: Retrieves each file individually while maintaining directory structure and saving progress
205
+ 5. **Local Storage or Archiving**: Saves files to your specified output directory or creates an archive
206
+
207
+ The resume functionality uses checkpoint files stored in `.git_ripper_checkpoints/` to track download progress, file integrity hashes, and metadata for each download session.
148
208
 
149
209
  ## Configuration
150
210
 
@@ -178,6 +238,29 @@ Error: Path not found in repository
178
238
 
179
239
  **Solution**: Verify the folder path exists in the specified branch and repository.
180
240
 
241
+ #### Resume Issues
242
+
243
+ If you encounter problems with resume functionality:
244
+
245
+ ```bash
246
+ # Clear all checkpoints and start fresh
247
+ git-ripper https://github.com/owner/repo/tree/branch/folder --force-restart
248
+
249
+ # Or disable resume entirely
250
+ git-ripper https://github.com/owner/repo/tree/branch/folder --no-resume
251
+ ```
252
+
253
+ #### Corrupted Download
254
+
255
+ If files appear corrupted after resume:
256
+
257
+ ```bash
258
+ # Force restart will re-download everything
259
+ git-ripper https://github.com/owner/repo/tree/branch/folder --force-restart
260
+ ```
261
+
262
+ The resume feature automatically detects and re-downloads corrupted files, but `--force-restart` ensures a completely clean download.
263
+
181
264
  ## Contributing
182
265
 
183
266
  Contributions make the open-source community an amazing place to learn, inspire, and create. Any contributions to Git-ripper are **greatly appreciated**.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "git-ripper",
3
- "version": "1.4.4",
3
+ "version": "1.4.6",
4
4
  "description": "CLI tool that lets you download specific folders from GitHub repositories without cloning the entire repo.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -29,15 +29,13 @@
29
29
  "author": "sairajb",
30
30
  "license": "MIT",
31
31
  "dependencies": {
32
- "ansi-styles": "^6.2.1",
33
32
  "archiver": "^6.0.1",
34
33
  "axios": "^1.6.7",
35
34
  "chalk": "^5.3.0",
36
35
  "cli-progress": "^3.12.0",
37
36
  "commander": "^12.0.0",
38
37
  "p-limit": "^6.2.0",
39
- "pretty-bytes": "^6.1.1",
40
- "supports-color": "^9.4.0"
38
+ "pretty-bytes": "^6.1.1"
41
39
  },
42
40
  "repository": {
43
41
  "type": "git",
package/src/archiver.js CHANGED
@@ -144,10 +144,32 @@ export const downloadAndArchive = async (
144
144
  // Create a temporary directory for the download
145
145
  const tempDir = path.join(outputDir, `.temp-${Date.now()}`);
146
146
  fs.mkdirSync(tempDir, { recursive: true });
147
-
148
147
  try {
149
148
  // Download the folder contents
150
- await downloadFolder(repoInfo, tempDir);
149
+ const downloadResult = await downloadFolder(repoInfo, tempDir);
150
+
151
+ // Check if download failed
152
+ if (downloadResult && !downloadResult.success) {
153
+ throw new Error(
154
+ `Download failed: ${
155
+ downloadResult.failedFiles || 0
156
+ } files could not be downloaded`
157
+ );
158
+ }
159
+
160
+ // Check if there's anything to archive
161
+ const files = fs
162
+ .readdirSync(tempDir, { recursive: true })
163
+ .filter((file) => {
164
+ const fullPath = path.join(tempDir, file);
165
+ return fs.statSync(fullPath).isFile();
166
+ });
167
+
168
+ if (files.length === 0) {
169
+ throw new Error(
170
+ `No files to archive - download may have failed or repository is empty`
171
+ );
172
+ }
151
173
 
152
174
  // Determine archive filename
153
175
  let archiveFileName = archiveName;
package/src/downloader.js CHANGED
@@ -45,6 +45,7 @@ const getSpinnerFrame = () => {
45
45
  * @param {string} branch - Branch name
46
46
  * @param {string} folderPath - Path to the folder
47
47
  * @returns {Promise<Array>} - Promise resolving to an array of file objects
48
+ * @throws {Error} - Throws error on API failures instead of returning empty array
48
49
  */
49
50
  const fetchFolderContents = async (owner, repo, branch, folderPath) => {
50
51
  let effectiveBranch = branch;
@@ -55,12 +56,9 @@ const fetchFolderContents = async (owner, repo, branch, folderPath) => {
55
56
  const repoInfoResponse = await axios.get(repoInfoUrl);
56
57
  effectiveBranch = repoInfoResponse.data.default_branch;
57
58
  if (!effectiveBranch) {
58
- console.error(
59
- chalk.red(
60
- `Could not determine default branch for ${owner}/${repo}. Please specify a branch in the URL.`
61
- )
59
+ throw new Error(
60
+ `Could not determine default branch for ${owner}/${repo}. Please specify a branch in the URL.`
62
61
  );
63
- return [];
64
62
  }
65
63
  console.log(
66
64
  chalk.blue(
@@ -68,12 +66,12 @@ const fetchFolderContents = async (owner, repo, branch, folderPath) => {
68
66
  )
69
67
  );
70
68
  } catch (error) {
71
- console.error(
72
- chalk.red(
73
- `Failed to fetch default branch for ${owner}/${repo}: ${error.message}`
74
- )
69
+ if (error.message.includes("Could not determine default branch")) {
70
+ throw error;
71
+ }
72
+ throw new Error(
73
+ `Failed to fetch default branch for ${owner}/${repo}: ${error.message}`
75
74
  );
76
- return [];
77
75
  }
78
76
  }
79
77
 
@@ -109,54 +107,44 @@ const fetchFolderContents = async (owner, repo, branch, folderPath) => {
109
107
  return response.data.tree.filter((item) => item.path.startsWith(prefix));
110
108
  }
111
109
  } catch (error) {
110
+ let errorMessage = "";
111
+ let isRateLimit = false;
112
+
112
113
  if (error.response) {
113
114
  // Handle specific HTTP error codes
114
115
  switch (error.response.status) {
115
116
  case 403:
116
117
  if (error.response.headers["x-ratelimit-remaining"] === "0") {
117
- console.error(
118
- chalk.red(
119
- `GitHub API rate limit exceeded. Please wait until ${new Date(
120
- parseInt(error.response.headers["x-ratelimit-reset"]) * 1000
121
- ).toLocaleTimeString()} or add a GitHub token (feature coming soon).`
122
- )
123
- );
118
+ isRateLimit = true;
119
+ errorMessage = `GitHub API rate limit exceeded. Please wait until ${new Date(
120
+ parseInt(error.response.headers["x-ratelimit-reset"]) * 1000
121
+ ).toLocaleTimeString()} or add a GitHub token (feature coming soon).`;
124
122
  } else {
125
- console.error(
126
- chalk.red(
127
- `Access forbidden: ${
128
- error.response.data.message || "Unknown reason"
129
- }`
130
- )
131
- );
123
+ errorMessage = `Access forbidden: ${
124
+ error.response.data.message ||
125
+ "Repository may be private or you may not have access"
126
+ }`;
132
127
  }
133
128
  break;
134
129
  case 404:
135
- console.error(
136
- chalk.red(
137
- `Repository, branch, or folder not found: ${owner}/${repo}/${branch}/${folderPath}`
138
- )
139
- );
130
+ errorMessage = `Repository, branch, or folder not found: ${owner}/${repo}/${branch}/${folderPath}`;
140
131
  break;
141
132
  default:
142
- console.error(
143
- chalk.red(
144
- `API error (${error.response.status}): ${
145
- error.response.data.message || error.message
146
- }`
147
- )
148
- );
133
+ errorMessage = `API error (${error.response.status}): ${
134
+ error.response.data.message || error.message
135
+ }`;
149
136
  }
150
137
  } else if (error.request) {
151
- console.error(
152
- chalk.red(
153
- `Network error: No response received from GitHub. Please check your internet connection.`
154
- )
155
- );
138
+ errorMessage = `Network error: No response received from GitHub. Please check your internet connection.`;
156
139
  } else {
157
- console.error(chalk.red(`Error preparing request: ${error.message}`));
140
+ errorMessage = `Error preparing request: ${error.message}`;
158
141
  }
159
- return [];
142
+
143
+ // Always throw the error instead of returning empty array
144
+ const enrichedError = new Error(errorMessage);
145
+ enrichedError.isRateLimit = isRateLimit;
146
+ enrichedError.statusCode = error.response?.status;
147
+ throw enrichedError;
160
148
  }
161
149
  };
162
150
 
@@ -349,11 +337,15 @@ const downloadFolder = async (
349
337
  const contents = await fetchFolderContents(owner, repo, branch, folderPath);
350
338
 
351
339
  if (!contents || contents.length === 0) {
352
- console.log(
353
- chalk.yellow(`No files found in ${folderPath || "repository root"}`)
354
- );
355
- console.log(chalk.green(`Folder cloned successfully!`));
356
- return;
340
+ const message = `No files found in ${folderPath || "repository root"}`;
341
+ console.log(chalk.yellow(message));
342
+ // Don't print success message when no files are found - this might indicate an error
343
+ return {
344
+ success: true,
345
+ filesDownloaded: 0,
346
+ failedFiles: 0,
347
+ isEmpty: true,
348
+ };
357
349
  }
358
350
 
359
351
  // Filter for blob type (files)
@@ -361,15 +353,18 @@ const downloadFolder = async (
361
353
  const totalFiles = files.length;
362
354
 
363
355
  if (totalFiles === 0) {
364
- console.log(
365
- chalk.yellow(
366
- `No files found in ${
367
- folderPath || "repository root"
368
- } (only directories)`
369
- )
370
- );
371
- console.log(chalk.green(`Folder cloned successfully!`));
372
- return;
356
+ const message = `No files found in ${
357
+ folderPath || "repository root"
358
+ } (only directories)`;
359
+ console.log(chalk.yellow(message));
360
+ // This is a legitimate case - directory exists but contains only subdirectories
361
+ console.log(chalk.green(`Directory structure downloaded successfully!`));
362
+ return {
363
+ success: true,
364
+ filesDownloaded: 0,
365
+ failedFiles: 0,
366
+ isEmpty: true,
367
+ };
373
368
  }
374
369
 
375
370
  console.log(
@@ -464,7 +459,6 @@ const downloadFolder = async (
464
459
  // Count successful and failed downloads
465
460
  const succeeded = results.filter((r) => r.success).length;
466
461
  const failed = failedFiles.length;
467
-
468
462
  if (failed > 0) {
469
463
  console.log(
470
464
  chalk.yellow(
@@ -485,15 +479,45 @@ const downloadFolder = async (
485
479
  )
486
480
  );
487
481
  }
482
+
483
+ // Don't claim success if files failed to download
484
+ if (succeeded === 0) {
485
+ console.log(
486
+ chalk.red(`❌ Download failed: No files were downloaded successfully`)
487
+ );
488
+ return {
489
+ success: false,
490
+ filesDownloaded: succeeded,
491
+ failedFiles: failed,
492
+ isEmpty: false,
493
+ };
494
+ } else {
495
+ console.log(chalk.yellow(`⚠️ Download completed with errors`));
496
+ return {
497
+ success: false,
498
+ filesDownloaded: succeeded,
499
+ failedFiles: failed,
500
+ isEmpty: false,
501
+ };
502
+ }
488
503
  } else {
489
504
  console.log(
490
- chalk.green(` All ${succeeded} files downloaded successfully!`)
505
+ chalk.green(`✅ All ${succeeded} files downloaded successfully!`)
491
506
  );
507
+ console.log(chalk.green(`Folder cloned successfully!`));
508
+ return {
509
+ success: true,
510
+ filesDownloaded: succeeded,
511
+ failedFiles: failed,
512
+ isEmpty: false,
513
+ };
492
514
  }
493
-
494
- console.log(chalk.green(`Folder cloned successfully!`));
495
515
  } catch (error) {
496
- console.error(chalk.red(`Error downloading folder: ${error.message}`));
516
+ // Log the specific error details
517
+ console.error(chalk.red(`❌ Error downloading folder: ${error.message}`));
518
+
519
+ // Re-throw the error so the main CLI can exit with proper error code
520
+ throw error;
497
521
  }
498
522
  };
499
523
 
@@ -576,13 +600,16 @@ const downloadFolderWithResume = async (
576
600
 
577
601
  try {
578
602
  const contents = await fetchFolderContents(owner, repo, branch, folderPath);
579
-
580
603
  if (!contents || contents.length === 0) {
581
- console.log(
582
- chalk.yellow(`No files found in ${folderPath || "repository root"}`)
583
- );
584
- console.log(chalk.green(`Folder cloned successfully!`));
585
- return;
604
+ const message = `No files found in ${folderPath || "repository root"}`;
605
+ console.log(chalk.yellow(message));
606
+ // Don't print success message when no files are found - this might indicate an error
607
+ return {
608
+ success: true,
609
+ filesDownloaded: 0,
610
+ failedFiles: 0,
611
+ isEmpty: true,
612
+ };
586
613
  }
587
614
 
588
615
  // Filter for blob type (files)
@@ -590,15 +617,18 @@ const downloadFolderWithResume = async (
590
617
  const totalFiles = files.length;
591
618
 
592
619
  if (totalFiles === 0) {
593
- console.log(
594
- chalk.yellow(
595
- `No files found in ${
596
- folderPath || "repository root"
597
- } (only directories)`
598
- )
599
- );
600
- console.log(chalk.green(`Folder cloned successfully!`));
601
- return;
620
+ const message = `No files found in ${
621
+ folderPath || "repository root"
622
+ } (only directories)`;
623
+ console.log(chalk.yellow(message));
624
+ // This is a legitimate case - directory exists but contains only subdirectories
625
+ console.log(chalk.green(`Directory structure downloaded successfully!`));
626
+ return {
627
+ success: true,
628
+ filesDownloaded: 0,
629
+ failedFiles: 0,
630
+ isEmpty: true,
631
+ };
602
632
  }
603
633
 
604
634
  // Create new checkpoint if none exists
@@ -748,7 +778,6 @@ const downloadFolderWithResume = async (
748
778
  // Count results
749
779
  const succeeded = checkpoint.downloadedFiles.length;
750
780
  const failed = failedFiles.length;
751
-
752
781
  if (failed > 0) {
753
782
  console.log(
754
783
  chalk.yellow(
@@ -766,21 +795,47 @@ const downloadFolderWithResume = async (
766
795
  console.log(
767
796
  chalk.blue(`💡 Run the same command again to retry failed downloads`)
768
797
  );
798
+
799
+ // Don't claim success if files failed to download
800
+ if (succeeded === 0) {
801
+ console.log(
802
+ chalk.red(`❌ Download failed: No files were downloaded successfully`)
803
+ );
804
+ return {
805
+ success: false,
806
+ filesDownloaded: succeeded,
807
+ failedFiles: failed,
808
+ isEmpty: false,
809
+ };
810
+ } else {
811
+ console.log(chalk.yellow(`⚠️ Download completed with errors`));
812
+ return {
813
+ success: false,
814
+ filesDownloaded: succeeded,
815
+ failedFiles: failed,
816
+ isEmpty: false,
817
+ };
818
+ }
769
819
  } else {
770
820
  console.log(
771
821
  chalk.green(`🎉 All ${succeeded} files downloaded successfully!`)
772
822
  );
773
823
  resumeManager.cleanupCheckpoint(url, outputDir);
824
+ console.log(chalk.green(`Folder cloned successfully!`));
825
+ return {
826
+ success: true,
827
+ filesDownloaded: succeeded,
828
+ failedFiles: failed,
829
+ isEmpty: false,
830
+ };
774
831
  }
775
-
776
- console.log(chalk.green(`Folder cloned successfully!`));
777
832
  } catch (error) {
778
833
  // Save checkpoint on any error
779
834
  if (checkpoint) {
780
835
  resumeManager.saveCheckpoint(checkpoint);
781
836
  }
782
837
 
783
- console.error(chalk.red(`Error downloading folder: ${error.message}`));
838
+ console.error(chalk.red(`❌ Error downloading folder: ${error.message}`));
784
839
  throw error;
785
840
  }
786
841
  };
package/src/index.js CHANGED
@@ -123,24 +123,46 @@ const initializeCLI = () => {
123
123
  forceRestart: options.forceRestart || false,
124
124
  };
125
125
 
126
- if (createArchive) {
127
- console.log(`Creating ZIP archive...`);
128
- await downloadAndArchive(parsedUrl, options.output, archiveName);
129
- } else {
130
- console.log(`Downloading folder to: ${options.output}`);
126
+ let operationType = createArchive ? "archive" : "download";
127
+ let result = null;
128
+ let error = null;
131
129
 
132
- if (downloadOptions.resume) {
133
- await downloadFolderWithResume(
134
- parsedUrl,
135
- options.output,
136
- downloadOptions
137
- );
130
+ try {
131
+ if (createArchive) {
132
+ console.log(`Creating ZIP archive...`);
133
+ await downloadAndArchive(parsedUrl, options.output, archiveName);
138
134
  } else {
139
- await downloadFolder(parsedUrl, options.output);
135
+ console.log(`Downloading folder to: ${options.output}`);
136
+ if (downloadOptions.resume) {
137
+ result = await downloadFolderWithResume(
138
+ parsedUrl,
139
+ options.output,
140
+ downloadOptions
141
+ );
142
+ } else {
143
+ result = await downloadFolder(parsedUrl, options.output);
144
+ }
140
145
  }
146
+ } catch (opError) {
147
+ error = opError;
141
148
  }
142
149
 
143
- console.log("Operation completed successfully!");
150
+ // Consolidated result and error handling
151
+ if (error) {
152
+ const failMsg =
153
+ operationType === "archive"
154
+ ? `❌ Archive creation failed: ${error.message}`
155
+ : `❌ Download failed: ${error.message}`;
156
+ console.error(chalk.red(failMsg));
157
+ process.exit(1);
158
+ } else if (!createArchive && result && !result.success) {
159
+ console.error(chalk.red(`❌ Download failed`));
160
+ process.exit(1);
161
+ } else if (!createArchive && result && result.isEmpty) {
162
+ console.log("Operation completed - no files to download!");
163
+ } else {
164
+ console.log("Operation completed successfully!");
165
+ }
144
166
  } catch (error) {
145
167
  console.error("Error:", error.message);
146
168
  process.exit(1);