github-backup 0.55.0__tar.gz → 0.57.0__tar.gz

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 (28) hide show
  1. {github_backup-0.55.0 → github_backup-0.57.0}/CHANGES.rst +126 -1
  2. {github_backup-0.55.0/github_backup.egg-info → github_backup-0.57.0}/PKG-INFO +61 -3
  3. {github_backup-0.55.0 → github_backup-0.57.0}/README +60 -2
  4. {github_backup-0.55.0 → github_backup-0.57.0}/README.rst +60 -2
  5. github_backup-0.57.0/github_backup/__init__.py +1 -0
  6. github_backup-0.57.0/github_backup/__main__.py +13 -0
  7. github_backup-0.55.0/bin/github-backup → github_backup-0.57.0/github_backup/cli.py +2 -0
  8. {github_backup-0.55.0 → github_backup-0.57.0}/github_backup/github_backup.py +84 -43
  9. {github_backup-0.55.0 → github_backup-0.57.0/github_backup.egg-info}/PKG-INFO +61 -3
  10. {github_backup-0.55.0 → github_backup-0.57.0}/github_backup.egg-info/SOURCES.txt +5 -2
  11. github_backup-0.57.0/github_backup.egg-info/entry_points.txt +2 -0
  12. {github_backup-0.55.0 → github_backup-0.57.0}/release-requirements.txt +4 -4
  13. {github_backup-0.55.0 → github_backup-0.57.0}/setup.py +5 -1
  14. github_backup-0.57.0/tests/test_skip_assets_on.py +320 -0
  15. github_backup-0.55.0/github_backup/__init__.py +0 -1
  16. {github_backup-0.55.0 → github_backup-0.57.0}/LICENSE.txt +0 -0
  17. {github_backup-0.55.0 → github_backup-0.57.0}/MANIFEST.in +0 -0
  18. {github_backup-0.55.0 → github_backup-0.57.0}/github_backup.egg-info/dependency_links.txt +0 -0
  19. {github_backup-0.55.0 → github_backup-0.57.0}/github_backup.egg-info/top_level.txt +0 -0
  20. {github_backup-0.55.0 → github_backup-0.57.0}/github_backup.egg-info/zip-safe +0 -0
  21. {github_backup-0.55.0 → github_backup-0.57.0}/requirements.txt +0 -0
  22. {github_backup-0.55.0 → github_backup-0.57.0}/setup.cfg +0 -0
  23. {github_backup-0.55.0 → github_backup-0.57.0}/tests/test_all_starred.py +0 -0
  24. {github_backup-0.55.0 → github_backup-0.57.0}/tests/test_attachments.py +0 -0
  25. {github_backup-0.55.0 → github_backup-0.57.0}/tests/test_case_sensitivity.py +0 -0
  26. {github_backup-0.55.0 → github_backup-0.57.0}/tests/test_http_451.py +0 -0
  27. {github_backup-0.55.0 → github_backup-0.57.0}/tests/test_json_dump_if_changed.py +0 -0
  28. {github_backup-0.55.0 → github_backup-0.57.0}/tests/test_pagination.py +0 -0
@@ -1,9 +1,134 @@
1
1
  Changelog
2
2
  =========
3
3
 
4
- 0.55.0 (2025-12-07)
4
+ 0.57.0 (2025-12-12)
5
5
  -------------------
6
6
  ------------------------
