github-backup 0.54.0__tar.gz → 0.56.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 (27) hide show
  1. {github_backup-0.54.0 → github_backup-0.56.0}/CHANGES.rst +134 -1
  2. {github_backup-0.54.0/github_backup.egg-info → github_backup-0.56.0}/PKG-INFO +27 -4
  3. {github_backup-0.54.0 → github_backup-0.56.0}/README +26 -3
  4. {github_backup-0.54.0 → github_backup-0.56.0}/README.rst +26 -3
  5. github_backup-0.56.0/github_backup/__init__.py +1 -0
  6. github_backup-0.56.0/github_backup/__main__.py +13 -0
  7. github_backup-0.54.0/bin/github-backup → github_backup-0.56.0/github_backup/cli.py +9 -0
  8. {github_backup-0.54.0 → github_backup-0.56.0}/github_backup/github_backup.py +41 -32
  9. {github_backup-0.54.0 → github_backup-0.56.0/github_backup.egg-info}/PKG-INFO +27 -4
  10. {github_backup-0.54.0 → github_backup-0.56.0}/github_backup.egg-info/SOURCES.txt +5 -1
  11. github_backup-0.56.0/github_backup.egg-info/entry_points.txt +2 -0
  12. {github_backup-0.54.0 → github_backup-0.56.0}/release-requirements.txt +4 -4
  13. {github_backup-0.54.0 → github_backup-0.56.0}/setup.py +5 -1
  14. github_backup-0.56.0/tests/test_all_starred.py +161 -0
  15. github_backup-0.56.0/tests/test_case_sensitivity.py +112 -0
  16. github_backup-0.54.0/github_backup/__init__.py +0 -1
  17. {github_backup-0.54.0 → github_backup-0.56.0}/LICENSE.txt +0 -0
  18. {github_backup-0.54.0 → github_backup-0.56.0}/MANIFEST.in +0 -0
  19. {github_backup-0.54.0 → github_backup-0.56.0}/github_backup.egg-info/dependency_links.txt +0 -0
  20. {github_backup-0.54.0 → github_backup-0.56.0}/github_backup.egg-info/top_level.txt +0 -0
  21. {github_backup-0.54.0 → github_backup-0.56.0}/github_backup.egg-info/zip-safe +0 -0
  22. {github_backup-0.54.0 → github_backup-0.56.0}/requirements.txt +0 -0
  23. {github_backup-0.54.0 → github_backup-0.56.0}/setup.cfg +0 -0
  24. {github_backup-0.54.0 → github_backup-0.56.0}/tests/test_attachments.py +0 -0
  25. {github_backup-0.54.0 → github_backup-0.56.0}/tests/test_http_451.py +0 -0
  26. {github_backup-0.54.0 → github_backup-0.56.0}/tests/test_json_dump_if_changed.py +0 -0
  27. {github_backup-0.54.0 → github_backup-0.56.0}/tests/test_pagination.py +0 -0
@@ -1,10 +1,143 @@
1
1
  Changelog
2
2
  =========
3
3
 
4
- 0.54.0 (2025-12-03)
4
+ 0.56.0 (2025-12-11)
5
5
  -------------------
6
6
  ------------------------
7
7
 