7
+ - Add GitHub Apps documentation and remove outdated header. [Rodos]
8
+
9
+ - Add GitHub Apps authentication section with setup steps
10
+ and CI/CD workflow example using actions/create-github-app-token
11
+ - Remove outdated machine-man-preview header (graduated 2020)
12
+
13
+ Closes #189
14
+ - Docs: add stdin token example to README. [Rodos]
15
+
16
+ Add example showing how to pipe a token from stdin using
17
+ file:///dev/stdin to avoid storing tokens in environment
18
+ variables or command history.
19
+
20
+ Closes #187
21
+ - Add --skip-assets-on flag to skip release asset downloads (#135)
22
+ [Rodos]
23
+
24
+ Allow users to skip downloading release assets for specific repositories
25
+ while still backing up release metadata. Useful for starred repos with
26
+ large assets (e.g. syncthing with 27GB+).
27
+
28
+ Usage: --skip-assets-on repo1 repo2 owner/repo3
29
+
30
+ Features:
31
+ - Space-separated repos (consistent with --exclude)
32
+ - Case-insensitive matching
33
+ - Supports both repo name and owner/repo format
34
+
35
+
36
+ 0.56.0 (2025-12-11)
37
+ -------------------
38
+
39
+ Fix
40
+ ~~~
41
+ - Replace deprecated git lfs clone with git clone + git lfs fetch --all.
42
+ [Rodos]
43
+
44
+ git lfs clone is deprecated - modern git clone handles LFS automatically.
45
+ Using git lfs fetch --all ensures all LFS objects across all refs are
46
+ backed up, matching the existing bare clone behavior and providing
47
+ complete LFS backups.
48
+
49
+ Closes #379
50
+ - Add Windows support with entry_points and os.replace. [Rodos]
51
+
52
+ - Replace os.rename() with os.replace() for atomic file operations
53
+ on Windows (os.rename fails if destination exists on Windows)
54
+ - Add entry_points console_scripts for proper .exe generation on Windows
55
+ - Create github_backup/cli.py with main() entry point
56
+ - Add github_backup/__main__.py for python -m github_backup support
57
+ - Keep bin/github-backup as thin wrapper for backwards compatibility
58
+
59
+ Closes #112
60
+
61
+ Other
62
+ ~~~~~
63
+ - Docs: add "Restoring from Backup" section to README. [Rodos]
64
+
65
+ Clarifies that this tool is backup-only with no inbuilt restore.
66
+ Documents that git repos can be pushed back, but issues/PRs have
67
+ GitHub API limitations affecting all backup tools.
68
+
69
+ Closes #246
70
+ - Chore(deps): bump urllib3 in the python-packages group.
71
+ [dependabot[bot]]
72
+
73
+ Bumps the python-packages group with 1 update: [urllib3](https://github.com/urllib3/urllib3).
74
+
75
+
76
+ Updates `urllib3` from 2.6.0 to 2.6.1
77
+ - [Release notes](https://github.com/urllib3/urllib3/releases)
78
+ - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
79
+ - [Commits](https://github.com/urllib3/urllib3/compare/2.6.0...2.6.1)
80
+
81
+ ---
82
+ updated-dependencies:
83
+ - dependency-name: urllib3
84
+ dependency-version: 2.6.1
85
+ dependency-type: direct:production
86
+ update-type: version-update:semver-patch
87
+ dependency-group: python-packages
88
+ ...
89
+ - Chore(deps): bump the python-packages group with 3 updates.
90
+ [dependabot[bot]]
91
+
92
+ Bumps the python-packages group with 3 updates: [black](https://github.com/psf/black), [pytest](https://github.com/pytest-dev/pytest) and [platformdirs](https://github.com/tox-dev/platformdirs).
93
+
94
+
95
+ Updates `black` from 25.11.0 to 25.12.0
96
+ - [Release notes](https://github.com/psf/black/releases)
97
+ - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md)
98
+ - [Commits](https://github.com/psf/black/compare/25.11.0...25.12.0)
99
+
100
+ Updates `pytest` from 9.0.1 to 9.0.2
101
+ - [Release notes](https://github.com/pytest-dev/pytest/releases)
102
+ - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
103
+ - [Commits](https://github.com/pytest-dev/pytest/compare/9.0.1...9.0.2)
104
+
105
+ Updates `platformdirs` from 4.5.0 to 4.5.1
106
+ - [Release notes](https://github.com/tox-dev/platformdirs/releases)
107
+ - [Changelog](https://github.com/tox-dev/platformdirs/blob/main/CHANGES.rst)
108
+ - [Commits](https://github.com/tox-dev/platformdirs/compare/4.5.0...4.5.1)
109
+
110
+ ---
111
+ updated-dependencies:
112
+ - dependency-name: black
113
+ dependency-version: 25.12.0
114
+ dependency-type: direct:production
115
+ update-type: version-update:semver-minor
116
+ dependency-group: python-packages
117
+ - dependency-name: pytest
118
+ dependency-version: 9.0.2
119
+ dependency-type: direct:production
120
+ update-type: version-update:semver-patch
121
+ dependency-group: python-packages
122
+ - dependency-name: platformdirs
123
+ dependency-version: 4.5.1
124
+ dependency-type: direct:production
125
+ update-type: version-update:semver-patch
126
+ dependency-group: python-packages
127
+ ...
128
+
129
+
130
+ 0.55.0 (2025-12-07)
131
+ -------------------
7
132
 
8
133
  Fix
9
134
  ~~~
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.55.0
3
+ Version: 0.57.0
4
4
  Summary: backup a github user or organization
5
5
  Home-page: http://github.com/josegonzalez/python-github-backup
6
6
  Author: Jose Diaz-Gonzalez
@@ -80,8 +80,8 @@ CLI Help output::
80
80
  [--keychain-name OSX_KEYCHAIN_ITEM_NAME]
81
81
  [--keychain-account OSX_KEYCHAIN_ITEM_ACCOUNT]
82
82
  [--releases] [--latest-releases NUMBER_OF_LATEST_RELEASES]
83
- [--skip-prerelease] [--assets] [--attachments]
84
- [--exclude [REPOSITORY [REPOSITORY ...]]
83
+ [--skip-prerelease] [--assets] [--skip-assets-on [REPO ...]]
84
+ [--attachments] [--exclude [REPOSITORY [REPOSITORY ...]]
85
85
  [--throttle-limit THROTTLE_LIMIT] [--throttle-pause THROTTLE_PAUSE]
86
86
  USER
87
87
 
@@ -163,6 +163,9 @@ CLI Help output::
163
163
  --skip-prerelease skip prerelease and draft versions; only applies if including releases
164
164
  --assets include assets alongside release information; only
165
165
  applies if including releases
166
+ --skip-assets-on [REPO ...]
167
+ skip asset downloads for these repositories (e.g.
168
+ --skip-assets-on repo1 owner/repo2)
166
169
  --attachments download user-attachments from issues and pull requests
167
170
  to issues/attachments/{issue_number}/ and
168
171
  pulls/attachments/{pull_number}/ directories
@@ -204,6 +207,37 @@ Customise the permissions for your use case, but for a personal account full bac
204
207
  **Repository permissions**: Read access to contents, issues, metadata, pull requests, and webhooks.
205
208
 
206
209
 
210
+ GitHub Apps
211
+ ~~~~~~~~~~~
212
+
213
+ GitHub Apps are ideal for organization backups in CI/CD. Tokens are scoped to specific repositories and expire after 1 hour.
214
+
215
+ **One-time setup:**
216
+
217
+ 1. Create a GitHub App at *Settings -> Developer Settings -> GitHub Apps -> New GitHub App*
218
+ 2. Set a name and homepage URL (can be any URL)
219
+ 3. Uncheck "Webhook > Active" (not needed for backups)
220
+ 4. Set permissions (same as fine-grained tokens above)
221
+ 5. Click "Create GitHub App", then note the **App ID** shown on the next page
222
+ 6. Under "Private keys", click "Generate a private key" and save the downloaded file
223
+ 7. Go to *Install App* in your app's settings
224
+ 8. Select the account/organization and which repositories to back up
225
+
226
+ **CI/CD usage with GitHub Actions:**
227
+
228
+ Store the App ID as a repository variable and the private key contents as a secret, then use ``actions/create-github-app-token``::
229
+
230
+ - uses: actions/create-github-app-token@v1
231
+ id: app-token
232
+ with:
233
+ app-id: ${{ vars.APP_ID }}
234
+ private-key: ${{ secrets.APP_PRIVATE_KEY }}
235
+
236
+ - run: github-backup myorg -t ${{ steps.app-token.outputs.token }} --as-app -o ./backup --all
237
+
238
+ Note: Installation tokens expire after 1 hour. For long-running backups, use a fine-grained personal access token instead.
239
+
240
+
207
241
  Prefer SSH
208
242
  ~~~~~~~~~~
209
243
 
@@ -245,6 +279,8 @@ When you use the ``--lfs`` option, you will need to make sure you have Git LFS i
245
279
 
246
280
  Instructions on how to do this can be found on https://git-lfs.github.com.
247
281
 
282
+ LFS objects are fetched for all refs, not just the current checkout, ensuring a complete backup of all LFS content across all branches and history.
283
+
248
284
 
249
285
  About Attachments
250
286
  -----------------
@@ -389,6 +425,28 @@ Debug an error/block or incomplete backup into a temporary directory. Omit "incr
389
425
 
390
426
  github-backup -f $FINE_ACCESS_TOKEN -o /tmp/github-backup/ -l debug -P --all-starred --starred --watched --followers --following --issues --issue-comments --issue-events --pulls --pull-comments --pull-commits --labels --milestones --repositories --wikis --releases --assets --pull-details --gists --starred-gists $GH_USER
391
427
 
428
+ Pipe a token from stdin to avoid storing it in environment variables or command history (Unix-like systems only)::
429
+
430
+ my-secret-manager get github-token | github-backup user -t file:///dev/stdin -o /backup --repositories
431
+
432
+ Restoring from Backup
433
+ =====================
434
+
435
+ This tool creates backups only, there is no inbuilt restore command.
436
+
437
+ **Git repositories, wikis, and gists** can be restored by pushing them back to GitHub as you would any git repository. For example, to restore a bare repository backup::
438
+
439
+ cd /tmp/white-house/repositories/petitions/repository
440
+ git push --mirror git@github.com:WhiteHouse/petitions.git
441
+
442
+ **Issues, pull requests, comments, and other metadata** are saved as JSON files for archival purposes. The GitHub API does not support recreating this data faithfully, creating issues via the API has limitations:
443
+
444
+ - New issue/PR numbers are assigned (original numbers cannot be set)
445
+ - Timestamps reflect creation time (original dates cannot be set)
446
+ - The API caller becomes the author (original authors cannot be set)
447
+ - Cross-references between issues and PRs will break
448
+
449
+ These are GitHub API limitations that affect all backup and migration tools, not just this one. Recreating issues with these limitations via the GitHub API is an exercise for the reader. The JSON backups remain useful for searching, auditing, or manual reference.
392
450
 
393
451
 
394
452
  Development
@@ -50,8 +50,8 @@ CLI Help output::
50
50
  [--keychain-name OSX_KEYCHAIN_ITEM_NAME]
51
51
  [--keychain-account OSX_KEYCHAIN_ITEM_ACCOUNT]
52
52
  [--releases] [--latest-releases NUMBER_OF_LATEST_RELEASES]
53
- [--skip-prerelease] [--assets] [--attachments]
54
- [--exclude [REPOSITORY [REPOSITORY ...]]
53
+ [--skip-prerelease] [--assets] [--skip-assets-on [REPO ...]]
54
+ [--attachments] [--exclude [REPOSITORY [REPOSITORY ...]]
55
55
  [--throttle-limit THROTTLE_LIMIT] [--throttle-pause THROTTLE_PAUSE]
56
56
  USER
57
57
 
@@ -133,6 +133,9 @@ CLI Help output::
133
133
  --skip-prerelease skip prerelease and draft versions; only applies if including releases
134
134
  --assets include assets alongside release information; only
135
135
  applies if including releases
136
+ --skip-assets-on [REPO ...]
137
+ skip asset downloads for these repositories (e.g.
138
+ --skip-assets-on repo1 owner/repo2)
136
139
  --attachments download user-attachments from issues and pull requests
137
140
  to issues/attachments/{issue_number}/ and
138
141
  pulls/attachments/{pull_number}/ directories
@@ -174,6 +177,37 @@ Customise the permissions for your use case, but for a personal account full bac
174
177
  **Repository permissions**: Read access to contents, issues, metadata, pull requests, and webhooks.
175
178
 
176
179
 
180
+ GitHub Apps
181
+ ~~~~~~~~~~~
182
+
183
+ GitHub Apps are ideal for organization backups in CI/CD. Tokens are scoped to specific repositories and expire after 1 hour.
184
+
185
+ **One-time setup:**
186
+
187
+ 1. Create a GitHub App at *Settings -> Developer Settings -> GitHub Apps -> New GitHub App*
188
+ 2. Set a name and homepage URL (can be any URL)
189
+ 3. Uncheck "Webhook > Active" (not needed for backups)
190
+ 4. Set permissions (same as fine-grained tokens above)
191
+ 5. Click "Create GitHub App", then note the **App ID** shown on the next page
192
+ 6. Under "Private keys", click "Generate a private key" and save the downloaded file
193
+ 7. Go to *Install App* in your app's settings
194
+ 8. Select the account/organization and which repositories to back up
195
+
196
+ **CI/CD usage with GitHub Actions:**
197
+
198
+ Store the App ID as a repository variable and the private key contents as a secret, then use ``actions/create-github-app-token``::
199
+
200
+ - uses: actions/create-github-app-token@v1
201
+ id: app-token
202
+ with:
203
+ app-id: ${{ vars.APP_ID }}
204
+ private-key: ${{ secrets.APP_PRIVATE_KEY }}
205
+
206
+ - run: github-backup myorg -t ${{ steps.app-token.outputs.token }} --as-app -o ./backup --all
207
+
208
+ Note: Installation tokens expire after 1 hour. For long-running backups, use a fine-grained personal access token instead.
209
+
210
+
177
211
  Prefer SSH
178
212
  ~~~~~~~~~~
179
213
 
@@ -215,6 +249,8 @@ When you use the ``--lfs`` option, you will need to make sure you have Git LFS i
215
249
 
216
250
  Instructions on how to do this can be found on https://git-lfs.github.com.
217
251
 
252
+ LFS objects are fetched for all refs, not just the current checkout, ensuring a complete backup of all LFS content across all branches and history.
253
+
218
254
 
219
255
  About Attachments
220
256
  -----------------
@@ -359,6 +395,28 @@ Debug an error/block or incomplete backup into a temporary directory. Omit "incr
359
395
 
360
396
  github-backup -f $FINE_ACCESS_TOKEN -o /tmp/github-backup/ -l debug -P --all-starred --starred --watched --followers --following --issues --issue-comments --issue-events --pulls --pull-comments --pull-commits --labels --milestones --repositories --wikis --releases --assets --pull-details --gists --starred-gists $GH_USER
361
397
 
398
+ Pipe a token from stdin to avoid storing it in environment variables or command history (Unix-like systems only)::
399
+
400
+ my-secret-manager get github-token | github-backup user -t file:///dev/stdin -o /backup --repositories
401
+
402
+ Restoring from Backup
403
+ =====================
404
+
405
+ This tool creates backups only, there is no inbuilt restore command.
406
+
407
+ **Git repositories, wikis, and gists** can be restored by pushing them back to GitHub as you would any git repository. For example, to restore a bare repository backup::
408
+
409
+ cd /tmp/white-house/repositories/petitions/repository
410
+ git push --mirror git@github.com:WhiteHouse/petitions.git
411
+
412
+ **Issues, pull requests, comments, and other metadata** are saved as JSON files for archival purposes. The GitHub API does not support recreating this data faithfully, creating issues via the API has limitations:
413
+
414
+ - New issue/PR numbers are assigned (original numbers cannot be set)
415
+ - Timestamps reflect creation time (original dates cannot be set)
416
+ - The API caller becomes the author (original authors cannot be set)
417
+ - Cross-references between issues and PRs will break
418
+
419
+ These are GitHub API limitations that affect all backup and migration tools, not just this one. Recreating issues with these limitations via the GitHub API is an exercise for the reader. The JSON backups remain useful for searching, auditing, or manual reference.
362
420
 
363
421
 
364
422
  Development
@@ -50,8 +50,8 @@ CLI Help output::
50
50
  [--keychain-name OSX_KEYCHAIN_ITEM_NAME]
51
51
  [--keychain-account OSX_KEYCHAIN_ITEM_ACCOUNT]
52
52
  [--releases] [--latest-releases NUMBER_OF_LATEST_RELEASES]
53
- [--skip-prerelease] [--assets] [--attachments]
54
- [--exclude [REPOSITORY [REPOSITORY ...]]
53
+ [--skip-prerelease] [--assets] [--skip-assets-on [REPO ...]]
54
+ [--attachments] [--exclude [REPOSITORY [REPOSITORY ...]]
55
55
  [--throttle-limit THROTTLE_LIMIT] [--throttle-pause THROTTLE_PAUSE]
56
56
  USER
57
57
 
@@ -133,6 +133,9 @@ CLI Help output::
133
133
  --skip-prerelease skip prerelease and draft versions; only applies if including releases
134
134
  --assets include assets alongside release information; only
135
135
  applies if including releases
136
+ --skip-assets-on [REPO ...]
137
+ skip asset downloads for these repositories (e.g.
138
+ --skip-assets-on repo1 owner/repo2)
136
139
  --attachments download user-attachments from issues and pull requests
137
140
  to issues/attachments/{issue_number}/ and
138
141
  pulls/attachments/{pull_number}/ directories
@@ -174,6 +177,37 @@ Customise the permissions for your use case, but for a personal account full bac
174
177
  **Repository permissions**: Read access to contents, issues, metadata, pull requests, and webhooks.
175
178
 
176
179
 
180
+ GitHub Apps
181
+ ~~~~~~~~~~~
182
+
183
+ GitHub Apps are ideal for organization backups in CI/CD. Tokens are scoped to specific repositories and expire after 1 hour.
184
+
185
+ **One-time setup:**
186
+
187
+ 1. Create a GitHub App at *Settings -> Developer Settings -> GitHub Apps -> New GitHub App*
188
+ 2. Set a name and homepage URL (can be any URL)
189
+ 3. Uncheck "Webhook > Active" (not needed for backups)
190
+ 4. Set permissions (same as fine-grained tokens above)
191
+ 5. Click "Create GitHub App", then note the **App ID** shown on the next page
192
+ 6. Under "Private keys", click "Generate a private key" and save the downloaded file
193
+ 7. Go to *Install App* in your app's settings
194
+ 8. Select the account/organization and which repositories to back up
195
+
196
+ **CI/CD usage with GitHub Actions:**
197
+
198
+ Store the App ID as a repository variable and the private key contents as a secret, then use ``actions/create-github-app-token``::
199
+
200
+ - uses: actions/create-github-app-token@v1
201
+ id: app-token
202
+ with:
203
+ app-id: ${{ vars.APP_ID }}
204
+ private-key: ${{ secrets.APP_PRIVATE_KEY }}
205
+
206
+ - run: github-backup myorg -t ${{ steps.app-token.outputs.token }} --as-app -o ./backup --all
207
+
208
+ Note: Installation tokens expire after 1 hour. For long-running backups, use a fine-grained personal access token instead.
209
+
210
+
177
211
  Prefer SSH
178
212
  ~~~~~~~~~~
179
213
 
@@ -215,6 +249,8 @@ When you use the ``--lfs`` option, you will need to make sure you have Git LFS i
215
249
 
216
250
  Instructions on how to do this can be found on https://git-lfs.github.com.
217
251
 
252
+ LFS objects are fetched for all refs, not just the current checkout, ensuring a complete backup of all LFS content across all branches and history.
253
+
218
254
 
219
255
  About Attachments
220
256
  -----------------
@@ -359,6 +395,28 @@ Debug an error/block or incomplete backup into a temporary directory. Omit "incr
359
395
 
360
396
  github-backup -f $FINE_ACCESS_TOKEN -o /tmp/github-backup/ -l debug -P --all-starred --starred --watched --followers --following --issues --issue-comments --issue-events --pulls --pull-comments --pull-commits --labels --milestones --repositories --wikis --releases --assets --pull-details --gists --starred-gists $GH_USER
361
397
 
398
+ Pipe a token from stdin to avoid storing it in environment variables or command history (Unix-like systems only)::
399
+
400
+ my-secret-manager get github-token | github-backup user -t file:///dev/stdin -o /backup --repositories
401
+
402
+ Restoring from Backup
403
+ =====================
404
+
405
+ This tool creates backups only, there is no inbuilt restore command.
406
+
407
+ **Git repositories, wikis, and gists** can be restored by pushing them back to GitHub as you would any git repository. For example, to restore a bare repository backup::
408
+
409
+ cd /tmp/white-house/repositories/petitions/repository
410
+ git push --mirror git@github.com:WhiteHouse/petitions.git
411
+
412
+ **Issues, pull requests, comments, and other metadata** are saved as JSON files for archival purposes. The GitHub API does not support recreating this data faithfully, creating issues via the API has limitations:
413
+
414
+ - New issue/PR numbers are assigned (original numbers cannot be set)
415
+ - Timestamps reflect creation time (original dates cannot be set)
416
+ - The API caller becomes the author (original authors cannot be set)
417
+ - Cross-references between issues and PRs will break
418
+
419
+ These are GitHub API limitations that affect all backup and migration tools, not just this one. Recreating issues with these limitations via the GitHub API is an exercise for the reader. The JSON backups remain useful for searching, auditing, or manual reference.
362
420
 
363
421
 
364
422
  Development
@@ -0,0 +1 @@
1
+ __version__ = "0.57.0"
@@ -0,0 +1,13 @@
1
+ """Allow running as: python -m github_backup"""
2
+
3
+ import sys
4
+
5
+ from github_backup.cli import main
6
+ from github_backup.github_backup import logger
7
+
8
+ if __name__ == "__main__":
9
+ try:
10
+ main()
11
+ except Exception as e:
12
+ logger.error(str(e))
13
+ sys.exit(1)
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env python
2
+ """Command-line interface for github-backup."""
2
3
 
3
4
  import logging
4
5
  import os
@@ -36,6 +37,7 @@ logging.basicConfig(level=logging.INFO, handlers=[stdout_handler, stderr_handler
36
37
 
37
38
 
38
39
  def main():
40
+ """Main entry point for github-backup CLI."""
39
41
  args = parse_args()
40
42
 
41
43
  if args.private and not get_auth(args):
@@ -440,6 +440,12 @@ def parse_args(args=None):
440
440
  dest="include_assets",
441
441
  help="include assets alongside release information; only applies if including releases",
442
442
  )
443
+ parser.add_argument(
444
+ "--skip-assets-on",
445
+ dest="skip_assets_on",
446
+ nargs="*",
447
+ help="skip asset downloads for these repositories",
448
+ )
443
449
  parser.add_argument(
444
450
  "--attachments",
445
451
  action="store_true",
@@ -561,7 +567,7 @@ def get_github_host(args):
561
567
 
562
568
 
563
569
  def read_file_contents(file_uri):
564
- return open(file_uri[len(FILE_URI_PREFIX):], "rt").readline().strip()
570
+ return open(file_uri[len(FILE_URI_PREFIX) :], "rt").readline().strip()
565
571
 
566
572
 
567
573
  def get_github_repo_url(args, repository):
@@ -631,7 +637,7 @@ def retrieve_data_gen(args, template, query_args=None, single_request=False):
631
637
  pass
632
638
  raise RepositoryUnavailableError(
633
639
  "Repository unavailable due to legal reasons (HTTP 451)",
634
- dmca_url=dmca_url
640
+ dmca_url=dmca_url,
635
641
  )
636
642
 
637
643
  # Check if we got correct data
@@ -709,7 +715,7 @@ def retrieve_data_gen(args, template, query_args=None, single_request=False):
709
715
  # Parse Link header: <https://api.github.com/...?per_page=100&after=cursor>; rel="next"
710
716
  for link in link_header.split(","):
711
717
  if 'rel="next"' in link:
712
- next_url = link[link.find("<") + 1:link.find(">")]
718
+ next_url = link[link.find("<") + 1 : link.find(">")]
713
719
  break
714
720
  if not next_url:
715
721
  break
@@ -763,9 +769,7 @@ def _get_response(request, auth, template):
763
769
  return r, errors
764
770
 
765
771
 
766
- def _construct_request(
767
- per_page, query_args, template, auth, as_app=None, fine=False
768
- ):
772
+ def _construct_request(per_page, query_args, template, auth, as_app=None, fine=False):
769
773
  # If template is already a full URL with query params (from Link header), use it directly
770
774
  if "?" in template and template.startswith("http"):
771
775
  request_url = template
@@ -796,9 +800,6 @@ def _construct_request(
796
800
  else:
797
801
  auth = auth.encode("ascii")
798
802
  request.add_header("Authorization", "token ".encode("ascii") + auth)
799
- request.add_header(
800
- "Accept", "application/vnd.github.machine-man-preview+json"
801
- )
802
803
 
803
804
  log_url = template if "?" not in template else template.split("?")[0]
804
805
  if querystring:
@@ -1038,7 +1039,7 @@ def download_attachment_file(url, path, auth, as_app=False, fine=False):
1038
1039
  bytes_downloaded += len(chunk)
1039
1040
 
1040
1041
  # Atomic rename to final location
1041
- os.rename(temp_path, path)
1042
+ os.replace(temp_path, path)
1042
1043
 
1043
1044
  metadata["size_bytes"] = bytes_downloaded
1044
1045
  metadata["success"] = True
@@ -1459,7 +1460,7 @@ def download_attachments(
1459
1460
 
1460
1461
  # Rename to add extension (already atomic from download)
1461
1462
  try:
1462
- os.rename(filepath, final_filepath)
1463
+ os.replace(filepath, final_filepath)
1463
1464
  metadata["saved_as"] = os.path.basename(final_filepath)
1464
1465
  except Exception as e:
1465
1466
  logger.warning(
@@ -1480,9 +1481,11 @@ def download_attachments(
1480
1481
  manifest = {
1481
1482
  "issue_number": number,
1482
1483
  "issue_type": item_type,
1483
- "repository": f"{args.user}/{args.repository}"
1484
- if hasattr(args, "repository") and args.repository
1485
- else args.user,
1484
+ "repository": (
1485
+ f"{args.user}/{args.repository}"
1486
+ if hasattr(args, "repository") and args.repository
1487
+ else args.user
1488
+ ),
1486
1489
  "manifest_updated_at": datetime.now(timezone.utc).isoformat(),
1487
1490
  "attachments": attachment_metadata_list,
1488
1491
  }
@@ -1490,7 +1493,7 @@ def download_attachments(
1490
1493
  manifest_path = os.path.join(attachments_dir, "manifest.json")
1491
1494
  with open(manifest_path + ".temp", "w") as f:
1492
1495
  json.dump(manifest, f, indent=2)
1493
- os.rename(manifest_path + ".temp", manifest_path) # Atomic write
1496
+ os.replace(manifest_path + ".temp", manifest_path) # Atomic write
1494
1497
  logger.debug(
1495
1498
  "Wrote manifest for {0} #{1}: {2} attachments".format(
1496
1499
  item_type_display, number, len(attachment_metadata_list)
@@ -1538,9 +1541,7 @@ def retrieve_repositories(args, authenticated_user):
1538
1541
  else:
1539
1542
  repo_path = "{0}/{1}".format(args.user, args.repository)
1540
1543
  single_request = True
1541
- template = "https://{0}/repos/{1}".format(
1542
- get_github_api_host(args), repo_path
1543
- )
1544
+ template = "https://{0}/repos/{1}".format(get_github_api_host(args), repo_path)
1544
1545
 
1545
1546
  repos = retrieve_data(args, template, single_request=single_request)
1546
1547
 
@@ -1565,7 +1566,10 @@ def retrieve_repositories(args, authenticated_user):
1565
1566
  repos.extend(gists)
1566
1567
 
1567
1568
  if args.include_starred_gists:
1568
- if not authenticated_user.get("login") or args.user.lower() != authenticated_user["login"].lower():
1569
+ if (
1570
+ not authenticated_user.get("login")
1571
+ or args.user.lower() != authenticated_user["login"].lower()
1572
+ ):
1569
1573
  logger.warning(
1570
1574
  "Cannot retrieve starred gists for '%s'. GitHub only allows access to the authenticated user's starred gists.",
1571
1575
  args.user,
@@ -1673,9 +1677,11 @@ def backup_repositories(args, output_directory, repositories):
1673
1677
 
1674
1678
  include_gists = args.include_gists or args.include_starred_gists
1675
1679
  include_starred = args.all_starred and repository.get("is_starred")
1676
- if (args.include_repository or args.include_everything) or (
1677
- include_gists and repository.get("is_gist")
1678
- ) or include_starred:
1680
+ if (
1681
+ (args.include_repository or args.include_everything)
1682
+ or (include_gists and repository.get("is_gist"))
1683
+ or include_starred
1684
+ ):
1679
1685
  repo_name = (
1680
1686
  repository.get("name")
1681
1687
  if not repository.get("is_gist")
@@ -1735,7 +1741,9 @@ def backup_repositories(args, output_directory, repositories):
1735
1741
  include_assets=args.include_assets or args.include_everything,
1736
1742
  )
1737
1743
  except RepositoryUnavailableError as e:
1738
- logger.warning(f"Repository {repository['full_name']} is unavailable (HTTP 451)")
1744
+ logger.warning(
1745
+ f"Repository {repository['full_name']} is unavailable (HTTP 451)"
1746
+ )
1739
1747
  if e.dmca_url:
1740
1748
  logger.warning(f"DMCA notice: {e.dmca_url}")
1741
1749
  logger.info(f"Skipping remaining resources for {repository['full_name']}")
@@ -1795,7 +1803,11 @@ def backup_issues(args, repo_cwd, repository, repos_template):
1795
1803
  modified = os.path.getmtime(issue_file)
1796
1804
  modified = datetime.fromtimestamp(modified).strftime("%Y-%m-%dT%H:%M:%SZ")
1797
1805
  if modified > issue["updated_at"]:
1798
- logger.info("Skipping issue {0} because it wasn't modified since last backup".format(number))
1806
+ logger.info(
1807
+ "Skipping issue {0} because it wasn't modified since last backup".format(
1808
+ number
1809
+ )
1810
+ )
1799
1811
  continue
1800
1812
 
1801
1813
  if args.include_issue_comments or args.include_everything:
@@ -1811,7 +1823,7 @@ def backup_issues(args, repo_cwd, repository, repos_template):
1811
1823
 
1812
1824
  with codecs.open(issue_file + ".temp", "w", encoding="utf-8") as f:
1813
1825
  json_dump(issue, f)
1814
- os.rename(issue_file + ".temp", issue_file) # Unlike json_dump, this is atomic
1826
+ os.replace(issue_file + ".temp", issue_file) # Atomic write
1815
1827
 
1816
1828
 
1817
1829
  def backup_pulls(args, repo_cwd, repository, repos_template):
@@ -1869,7 +1881,11 @@ def backup_pulls(args, repo_cwd, repository, repos_template):
1869
1881
  modified = os.path.getmtime(pull_file)
1870
1882
  modified = datetime.fromtimestamp(modified).strftime("%Y-%m-%dT%H:%M:%SZ")
1871
1883
  if modified > pull["updated_at"]:
1872
- logger.info("Skipping pull request {0} because it wasn't modified since last backup".format(number))
1884
+ logger.info(
1885
+ "Skipping pull request {0} because it wasn't modified since last backup".format(
1886
+ number
1887
+ )
1888
+ )
1873
1889
  continue
1874
1890
  if args.include_pull_comments or args.include_everything:
1875
1891
  template = comments_regular_template.format(number)
@@ -1886,7 +1902,7 @@ def backup_pulls(args, repo_cwd, repository, repos_template):
1886
1902
 
1887
1903
  with codecs.open(pull_file + ".temp", "w", encoding="utf-8") as f:
1888
1904
  json_dump(pull, f)
1889
- os.rename(pull_file + ".temp", pull_file) # Unlike json_dump, this is atomic
1905
+ os.replace(pull_file + ".temp", pull_file) # Atomic write
1890
1906
 
1891
1907
 
1892
1908
  def backup_milestones(args, repo_cwd, repository, repos_template):
@@ -1919,9 +1935,11 @@ def backup_milestones(args, repo_cwd, repository, repos_template):
1919
1935
  elif written_count == 0:
1920
1936
  logger.info("{0} milestones unchanged, skipped write".format(total))
1921
1937
  else:
1922
- logger.info("Saved {0} of {1} milestones to disk ({2} unchanged)".format(
1923
- written_count, total, total - written_count
1924
- ))
1938
+ logger.info(
1939
+ "Saved {0} of {1} milestones to disk ({2} unchanged)".format(
1940
+ written_count, total, total - written_count
1941
+ )
1942
+ )
1925
1943
 
1926
1944
 
1927
1945
  def backup_labels(args, repo_cwd, repository, repos_template):
@@ -1975,6 +1993,20 @@ def backup_releases(args, repo_cwd, repository, repos_template, include_assets=F
1975
1993
  )
1976
1994
  releases = releases[: args.number_of_latest_releases]
1977
1995
 
1996
+ # Check if this repo should skip asset downloads (case-insensitive)
1997
+ skip_assets = False
1998
+ if include_assets:
1999
+ repo_name = repository.get("name", "").lower()
2000
+ repo_full_name = repository.get("full_name", "").lower()
2001
+ skip_repos = [r.lower() for r in (args.skip_assets_on or [])]
2002
+ skip_assets = repo_name in skip_repos or repo_full_name in skip_repos
2003
+ if skip_assets:
2004
+ logger.info(
2005
+ "Skipping assets for {0} ({1} releases) due to --skip-assets-on".format(
2006
+ repository.get("name"), len(releases)
2007
+ )
2008
+ )
2009
+
1978
2010
  # for each release, store it
1979
2011
  written_count = 0
1980
2012
  for release in releases:
@@ -1986,7 +2018,7 @@ def backup_releases(args, repo_cwd, repository, repos_template, include_assets=F
1986
2018
  if json_dump_if_changed(release, output_filepath):
1987
2019
  written_count += 1
1988
2020
 
1989
- if include_assets:
2021
+ if include_assets and not skip_assets:
1990
2022
  assets = retrieve_data(args, release["assets_url"])
1991
2023
  if len(assets) > 0:
1992
2024
  # give release asset files somewhere to live & download them (not including source archives)
@@ -2008,9 +2040,11 @@ def backup_releases(args, repo_cwd, repository, repos_template, include_assets=F
2008
2040
  elif written_count == 0:
2009
2041
  logger.info("{0} releases unchanged, skipped write".format(total))
2010
2042
  else:
2011
- logger.info("Saved {0} of {1} releases to disk ({2} unchanged)".format(
2012
- written_count, total, total - written_count
2013
- ))
2043
+ logger.info(
2044
+ "Saved {0} of {1} releases to disk ({2} unchanged)".format(
2045
+ written_count, total, total - written_count
2046
+ )
2047
+ )
2014
2048
 
2015
2049
 
2016
2050
  def fetch_repository(
@@ -2024,9 +2058,12 @@ def fetch_repository(
2024
2058
  ):
2025
2059
  if bare_clone:
2026
2060
  if os.path.exists(local_dir):
2027
- clone_exists = subprocess.check_output(
2028
- ["git", "rev-parse", "--is-bare-repository"], cwd=local_dir
2029
- ) == b"true\n"
2061
+ clone_exists = (
2062
+ subprocess.check_output(
2063
+ ["git", "rev-parse", "--is-bare-repository"], cwd=local_dir
2064
+ )
2065
+ == b"true\n"
2066
+ )
2030
2067
  else:
2031
2068
  clone_exists = False
2032
2069
  else:
@@ -2047,7 +2084,9 @@ def fetch_repository(
2047
2084
  )
2048
2085
  else:
2049
2086
  logger.info(
2050
- "Skipping {0} (repository not accessible - may be empty, private, or credentials invalid)".format(name)
2087
+ "Skipping {0} (repository not accessible - may be empty, private, or credentials invalid)".format(
2088
+ name
2089
+ )
2051
2090
  )
2052
2091
  return
2053
2092
 
@@ -2090,11 +2129,13 @@ def fetch_repository(
2090
2129
  git_command.pop()
2091
2130
  logging_subprocess(git_command, cwd=local_dir)
2092
2131
  else:
2093
- if lfs_clone:
2094
- git_command = ["git", "lfs", "clone", remote_url, local_dir]
2095
- else:
2096
- git_command = ["git", "clone", remote_url, local_dir]
2132
+ git_command = ["git", "clone", remote_url, local_dir]
2097
2133
  logging_subprocess(git_command)
2134
+ if lfs_clone:
2135
+ git_command = ["git", "lfs", "fetch", "--all", "--prune"]
2136
+ if no_prune:
2137
+ git_command.pop()
2138
+ logging_subprocess(git_command, cwd=local_dir)
2098
2139
 
2099
2140
 
2100
2141
  def backup_account(args, output_directory):
@@ -2203,5 +2244,5 @@ def json_dump_if_changed(data, output_file_path):
2203
2244
  temp_file = output_file_path + ".temp"
2204
2245
  with codecs.open(temp_file, "w", encoding="utf-8") as f:
2205
2246
  f.write(new_content)
2206
- os.rename(temp_file, output_file_path) # Atomic on POSIX systems
2247
+ os.replace(temp_file, output_file_path) # Atomic write
2207
2248
  return True
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.55.0
3
+ Version: 0.57.0
4
4
  Summary: backup a github user or organization
5
5
  Home-page: http://github.com/josegonzalez/python-github-backup
6
6
  Author: Jose Diaz-Gonzalez
@@ -80,8 +80,8 @@ CLI Help output::
80
80
  [--keychain-name OSX_KEYCHAIN_ITEM_NAME]
81
81
  [--keychain-account OSX_KEYCHAIN_ITEM_ACCOUNT]
82
82
  [--releases] [--latest-releases NUMBER_OF_LATEST_RELEASES]
83
- [--skip-prerelease] [--assets] [--attachments]
84
- [--exclude [REPOSITORY [REPOSITORY ...]]
83
+ [--skip-prerelease] [--assets] [--skip-assets-on [REPO ...]]
84
+ [--attachments] [--exclude [REPOSITORY [REPOSITORY ...]]
85
85
  [--throttle-limit THROTTLE_LIMIT] [--throttle-pause THROTTLE_PAUSE]
86
86
  USER
87
87
 
@@ -163,6 +163,9 @@ CLI Help output::
163
163
  --skip-prerelease skip prerelease and draft versions; only applies if including releases
164
164
  --assets include assets alongside release information; only
165
165
  applies if including releases
166
+ --skip-assets-on [REPO ...]
167
+ skip asset downloads for these repositories (e.g.
168
+ --skip-assets-on repo1 owner/repo2)
166
169
  --attachments download user-attachments from issues and pull requests
167
170
  to issues/attachments/{issue_number}/ and
168
171
  pulls/attachments/{pull_number}/ directories
@@ -204,6 +207,37 @@ Customise the permissions for your use case, but for a personal account full bac
204
207
  **Repository permissions**: Read access to contents, issues, metadata, pull requests, and webhooks.
205
208
 
206
209
 
210
+ GitHub Apps
211
+ ~~~~~~~~~~~
212
+
213
+ GitHub Apps are ideal for organization backups in CI/CD. Tokens are scoped to specific repositories and expire after 1 hour.
214
+
215
+ **One-time setup:**
216
+
217
+ 1. Create a GitHub App at *Settings -> Developer Settings -> GitHub Apps -> New GitHub App*
218
+ 2. Set a name and homepage URL (can be any URL)
219
+ 3. Uncheck "Webhook > Active" (not needed for backups)
220
+ 4. Set permissions (same as fine-grained tokens above)
221
+ 5. Click "Create GitHub App", then note the **App ID** shown on the next page
222
+ 6. Under "Private keys", click "Generate a private key" and save the downloaded file
223
+ 7. Go to *Install App* in your app's settings
224
+ 8. Select the account/organization and which repositories to back up
225
+
226
+ **CI/CD usage with GitHub Actions:**
227
+
228
+ Store the App ID as a repository variable and the private key contents as a secret, then use ``actions/create-github-app-token``::
229
+
230
+ - uses: actions/create-github-app-token@v1
231
+ id: app-token
232
+ with:
233
+ app-id: ${{ vars.APP_ID }}
234
+ private-key: ${{ secrets.APP_PRIVATE_KEY }}
235
+
236
+ - run: github-backup myorg -t ${{ steps.app-token.outputs.token }} --as-app -o ./backup --all
237
+
238
+ Note: Installation tokens expire after 1 hour. For long-running backups, use a fine-grained personal access token instead.
239
+
240
+
207
241
  Prefer SSH
208
242
  ~~~~~~~~~~
209
243
 
@@ -245,6 +279,8 @@ When you use the ``--lfs`` option, you will need to make sure you have Git LFS i
245
279
 
246
280
  Instructions on how to do this can be found on https://git-lfs.github.com.
247
281
 
282
+ LFS objects are fetched for all refs, not just the current checkout, ensuring a complete backup of all LFS content across all branches and history.
283
+
248
284
 
249
285
  About Attachments
250
286
  -----------------
@@ -389,6 +425,28 @@ Debug an error/block or incomplete backup into a temporary directory. Omit "incr
389
425
 
390
426
  github-backup -f $FINE_ACCESS_TOKEN -o /tmp/github-backup/ -l debug -P --all-starred --starred --watched --followers --following --issues --issue-comments --issue-events --pulls --pull-comments --pull-commits --labels --milestones --repositories --wikis --releases --assets --pull-details --gists --starred-gists $GH_USER
391
427
 
428
+ Pipe a token from stdin to avoid storing it in environment variables or command history (Unix-like systems only)::
429
+
430
+ my-secret-manager get github-token | github-backup user -t file:///dev/stdin -o /backup --repositories
431
+
432
+ Restoring from Backup
433
+ =====================
434
+
435
+ This tool creates backups only, there is no inbuilt restore command.
436
+
437
+ **Git repositories, wikis, and gists** can be restored by pushing them back to GitHub as you would any git repository. For example, to restore a bare repository backup::
438
+
439
+ cd /tmp/white-house/repositories/petitions/repository
440
+ git push --mirror git@github.com:WhiteHouse/petitions.git
441
+
442
+ **Issues, pull requests, comments, and other metadata** are saved as JSON files for archival purposes. The GitHub API does not support recreating this data faithfully, creating issues via the API has limitations:
443
+
444
+ - New issue/PR numbers are assigned (original numbers cannot be set)
445
+ - Timestamps reflect creation time (original dates cannot be set)
446
+ - The API caller becomes the author (original authors cannot be set)
447
+ - Cross-references between issues and PRs will break
448
+
449
+ These are GitHub API limitations that affect all backup and migration tools, not just this one. Recreating issues with these limitations via the GitHub API is an exercise for the reader. The JSON backups remain useful for searching, auditing, or manual reference.
392
450
 
393
451
 
394
452
  Development
@@ -6,12 +6,14 @@ README.rst
6
6
  release-requirements.txt
7
7
  requirements.txt
8
8
  setup.py
9
- bin/github-backup
10
9
  github_backup/__init__.py
10
+ github_backup/__main__.py
11
+ github_backup/cli.py
11
12
  github_backup/github_backup.py
12
13
  github_backup.egg-info/PKG-INFO
13
14
  github_backup.egg-info/SOURCES.txt
14
15
  github_backup.egg-info/dependency_links.txt
16
+ github_backup.egg-info/entry_points.txt
15
17
  github_backup.egg-info/top_level.txt
16
18
  github_backup.egg-info/zip-safe
17
19
  tests/test_all_starred.py
@@ -19,4 +21,5 @@ tests/test_attachments.py
19
21
  tests/test_case_sensitivity.py
20
22
  tests/test_http_451.py
21
23
  tests/test_json_dump_if_changed.py
22
- tests/test_pagination.py
24
+ tests/test_pagination.py
25
+ tests/test_skip_assets_on.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ github-backup = github_backup.cli:main
@@ -1,5 +1,5 @@
1
1
  autopep8==2.3.2
2
- black==25.11.0
2
+ black==25.12.0
3
3
  bleach==6.3.0
4
4
  certifi==2025.11.12
5
5
  charset-normalizer==3.4.4
@@ -8,7 +8,7 @@ colorama==0.4.6
8
8
  docutils==0.22.3
9
9
  flake8==7.3.0
10
10
  gitchangelog==3.0.4
11
- pytest==9.0.1
11
+ pytest==9.0.2
12
12
  idna==3.11
13
13
  importlib-metadata==8.7.0
14
14
  jaraco.classes==3.4.0
@@ -21,7 +21,7 @@ mypy-extensions==1.1.0
21
21
  packaging==25.0
22
22
  pathspec==0.12.1
23
23
  pkginfo==1.12.1.2
24
- platformdirs==4.5.0
24
+ platformdirs==4.5.1
25
25
  pycodestyle==2.14.0
26
26
  pyflakes==3.4.0
27
27
  Pygments==2.19.2
@@ -35,6 +35,6 @@ setuptools==80.9.0
35
35
  six==1.17.0
36
36
  tqdm==4.67.1
37
37
  twine==6.2.0
38
- urllib3==2.6.0
38
+ urllib3==2.6.1
39
39
  webencodings==0.5.1
40
40
  zipp==3.23.0
@@ -33,7 +33,11 @@ setup(
33
33
  author="Jose Diaz-Gonzalez",
34
34
  author_email="github-backup@josediazgonzalez.com",
35
35
  packages=["github_backup"],
36
- scripts=["bin/github-backup"],
36
+ entry_points={
37
+ "console_scripts": [
38
+ "github-backup=github_backup.cli:main",
39
+ ],
40
+ },
37
41
  url="http://github.com/josegonzalez/python-github-backup",
38
42
  license="MIT",
39
43
  classifiers=[
@@ -0,0 +1,320 @@
1
+ """Tests for --skip-assets-on flag behavior (issue #135)."""
2
+
3
+ import pytest
4
+ from unittest.mock import Mock, patch
5
+
6
+ from github_backup import github_backup
7
+
8
+
9
+ class TestSkipAssetsOn:
10
+ """Test suite for --skip-assets-on flag.
11
+
12
+ Issue #135: Allow skipping asset downloads for specific repositories
13
+ while still backing up release metadata.
14
+ """
15
+
16
+ def _create_mock_args(self, **overrides):
17
+ """Create a mock args object with sensible defaults."""
18
+ args = Mock()
19
+ args.user = "testuser"
20
+ args.output_directory = "/tmp/backup"
21
+ args.include_repository = False
22
+ args.include_everything = False
23
+ args.include_gists = False
24
+ args.include_starred_gists = False
25
+ args.all_starred = False
26
+ args.skip_existing = False
27
+ args.bare_clone = False
28
+ args.lfs_clone = False
29
+ args.no_prune = False
30
+ args.include_wiki = False
31
+ args.include_issues = False
32
+ args.include_issue_comments = False
33
+ args.include_issue_events = False
34
+ args.include_pulls = False
35
+ args.include_pull_comments = False
36
+ args.include_pull_commits = False
37
+ args.include_pull_details = False
38
+ args.include_labels = False
39
+ args.include_hooks = False
40
+ args.include_milestones = False
41
+ args.include_releases = True
42
+ args.include_assets = True
43
+ args.skip_assets_on = []
44
+ args.include_attachments = False
45
+ args.incremental = False
46
+ args.incremental_by_files = False
47
+ args.github_host = None
48
+ args.prefer_ssh = False
49
+ args.token_classic = "test-token"
50
+ args.token_fine = None
51
+ args.username = None
52
+ args.password = None
53
+ args.as_app = False
54
+ args.osx_keychain_item_name = None
55
+ args.osx_keychain_item_account = None
56
+ args.skip_prerelease = False
57
+ args.number_of_latest_releases = None
58
+
59
+ for key, value in overrides.items():
60
+ setattr(args, key, value)
61
+
62
+ return args
63
+
64
+ def _create_mock_repository(self, name="test-repo", owner="testuser"):
65
+ """Create a mock repository object."""
66
+ return {
67
+ "name": name,
68
+ "full_name": f"{owner}/{name}",
69
+ "owner": {"login": owner},
70
+ "private": False,
71
+ "fork": False,
72
+ "has_wiki": False,
73
+ }
74
+
75
+ def _create_mock_release(self, tag="v1.0.0"):
76
+ """Create a mock release object."""
77
+ return {
78
+ "tag_name": tag,
79
+ "name": tag,
80
+ "prerelease": False,
81
+ "draft": False,
82
+ "assets_url": f"https://api.github.com/repos/testuser/test-repo/releases/{tag}/assets",
83
+ }
84
+
85
+ def _create_mock_asset(self, name="asset.zip"):
86
+ """Create a mock asset object."""
87
+ return {
88
+ "name": name,
89
+ "url": f"https://api.github.com/repos/testuser/test-repo/releases/assets/{name}",
90
+ }
91
+
92
+
93
+ class TestSkipAssetsOnArgumentParsing(TestSkipAssetsOn):
94
+ """Tests for --skip-assets-on argument parsing."""
95
+
96
+ def test_skip_assets_on_not_set_defaults_to_none(self):
97
+ """When --skip-assets-on is not specified, it should default to None."""
98
+ args = github_backup.parse_args(["testuser"])
99
+ assert args.skip_assets_on is None
100
+
101
+ def test_skip_assets_on_single_repo(self):
102
+ """Single --skip-assets-on should create list with one item."""
103
+ args = github_backup.parse_args(["testuser", "--skip-assets-on", "big-repo"])
104
+ assert args.skip_assets_on == ["big-repo"]
105
+
106
+ def test_skip_assets_on_multiple_repos(self):
107
+ """Multiple repos can be specified space-separated (like --exclude)."""
108
+ args = github_backup.parse_args(
109
+ [
110
+ "testuser",
111
+ "--skip-assets-on",
112
+ "big-repo",
113
+ "another-repo",
114
+ "owner/third-repo",
115
+ ]
116
+ )
117
+ assert args.skip_assets_on == ["big-repo", "another-repo", "owner/third-repo"]
118
+
119
+
120
+ class TestSkipAssetsOnBehavior(TestSkipAssetsOn):
121
+ """Tests for --skip-assets-on behavior in backup_releases."""
122
+
123
+ @patch("github_backup.github_backup.download_file")
124
+ @patch("github_backup.github_backup.retrieve_data")
125
+ @patch("github_backup.github_backup.mkdir_p")
126
+ @patch("github_backup.github_backup.json_dump_if_changed")
127
+ def test_assets_downloaded_when_not_skipped(
128
+ self, mock_json_dump, mock_mkdir, mock_retrieve, mock_download
129
+ ):
130
+ """Assets should be downloaded when repo is not in skip list."""
131
+ args = self._create_mock_args(skip_assets_on=[])
132
+ repository = self._create_mock_repository(name="normal-repo")
133
+ release = self._create_mock_release()
134
+ asset = self._create_mock_asset()
135
+
136
+ mock_json_dump.return_value = True
137
+ mock_retrieve.side_effect = [
138
+ [release], # First call: get releases
139
+ [asset], # Second call: get assets
140
+ ]
141
+
142
+ with patch("os.path.join", side_effect=lambda *args: "/".join(args)):
143
+ github_backup.backup_releases(
144
+ args,
145
+ "/tmp/backup/repositories/normal-repo",
146
+ repository,
147
+ "https://api.github.com/repos/{owner}/{repo}",
148
+ include_assets=True,
149
+ )
150
+
151
+ # download_file should have been called for the asset
152
+ mock_download.assert_called_once()
153
+
154
+ @patch("github_backup.github_backup.download_file")
155
+ @patch("github_backup.github_backup.retrieve_data")
156
+ @patch("github_backup.github_backup.mkdir_p")
157
+ @patch("github_backup.github_backup.json_dump_if_changed")
158
+ def test_assets_skipped_when_repo_name_matches(
159
+ self, mock_json_dump, mock_mkdir, mock_retrieve, mock_download
160
+ ):
161
+ """Assets should be skipped when repo name is in skip list."""
162
+ args = self._create_mock_args(skip_assets_on=["big-repo"])
163
+ repository = self._create_mock_repository(name="big-repo")
164
+ release = self._create_mock_release()
165
+
166
+ mock_json_dump.return_value = True
167
+ mock_retrieve.return_value = [release]
168
+
169
+ github_backup.backup_releases(
170
+ args,
171
+ "/tmp/backup/repositories/big-repo",
172
+ repository,
173
+ "https://api.github.com/repos/{owner}/{repo}",
174
+ include_assets=True,
175
+ )
176
+
177
+ # download_file should NOT have been called
178
+ mock_download.assert_not_called()
179
+
180
+ @patch("github_backup.github_backup.download_file")
181
+ @patch("github_backup.github_backup.retrieve_data")
182
+ @patch("github_backup.github_backup.mkdir_p")
183
+ @patch("github_backup.github_backup.json_dump_if_changed")
184
+ def test_assets_skipped_when_full_name_matches(
185
+ self, mock_json_dump, mock_mkdir, mock_retrieve, mock_download
186
+ ):
187
+ """Assets should be skipped when owner/repo format matches."""
188
+ args = self._create_mock_args(skip_assets_on=["otheruser/big-repo"])
189
+ repository = self._create_mock_repository(name="big-repo", owner="otheruser")
190
+ release = self._create_mock_release()
191
+
192
+ mock_json_dump.return_value = True
193
+ mock_retrieve.return_value = [release]
194
+
195
+ github_backup.backup_releases(
196
+ args,
197
+ "/tmp/backup/repositories/big-repo",
198
+ repository,
199
+ "https://api.github.com/repos/{owner}/{repo}",
200
+ include_assets=True,
201
+ )
202
+
203
+ # download_file should NOT have been called
204
+ mock_download.assert_not_called()
205
+
206
+ @patch("github_backup.github_backup.download_file")
207
+ @patch("github_backup.github_backup.retrieve_data")
208
+ @patch("github_backup.github_backup.mkdir_p")
209
+ @patch("github_backup.github_backup.json_dump_if_changed")
210
+ def test_case_insensitive_matching(
211
+ self, mock_json_dump, mock_mkdir, mock_retrieve, mock_download
212
+ ):
213
+ """Skip matching should be case-insensitive."""
214
+ # User types uppercase, repo name is lowercase
215
+ args = self._create_mock_args(skip_assets_on=["BIG-REPO"])
216
+ repository = self._create_mock_repository(name="big-repo")
217
+ release = self._create_mock_release()
218
+
219
+ mock_json_dump.return_value = True
220
+ mock_retrieve.return_value = [release]
221
+
222
+ github_backup.backup_releases(
223
+ args,
224
+ "/tmp/backup/repositories/big-repo",
225
+ repository,
226
+ "https://api.github.com/repos/{owner}/{repo}",
227
+ include_assets=True,
228
+ )
229
+
230
+ # download_file should NOT have been called (case-insensitive match)
231
+ assert not mock_download.called
232
+
233
+ @patch("github_backup.github_backup.download_file")
234
+ @patch("github_backup.github_backup.retrieve_data")
235
+ @patch("github_backup.github_backup.mkdir_p")
236
+ @patch("github_backup.github_backup.json_dump_if_changed")
237
+ def test_multiple_skip_repos(
238
+ self, mock_json_dump, mock_mkdir, mock_retrieve, mock_download
239
+ ):
240
+ """Multiple repos in skip list should all be skipped."""
241
+ args = self._create_mock_args(skip_assets_on=["repo1", "repo2", "repo3"])
242
+ repository = self._create_mock_repository(name="repo2")
243
+ release = self._create_mock_release()
244
+
245
+ mock_json_dump.return_value = True
246
+ mock_retrieve.return_value = [release]
247
+
248
+ github_backup.backup_releases(
249
+ args,
250
+ "/tmp/backup/repositories/repo2",
251
+ repository,
252
+ "https://api.github.com/repos/{owner}/{repo}",
253
+ include_assets=True,
254
+ )
255
+
256
+ # download_file should NOT have been called
257
+ mock_download.assert_not_called()
258
+
259
+ @patch("github_backup.github_backup.download_file")
260
+ @patch("github_backup.github_backup.retrieve_data")
261
+ @patch("github_backup.github_backup.mkdir_p")
262
+ @patch("github_backup.github_backup.json_dump_if_changed")
263
+ def test_release_metadata_still_saved_when_assets_skipped(
264
+ self, mock_json_dump, mock_mkdir, mock_retrieve, mock_download
265
+ ):
266
+ """Release JSON should still be saved even when assets are skipped."""
267
+ args = self._create_mock_args(skip_assets_on=["big-repo"])
268
+ repository = self._create_mock_repository(name="big-repo")
269
+ release = self._create_mock_release()
270
+
271
+ mock_json_dump.return_value = True
272
+ mock_retrieve.return_value = [release]
273
+
274
+ github_backup.backup_releases(
275
+ args,
276
+ "/tmp/backup/repositories/big-repo",
277
+ repository,
278
+ "https://api.github.com/repos/{owner}/{repo}",
279
+ include_assets=True,
280
+ )
281
+
282
+ # json_dump_if_changed should have been called for release metadata
283
+ mock_json_dump.assert_called_once()
284
+ # But download_file should NOT have been called
285
+ mock_download.assert_not_called()
286
+
287
+ @patch("github_backup.github_backup.download_file")
288
+ @patch("github_backup.github_backup.retrieve_data")
289
+ @patch("github_backup.github_backup.mkdir_p")
290
+ @patch("github_backup.github_backup.json_dump_if_changed")
291
+ def test_non_matching_repo_still_downloads_assets(
292
+ self, mock_json_dump, mock_mkdir, mock_retrieve, mock_download
293
+ ):
294
+ """Repos not in skip list should still download assets."""
295
+ args = self._create_mock_args(skip_assets_on=["other-repo"])
296
+ repository = self._create_mock_repository(name="normal-repo")
297
+ release = self._create_mock_release()
298
+ asset = self._create_mock_asset()
299
+
300
+ mock_json_dump.return_value = True
301
+ mock_retrieve.side_effect = [
302
+ [release], # First call: get releases
303
+ [asset], # Second call: get assets
304
+ ]
305
+
306
+ with patch("os.path.join", side_effect=lambda *args: "/".join(args)):
307
+ github_backup.backup_releases(
308
+ args,
309
+ "/tmp/backup/repositories/normal-repo",
310
+ repository,
311
+ "https://api.github.com/repos/{owner}/{repo}",
312
+ include_assets=True,
313
+ )
314
+
315
+ # download_file SHOULD have been called
316
+ mock_download.assert_called_once()
317
+
318
+
319
+ if __name__ == "__main__":
320
+ pytest.main([__file__, "-v"])
@@ -1 +0,0 @@
1
- __version__ = "0.55.0"
File without changes