8
+ Fix
9
+ ~~~
10
+ - Replace deprecated git lfs clone with git clone + git lfs fetch --all.
11
+ [Rodos]
12
+
13
+ git lfs clone is deprecated - modern git clone handles LFS automatically.
14
+ Using git lfs fetch --all ensures all LFS objects across all refs are
15
+ backed up, matching the existing bare clone behavior and providing
16
+ complete LFS backups.
17
+
18
+ Closes #379
19
+ - Add Windows support with entry_points and os.replace. [Rodos]
20
+
21
+ - Replace os.rename() with os.replace() for atomic file operations
22
+ on Windows (os.rename fails if destination exists on Windows)
23
+ - Add entry_points console_scripts for proper .exe generation on Windows
24
+ - Create github_backup/cli.py with main() entry point
25
+ - Add github_backup/__main__.py for python -m github_backup support
26
+ - Keep bin/github-backup as thin wrapper for backwards compatibility
27
+
28
+ Closes #112
29
+
30
+ Other
31
+ ~~~~~
32
+ - Docs: add "Restoring from Backup" section to README. [Rodos]
33
+
34
+ Clarifies that this tool is backup-only with no inbuilt restore.
35
+ Documents that git repos can be pushed back, but issues/PRs have
36
+ GitHub API limitations affecting all backup tools.
37
+
38
+ Closes #246
39
+ - Chore(deps): bump urllib3 in the python-packages group.
40
+ [dependabot[bot]]
41
+
42
+ Bumps the python-packages group with 1 update: [urllib3](https://github.com/urllib3/urllib3).
43
+
44
+
45
+ Updates `urllib3` from 2.6.0 to 2.6.1
46
+ - [Release notes](https://github.com/urllib3/urllib3/releases)
47
+ - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
48
+ - [Commits](https://github.com/urllib3/urllib3/compare/2.6.0...2.6.1)
49
+
50
+ ---
51
+ updated-dependencies:
52
+ - dependency-name: urllib3
53
+ dependency-version: 2.6.1
54
+ dependency-type: direct:production
55
+ update-type: version-update:semver-patch
56
+ dependency-group: python-packages
57
+ ...
58
+ - Chore(deps): bump the python-packages group with 3 updates.
59
+ [dependabot[bot]]
60
+
61
+ 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).
62
+
63
+
64
+ Updates `black` from 25.11.0 to 25.12.0
65
+ - [Release notes](https://github.com/psf/black/releases)
66
+ - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md)
67
+ - [Commits](https://github.com/psf/black/compare/25.11.0...25.12.0)
68
+
69
+ Updates `pytest` from 9.0.1 to 9.0.2
70
+ - [Release notes](https://github.com/pytest-dev/pytest/releases)
71
+ - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
72
+ - [Commits](https://github.com/pytest-dev/pytest/compare/9.0.1...9.0.2)
73
+
74
+ Updates `platformdirs` from 4.5.0 to 4.5.1
75
+ - [Release notes](https://github.com/tox-dev/platformdirs/releases)
76
+ - [Changelog](https://github.com/tox-dev/platformdirs/blob/main/CHANGES.rst)
77
+ - [Commits](https://github.com/tox-dev/platformdirs/compare/4.5.0...4.5.1)
78
+
79
+ ---
80
+ updated-dependencies:
81
+ - dependency-name: black
82
+ dependency-version: 25.12.0
83
+ dependency-type: direct:production
84
+ update-type: version-update:semver-minor
85
+ dependency-group: python-packages
86
+ - dependency-name: pytest
87
+ dependency-version: 9.0.2
88
+ dependency-type: direct:production
89
+ update-type: version-update:semver-patch
90
+ dependency-group: python-packages
91
+ - dependency-name: platformdirs
92
+ dependency-version: 4.5.1
93
+ dependency-type: direct:production
94
+ update-type: version-update:semver-patch
95
+ dependency-group: python-packages
96
+ ...
97
+
98
+
99
+ 0.55.0 (2025-12-07)
100
+ -------------------
101
+
102
+ Fix
103
+ ~~~
104
+ - Improve error messages for inaccessible repos and empty wikis. [Rodos]
105
+ - --all-starred now clones repos without --repositories. [Rodos]
106
+ - Warn when --private used without authentication. [Rodos]
107
+ - Warn and skip when --starred-gists used for different user. [Rodos]
108
+
109
+ GitHub's API only allows retrieving starred gists for the authenticated
110
+ user. Previously, using --starred-gists when backing up a different user
111
+ would silently return no relevant data.
112
+
113
+ Now warns and skips the retrieval entirely when the target user differs
114
+ from the authenticated user. Uses case-insensitive comparison to match
115
+ GitHub's username handling.
116
+
117
+ Fixes #93
118
+
119
+ Other
120
+ ~~~~~
121
+ - Test: add missing test coverage for case sensitivity fix. [Rodos]
122
+ - Docs: fix RST formatting in Known blocking errors section. [Rodos]
123
+ - Chore(deps): bump urllib3 from 2.5.0 to 2.6.0. [dependabot[bot]]
124
+
125
+ Bumps [urllib3](https://github.com/urllib3/urllib3) from 2.5.0 to 2.6.0.
126
+ - [Release notes](https://github.com/urllib3/urllib3/releases)
127
+ - [Changelog](https://github.com/urllib3/urllib3/blob/main/CHANGES.rst)
128
+ - [Commits](https://github.com/urllib3/urllib3/compare/2.5.0...2.6.0)
129
+
130
+ ---
131
+ updated-dependencies:
132
+ - dependency-name: urllib3
133
+ dependency-version: 2.6.0
134
+ dependency-type: direct:production
135
+ ...
136
+
137
+
138
+ 0.54.0 (2025-12-03)
139
+ -------------------
140
+
8
141
  Fix
9
142
  ~~~
10
143
  - Send INFO/DEBUG to stdout, WARNING/ERROR to stderr. [Rodos]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.54.0
3
+ Version: 0.56.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
@@ -245,6 +245,8 @@ When you use the ``--lfs`` option, you will need to make sure you have Git LFS i
245
245
 
246
246
  Instructions on how to do this can be found on https://git-lfs.github.com.
247
247
 
248
+ 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.
249
+
248
250
 
249
251
  About Attachments
250
252
  -----------------
@@ -311,11 +313,11 @@ If the incremental argument is used, this will result in the next backup only re
311
313
 
312
314
  It's therefore recommended to only use the incremental argument if the output/result is being actively monitored, or complimented with periodic full non-incremental runs, to avoid unexpected missing data in a regular backup runs.
313
315
 
314
- 1. **Starred public repo hooks blocking**
316
+ **Starred public repo hooks blocking**
315
317
 
316
- Since the ``--all`` argument includes ``--hooks``, if you use ``--all`` and ``--all-starred`` together to clone a users starred public repositories, the backup will likely error and block the backup continuing.
318
+ Since the ``--all`` argument includes ``--hooks``, if you use ``--all`` and ``--all-starred`` together to clone a users starred public repositories, the backup will likely error and block the backup continuing.
317
319
 
318
- This is due to needing the correct permission for ``--hooks`` on public repos.
320
+ This is due to needing the correct permission for ``--hooks`` on public repos.
319
321
 
320
322
 
321
323
  "bare" is actually "mirror"
@@ -331,6 +333,8 @@ Starred gists vs starred repo behaviour
331
333
 
332
334
  The starred normal repo cloning (``--all-starred``) argument stores starred repos separately to the users own repositories. However, using ``--starred-gists`` will store starred gists within the same directory as the users own gists ``--gists``. Also, all gist repo directory names are IDs not the gist's name.
333
335
 
336
+ Note: ``--starred-gists`` only retrieves starred gists for the authenticated user, not the target user, due to a GitHub API limitation.
337
+
334
338
 
335
339
  Skip existing on incomplete backups
336
340
  -----------------------------------
@@ -388,6 +392,25 @@ Debug an error/block or incomplete backup into a temporary directory. Omit "incr
388
392
  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
389
393
 
390
394
 
395
+ Restoring from Backup
396
+ =====================
397
+
398
+ This tool creates backups only, there is no inbuilt restore command.
399
+
400
+ **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::
401
+
402
+ cd /tmp/white-house/repositories/petitions/repository
403
+ git push --mirror git@github.com:WhiteHouse/petitions.git
404
+
405
+ **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:
406
+
407
+ - New issue/PR numbers are assigned (original numbers cannot be set)
408
+ - Timestamps reflect creation time (original dates cannot be set)
409
+ - The API caller becomes the author (original authors cannot be set)
410
+ - Cross-references between issues and PRs will break
411
+
412
+ 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.
413
+
391
414
 
392
415
  Development
393
416
  ===========
@@ -215,6 +215,8 @@ When you use the ``--lfs`` option, you will need to make sure you have Git LFS i
215
215
 
216
216
  Instructions on how to do this can be found on https://git-lfs.github.com.
217
217
 
218
+ 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.
219
+
218
220
 
219
221
  About Attachments
220
222
  -----------------
@@ -281,11 +283,11 @@ If the incremental argument is used, this will result in the next backup only re
281
283
 
282
284
  It's therefore recommended to only use the incremental argument if the output/result is being actively monitored, or complimented with periodic full non-incremental runs, to avoid unexpected missing data in a regular backup runs.
283
285
 
284
- 1. **Starred public repo hooks blocking**
286
+ **Starred public repo hooks blocking**
285
287
 
286
- Since the ``--all`` argument includes ``--hooks``, if you use ``--all`` and ``--all-starred`` together to clone a users starred public repositories, the backup will likely error and block the backup continuing.
288
+ Since the ``--all`` argument includes ``--hooks``, if you use ``--all`` and ``--all-starred`` together to clone a users starred public repositories, the backup will likely error and block the backup continuing.
287
289
 
288
- This is due to needing the correct permission for ``--hooks`` on public repos.
290
+ This is due to needing the correct permission for ``--hooks`` on public repos.
289
291
 
290
292
 
291
293
  "bare" is actually "mirror"
@@ -301,6 +303,8 @@ Starred gists vs starred repo behaviour
301
303
 
302
304
  The starred normal repo cloning (``--all-starred``) argument stores starred repos separately to the users own repositories. However, using ``--starred-gists`` will store starred gists within the same directory as the users own gists ``--gists``. Also, all gist repo directory names are IDs not the gist's name.
303
305
 
306
+ Note: ``--starred-gists`` only retrieves starred gists for the authenticated user, not the target user, due to a GitHub API limitation.
307
+
304
308
 
305
309
  Skip existing on incomplete backups
306
310
  -----------------------------------
@@ -358,6 +362,25 @@ Debug an error/block or incomplete backup into a temporary directory. Omit "incr
358
362
  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
359
363
 
360
364
 
365
+ Restoring from Backup
366
+ =====================
367
+
368
+ This tool creates backups only, there is no inbuilt restore command.
369
+
370
+ **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::
371
+
372
+ cd /tmp/white-house/repositories/petitions/repository
373
+ git push --mirror git@github.com:WhiteHouse/petitions.git
374
+
375
+ **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:
376
+
377
+ - New issue/PR numbers are assigned (original numbers cannot be set)
378
+ - Timestamps reflect creation time (original dates cannot be set)
379
+ - The API caller becomes the author (original authors cannot be set)
380
+ - Cross-references between issues and PRs will break
381
+
382
+ 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.
383
+
361
384
 
362
385
  Development
363
386
  ===========
@@ -215,6 +215,8 @@ When you use the ``--lfs`` option, you will need to make sure you have Git LFS i
215
215
 
216
216
  Instructions on how to do this can be found on https://git-lfs.github.com.
217
217
 
218
+ 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.
219
+
218
220
 
219
221
  About Attachments
220
222
  -----------------
@@ -281,11 +283,11 @@ If the incremental argument is used, this will result in the next backup only re
281
283
 
282
284
  It's therefore recommended to only use the incremental argument if the output/result is being actively monitored, or complimented with periodic full non-incremental runs, to avoid unexpected missing data in a regular backup runs.
283
285
 
284
- 1. **Starred public repo hooks blocking**
286
+ **Starred public repo hooks blocking**
285
287
 
286
- Since the ``--all`` argument includes ``--hooks``, if you use ``--all`` and ``--all-starred`` together to clone a users starred public repositories, the backup will likely error and block the backup continuing.
288
+ Since the ``--all`` argument includes ``--hooks``, if you use ``--all`` and ``--all-starred`` together to clone a users starred public repositories, the backup will likely error and block the backup continuing.
287
289
 
288
- This is due to needing the correct permission for ``--hooks`` on public repos.
290
+ This is due to needing the correct permission for ``--hooks`` on public repos.
289
291
 
290
292
 
291
293
  "bare" is actually "mirror"
@@ -301,6 +303,8 @@ Starred gists vs starred repo behaviour
301
303
 
302
304
  The starred normal repo cloning (``--all-starred``) argument stores starred repos separately to the users own repositories. However, using ``--starred-gists`` will store starred gists within the same directory as the users own gists ``--gists``. Also, all gist repo directory names are IDs not the gist's name.
303
305
 
306
+ Note: ``--starred-gists`` only retrieves starred gists for the authenticated user, not the target user, due to a GitHub API limitation.
307
+
304
308
 
305
309
  Skip existing on incomplete backups
306
310
  -----------------------------------
@@ -358,6 +362,25 @@ Debug an error/block or incomplete backup into a temporary directory. Omit "incr
358
362
  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
359
363
 
360
364
 
365
+ Restoring from Backup
366
+ =====================
367
+
368
+ This tool creates backups only, there is no inbuilt restore command.
369
+
370
+ **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::
371
+
372
+ cd /tmp/white-house/repositories/petitions/repository
373
+ git push --mirror git@github.com:WhiteHouse/petitions.git
374
+
375
+ **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:
376
+
377
+ - New issue/PR numbers are assigned (original numbers cannot be set)
378
+ - Timestamps reflect creation time (original dates cannot be set)
379
+ - The API caller becomes the author (original authors cannot be set)
380
+ - Cross-references between issues and PRs will break
381
+
382
+ 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.
383
+
361
384
 
362
385
  Development
363
386
  ===========
@@ -0,0 +1 @@
1
+ __version__ = "0.56.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
@@ -9,6 +10,7 @@ from github_backup.github_backup import (
9
10
  backup_repositories,
10
11
  check_git_lfs_install,
11
12
  filter_repositories,
13
+ get_auth,
12
14
  get_authenticated_user,
13
15
  logger,
14
16
  mkdir_p,
@@ -35,8 +37,15 @@ logging.basicConfig(level=logging.INFO, handlers=[stdout_handler, stderr_handler
35
37
 
36
38
 
37
39
  def main():
40
+ """Main entry point for github-backup CLI."""
38
41
  args = parse_args()
39
42
 
43
+ if args.private and not get_auth(args):
44
+ logger.warning(
45
+ "The --private flag has no effect without authentication. "
46
+ "Use -t/--token, -f/--token-fine, or -u/--username to authenticate."
47
+ )
48
+
40
49
  if args.quiet:
41
50
  logger.setLevel(logging.WARNING)
42
51
 
@@ -561,7 +561,7 @@ def get_github_host(args):
561
561
 
562
562
 
563
563
  def read_file_contents(file_uri):
564
- return open(file_uri[len(FILE_URI_PREFIX) :], "rt").readline().strip()
564
+ return open(file_uri[len(FILE_URI_PREFIX):], "rt").readline().strip()
565
565
 
566
566
 
567
567
  def get_github_repo_url(args, repository):
@@ -1038,7 +1038,7 @@ def download_attachment_file(url, path, auth, as_app=False, fine=False):
1038
1038
  bytes_downloaded += len(chunk)
1039
1039
 
1040
1040
  # Atomic rename to final location
1041
- os.rename(temp_path, path)
1041
+ os.replace(temp_path, path)
1042
1042
 
1043
1043
  metadata["size_bytes"] = bytes_downloaded
1044
1044
  metadata["success"] = True
@@ -1459,7 +1459,7 @@ def download_attachments(
1459
1459
 
1460
1460
  # Rename to add extension (already atomic from download)
1461
1461
  try:
1462
- os.rename(filepath, final_filepath)
1462
+ os.replace(filepath, final_filepath)
1463
1463
  metadata["saved_as"] = os.path.basename(final_filepath)
1464
1464
  except Exception as e:
1465
1465
  logger.warning(
@@ -1490,7 +1490,7 @@ def download_attachments(
1490
1490
  manifest_path = os.path.join(attachments_dir, "manifest.json")
1491
1491
  with open(manifest_path + ".temp", "w") as f:
1492
1492
  json.dump(manifest, f, indent=2)
1493
- os.rename(manifest_path + ".temp", manifest_path) # Atomic write
1493
+ os.replace(manifest_path + ".temp", manifest_path) # Atomic write
1494
1494
  logger.debug(
1495
1495
  "Wrote manifest for {0} #{1}: {2} attachments".format(
1496
1496
  item_type_display, number, len(attachment_metadata_list)
@@ -1565,16 +1565,22 @@ def retrieve_repositories(args, authenticated_user):
1565
1565
  repos.extend(gists)
1566
1566
 
1567
1567
  if args.include_starred_gists:
1568
- starred_gists_template = "https://{0}/gists/starred".format(
1569
- get_github_api_host(args)
1570
- )
1571
- starred_gists = retrieve_data(
1572
- args, starred_gists_template, single_request=False
1573
- )
1574
- # flag each repo as a starred gist for downstream processing
1575
- for item in starred_gists:
1576
- item.update({"is_gist": True, "is_starred": True})
1577
- repos.extend(starred_gists)
1568
+ if not authenticated_user.get("login") or args.user.lower() != authenticated_user["login"].lower():
1569
+ logger.warning(
1570
+ "Cannot retrieve starred gists for '%s'. GitHub only allows access to the authenticated user's starred gists.",
1571
+ args.user,
1572
+ )
1573
+ else:
1574
+ starred_gists_template = "https://{0}/gists/starred".format(
1575
+ get_github_api_host(args)
1576
+ )
1577
+ starred_gists = retrieve_data(
1578
+ args, starred_gists_template, single_request=False
1579
+ )
1580
+ # flag each repo as a starred gist for downstream processing
1581
+ for item in starred_gists:
1582
+ item.update({"is_gist": True, "is_starred": True})
1583
+ repos.extend(starred_gists)
1578
1584
 
1579
1585
  return repos
1580
1586
 
@@ -1666,9 +1672,10 @@ def backup_repositories(args, output_directory, repositories):
1666
1672
  repo_url = get_github_repo_url(args, repository)
1667
1673
 
1668
1674
  include_gists = args.include_gists or args.include_starred_gists
1675
+ include_starred = args.all_starred and repository.get("is_starred")
1669
1676
  if (args.include_repository or args.include_everything) or (
1670
1677
  include_gists and repository.get("is_gist")
1671
- ):
1678
+ ) or include_starred:
1672
1679
  repo_name = (
1673
1680
  repository.get("name")
1674
1681
  if not repository.get("is_gist")
@@ -1804,7 +1811,7 @@ def backup_issues(args, repo_cwd, repository, repos_template):
1804
1811
 
1805
1812
  with codecs.open(issue_file + ".temp", "w", encoding="utf-8") as f:
1806
1813
  json_dump(issue, f)
1807
- os.rename(issue_file + ".temp", issue_file) # Unlike json_dump, this is atomic
1814
+ os.replace(issue_file + ".temp", issue_file) # Atomic write
1808
1815
 
1809
1816
 
1810
1817
  def backup_pulls(args, repo_cwd, repository, repos_template):
@@ -1879,7 +1886,7 @@ def backup_pulls(args, repo_cwd, repository, repos_template):
1879
1886
 
1880
1887
  with codecs.open(pull_file + ".temp", "w", encoding="utf-8") as f:
1881
1888
  json_dump(pull, f)
1882
- os.rename(pull_file + ".temp", pull_file) # Unlike json_dump, this is atomic
1889
+ os.replace(pull_file + ".temp", pull_file) # Atomic write
1883
1890
 
1884
1891
 
1885
1892
  def backup_milestones(args, repo_cwd, repository, repos_template):
@@ -2017,12 +2024,9 @@ def fetch_repository(
2017
2024
  ):
2018
2025
  if bare_clone:
2019
2026
  if os.path.exists(local_dir):
2020
- clone_exists = (
2021
- subprocess.check_output(
2022
- ["git", "rev-parse", "--is-bare-repository"], cwd=local_dir
2023
- )
2024
- == b"true\n"
2025
- )
2027
+ clone_exists = subprocess.check_output(
2028
+ ["git", "rev-parse", "--is-bare-repository"], cwd=local_dir
2029
+ ) == b"true\n"
2026
2030
  else:
2027
2031
  clone_exists = False
2028
2032
  else:
@@ -2037,11 +2041,14 @@ def fetch_repository(
2037
2041
  "git ls-remote " + remote_url, stdout=FNULL, stderr=FNULL, shell=True
2038
2042
  )
2039
2043
  if initialized == 128:
2040
- logger.info(
2041
- "Skipping {0} ({1}) since it's not initialized".format(
2042
- name, masked_remote_url
2044
+ if ".wiki.git" in remote_url:
2045
+ logger.info(
2046
+ "Skipping {0} wiki (wiki is enabled but has no content)".format(name)
2047
+ )
2048
+ else:
2049
+ logger.info(
2050
+ "Skipping {0} (repository not accessible - may be empty, private, or credentials invalid)".format(name)
2043
2051
  )
2044
- )
2045
2052
  return
2046
2053
 
2047
2054
  if clone_exists:
@@ -2083,11 +2090,13 @@ def fetch_repository(
2083
2090
  git_command.pop()
2084
2091
  logging_subprocess(git_command, cwd=local_dir)
2085
2092
  else:
2086
- if lfs_clone:
2087
- git_command = ["git", "lfs", "clone", remote_url, local_dir]
2088
- else:
2089
- git_command = ["git", "clone", remote_url, local_dir]
2093
+ git_command = ["git", "clone", remote_url, local_dir]
2090
2094
  logging_subprocess(git_command)
2095
+ if lfs_clone:
2096
+ git_command = ["git", "lfs", "fetch", "--all", "--prune"]
2097
+ if no_prune:
2098
+ git_command.pop()
2099
+ logging_subprocess(git_command, cwd=local_dir)
2091
2100
 
2092
2101
 
2093
2102
  def backup_account(args, output_directory):
@@ -2196,5 +2205,5 @@ def json_dump_if_changed(data, output_file_path):
2196
2205
  temp_file = output_file_path + ".temp"
2197
2206
  with codecs.open(temp_file, "w", encoding="utf-8") as f:
2198
2207
  f.write(new_content)
2199
- os.rename(temp_file, output_file_path) # Atomic on POSIX systems
2208
+ os.replace(temp_file, output_file_path) # Atomic write
2200
2209
  return True
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.54.0
3
+ Version: 0.56.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
@@ -245,6 +245,8 @@ When you use the ``--lfs`` option, you will need to make sure you have Git LFS i
245
245
 
246
246
  Instructions on how to do this can be found on https://git-lfs.github.com.
247
247
 
248
+ 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.
249
+
248
250
 
249
251
  About Attachments
250
252
  -----------------
@@ -311,11 +313,11 @@ If the incremental argument is used, this will result in the next backup only re
311
313
 
312
314
  It's therefore recommended to only use the incremental argument if the output/result is being actively monitored, or complimented with periodic full non-incremental runs, to avoid unexpected missing data in a regular backup runs.
313
315
 
314
- 1. **Starred public repo hooks blocking**
316
+ **Starred public repo hooks blocking**
315
317
 
316
- Since the ``--all`` argument includes ``--hooks``, if you use ``--all`` and ``--all-starred`` together to clone a users starred public repositories, the backup will likely error and block the backup continuing.
318
+ Since the ``--all`` argument includes ``--hooks``, if you use ``--all`` and ``--all-starred`` together to clone a users starred public repositories, the backup will likely error and block the backup continuing.
317
319
 
318
- This is due to needing the correct permission for ``--hooks`` on public repos.
320
+ This is due to needing the correct permission for ``--hooks`` on public repos.
319
321
 
320
322
 
321
323
  "bare" is actually "mirror"
@@ -331,6 +333,8 @@ Starred gists vs starred repo behaviour
331
333
 
332
334
  The starred normal repo cloning (``--all-starred``) argument stores starred repos separately to the users own repositories. However, using ``--starred-gists`` will store starred gists within the same directory as the users own gists ``--gists``. Also, all gist repo directory names are IDs not the gist's name.
333
335
 
336
+ Note: ``--starred-gists`` only retrieves starred gists for the authenticated user, not the target user, due to a GitHub API limitation.
337
+
334
338
 
335
339
  Skip existing on incomplete backups
336
340
  -----------------------------------
@@ -388,6 +392,25 @@ Debug an error/block or incomplete backup into a temporary directory. Omit "incr
388
392
  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
389
393
 
390
394
 
395
+ Restoring from Backup
396
+ =====================
397
+
398
+ This tool creates backups only, there is no inbuilt restore command.
399
+
400
+ **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::
401
+
402
+ cd /tmp/white-house/repositories/petitions/repository
403
+ git push --mirror git@github.com:WhiteHouse/petitions.git
404
+
405
+ **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:
406
+
407
+ - New issue/PR numbers are assigned (original numbers cannot be set)
408
+ - Timestamps reflect creation time (original dates cannot be set)
409
+ - The API caller becomes the author (original authors cannot be set)
410
+ - Cross-references between issues and PRs will break
411
+
412
+ 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.
413
+
391
414
 
392
415
  Development
393
416
  ===========
@@ -6,15 +6,19 @@ 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
19
+ tests/test_all_starred.py
17
20
  tests/test_attachments.py
21
+ tests/test_case_sensitivity.py
18
22
  tests/test_http_451.py
19
23
  tests/test_json_dump_if_changed.py
20
24
  tests/test_pagination.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.5.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,161 @@
1
+ """Tests for --all-starred flag behavior (issue #225)."""
2
+
3
+ import pytest
4
+ from unittest.mock import Mock, patch
5
+
6
+ from github_backup import github_backup
7
+
8
+
9
+ class TestAllStarredCloning:
10
+ """Test suite for --all-starred repository cloning behavior.
11
+
12
+ Issue #225: --all-starred should clone starred repos without requiring --repositories.
13
+ """
14
+
15
+ def _create_mock_args(self, **overrides):
16
+ """Create a mock args object with sensible defaults."""
17
+ args = Mock()
18
+ args.user = "testuser"
19
+ args.output_directory = "/tmp/backup"
20
+ args.include_repository = False
21
+ args.include_everything = False
22
+ args.include_gists = False
23
+ args.include_starred_gists = False
24
+ args.all_starred = False
25
+ args.skip_existing = False
26
+ args.bare_clone = False
27
+ args.lfs_clone = False
28
+ args.no_prune = False
29
+ args.include_wiki = False
30
+ args.include_issues = False
31
+ args.include_issue_comments = False
32
+ args.include_issue_events = False
33
+ args.include_pulls = False
34
+ args.include_pull_comments = False
35
+ args.include_pull_commits = False
36
+ args.include_pull_details = False
37
+ args.include_labels = False
38
+ args.include_hooks = False
39
+ args.include_milestones = False
40
+ args.include_releases = False
41
+ args.include_assets = False
42
+ args.include_attachments = False
43
+ args.incremental = False
44
+ args.incremental_by_files = False
45
+ args.github_host = None
46
+ args.prefer_ssh = False
47
+ args.token_classic = None
48
+ args.token_fine = None
49
+ args.username = None
50
+ args.password = None
51
+ args.as_app = False
52
+ args.osx_keychain_item_name = None
53
+ args.osx_keychain_item_account = None
54
+
55
+ for key, value in overrides.items():
56
+ setattr(args, key, value)
57
+
58
+ return args
59
+
60
+ @patch('github_backup.github_backup.fetch_repository')
61
+ @patch('github_backup.github_backup.get_github_repo_url')
62
+ def test_all_starred_clones_without_repositories_flag(self, mock_get_url, mock_fetch):
63
+ """--all-starred should clone starred repos without --repositories flag.
64
+
65
+ This is the core fix for issue #225.
66
+ """
67
+ args = self._create_mock_args(all_starred=True)
68
+ mock_get_url.return_value = "https://github.com/otheruser/awesome-project.git"
69
+
70
+ # A starred repository (is_starred flag set by retrieve_repositories)
71
+ starred_repo = {
72
+ "name": "awesome-project",
73
+ "full_name": "otheruser/awesome-project",
74
+ "owner": {"login": "otheruser"},
75
+ "private": False,
76
+ "fork": False,
77
+ "has_wiki": False,
78
+ "is_starred": True, # This flag is set for starred repos
79
+ }
80
+
81
+ with patch('github_backup.github_backup.mkdir_p'):
82
+ github_backup.backup_repositories(args, "/tmp/backup", [starred_repo])
83
+
84
+ # fetch_repository should be called for the starred repo
85
+ assert mock_fetch.called, "--all-starred should trigger repository cloning"
86
+ mock_fetch.assert_called_once()
87
+ call_args = mock_fetch.call_args
88
+ assert call_args[0][0] == "awesome-project" # repo name
89
+
90
+ @patch('github_backup.github_backup.fetch_repository')
91
+ @patch('github_backup.github_backup.get_github_repo_url')
92
+ def test_starred_repo_not_cloned_without_all_starred_flag(self, mock_get_url, mock_fetch):
93
+ """Starred repos should NOT be cloned if --all-starred is not set."""
94
+ args = self._create_mock_args(all_starred=False)
95
+ mock_get_url.return_value = "https://github.com/otheruser/awesome-project.git"
96
+
97
+ starred_repo = {
98
+ "name": "awesome-project",
99
+ "full_name": "otheruser/awesome-project",
100
+ "owner": {"login": "otheruser"},
101
+ "private": False,
102
+ "fork": False,
103
+ "has_wiki": False,
104
+ "is_starred": True,
105
+ }
106
+
107
+ with patch('github_backup.github_backup.mkdir_p'):
108
+ github_backup.backup_repositories(args, "/tmp/backup", [starred_repo])
109
+
110
+ # fetch_repository should NOT be called
111
+ assert not mock_fetch.called, "Starred repos should not be cloned without --all-starred"
112
+
113
+ @patch('github_backup.github_backup.fetch_repository')
114
+ @patch('github_backup.github_backup.get_github_repo_url')
115
+ def test_non_starred_repo_not_cloned_with_only_all_starred(self, mock_get_url, mock_fetch):
116
+ """Non-starred repos should NOT be cloned when only --all-starred is set."""
117
+ args = self._create_mock_args(all_starred=True)
118
+ mock_get_url.return_value = "https://github.com/testuser/my-project.git"
119
+
120
+ # A regular (non-starred) repository
121
+ regular_repo = {
122
+ "name": "my-project",
123
+ "full_name": "testuser/my-project",
124
+ "owner": {"login": "testuser"},
125
+ "private": False,
126
+ "fork": False,
127
+ "has_wiki": False,
128
+ # No is_starred flag
129
+ }
130
+
131
+ with patch('github_backup.github_backup.mkdir_p'):
132
+ github_backup.backup_repositories(args, "/tmp/backup", [regular_repo])
133
+
134
+ # fetch_repository should NOT be called for non-starred repos
135
+ assert not mock_fetch.called, "Non-starred repos should not be cloned with only --all-starred"
136
+
137
+ @patch('github_backup.github_backup.fetch_repository')
138
+ @patch('github_backup.github_backup.get_github_repo_url')
139
+ def test_repositories_flag_still_works(self, mock_get_url, mock_fetch):
140
+ """--repositories flag should still clone repos as before."""
141
+ args = self._create_mock_args(include_repository=True)
142
+ mock_get_url.return_value = "https://github.com/testuser/my-project.git"
143
+
144
+ regular_repo = {
145
+ "name": "my-project",
146
+ "full_name": "testuser/my-project",
147
+ "owner": {"login": "testuser"},
148
+ "private": False,
149
+ "fork": False,
150
+ "has_wiki": False,
151
+ }
152
+
153
+ with patch('github_backup.github_backup.mkdir_p'):
154
+ github_backup.backup_repositories(args, "/tmp/backup", [regular_repo])
155
+
156
+ # fetch_repository should be called
157
+ assert mock_fetch.called, "--repositories should trigger repository cloning"
158
+
159
+
160
+ if __name__ == "__main__":
161
+ pytest.main([__file__, "-v"])
@@ -0,0 +1,112 @@
1
+ """Tests for case-insensitive username/organization filtering."""
2
+
3
+ import pytest
4
+ from unittest.mock import Mock
5
+
6
+ from github_backup import github_backup
7
+
8
+
9
+ class TestCaseSensitivity:
10
+ """Test suite for case-insensitive username matching in filter_repositories."""
11
+
12
+ def test_filter_repositories_case_insensitive_user(self):
13
+ """Should filter repositories case-insensitively for usernames.
14
+
15
+ Reproduces issue #198 where typing 'iamrodos' fails to match
16
+ repositories with owner.login='Iamrodos' (the canonical case from GitHub API).
17
+ """
18
+ # Simulate user typing lowercase username
19
+ args = Mock()
20
+ args.user = "iamrodos" # lowercase (what user typed)
21
+ args.repository = None
22
+ args.name_regex = None
23
+ args.languages = None
24
+ args.exclude = None
25
+ args.fork = False
26
+ args.private = False
27
+ args.public = False
28
+ args.all = True
29
+
30
+ # Simulate GitHub API returning canonical case
31
+ repos = [
32
+ {
33
+ "name": "repo1",
34
+ "owner": {"login": "Iamrodos"}, # Capital I (canonical from API)
35
+ "private": False,
36
+ "fork": False,
37
+ },
38
+ {
39
+ "name": "repo2",
40
+ "owner": {"login": "Iamrodos"},
41
+ "private": False,
42
+ "fork": False,
43
+ },
44
+ ]
45
+
46
+ filtered = github_backup.filter_repositories(args, repos)
47
+
48
+ # Should match despite case difference
49
+ assert len(filtered) == 2
50
+ assert filtered[0]["name"] == "repo1"
51
+ assert filtered[1]["name"] == "repo2"
52
+
53
+ def test_filter_repositories_case_insensitive_org(self):
54
+ """Should filter repositories case-insensitively for organizations.
55
+
56
+ Tests the example from issue #198 where 'prai-org' doesn't match 'PRAI-Org'.
57
+ """
58
+ args = Mock()
59
+ args.user = "prai-org" # lowercase (what user typed)
60
+ args.repository = None
61
+ args.name_regex = None
62
+ args.languages = None
63
+ args.exclude = None
64
+ args.fork = False
65
+ args.private = False
66
+ args.public = False
67
+ args.all = True
68
+
69
+ repos = [
70
+ {
71
+ "name": "repo1",
72
+ "owner": {"login": "PRAI-Org"}, # Different case (canonical from API)
73
+ "private": False,
74
+ "fork": False,
75
+ },
76
+ ]
77
+
78
+ filtered = github_backup.filter_repositories(args, repos)
79
+
80
+ # Should match despite case difference
81
+ assert len(filtered) == 1
82
+ assert filtered[0]["name"] == "repo1"
83
+
84
+ def test_filter_repositories_case_variations(self):
85
+ """Should handle various case combinations correctly."""
86
+ args = Mock()
87
+ args.user = "TeSt-UsEr" # Mixed case
88
+ args.repository = None
89
+ args.name_regex = None
90
+ args.languages = None
91
+ args.exclude = None
92
+ args.fork = False
93
+ args.private = False
94
+ args.public = False
95
+ args.all = True
96
+
97
+ repos = [
98
+ {"name": "repo1", "owner": {"login": "test-user"}, "private": False, "fork": False},
99
+ {"name": "repo2", "owner": {"login": "TEST-USER"}, "private": False, "fork": False},
100
+ {"name": "repo3", "owner": {"login": "TeSt-UsEr"}, "private": False, "fork": False},
101
+ {"name": "repo4", "owner": {"login": "other-user"}, "private": False, "fork": False},
102
+ ]
103
+
104
+ filtered = github_backup.filter_repositories(args, repos)
105
+
106
+ # Should match first 3 (all case variations of same user)
107
+ assert len(filtered) == 3
108
+ assert set(r["name"] for r in filtered) == {"repo1", "repo2", "repo3"}
109
+
110
+
111
+ if __name__ == "__main__":
112
+ pytest.main([__file__, "-v"])
@@ -1 +0,0 @@
1
- __version__ = "0.54.0"
File without changes