Skip to content

Commit 5b52931

Browse files
authored
Merge pull request josegonzalez#461 from Iamrodos/fix-cli-ux-and-cleanup
fix: CLI UX improvements and cleanup
2 parents 9f7c081 + 1d6d474 commit 5b52931

File tree

5 files changed

+296
-15
lines changed

5 files changed

+296
-15
lines changed

README.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -281,11 +281,11 @@ If the incremental argument is used, this will result in the next backup only re
281281

282282
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.
283283

284-
1. **Starred public repo hooks blocking**
284+
**Starred public repo hooks blocking**
285285

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.
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.
287287

288-
This is due to needing the correct permission for ``--hooks`` on public repos.
288+
This is due to needing the correct permission for ``--hooks`` on public repos.
289289

290290

291291
"bare" is actually "mirror"

bin/github-backup

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ from github_backup.github_backup import (
99
backup_repositories,
1010
check_git_lfs_install,
1111
filter_repositories,
12+
get_auth,
1213
get_authenticated_user,
1314
logger,
1415
mkdir_p,
@@ -37,6 +38,12 @@ logging.basicConfig(level=logging.INFO, handlers=[stdout_handler, stderr_handler
3738
def main():
3839
args = parse_args()
3940

41+
if args.private and not get_auth(args):
42+
logger.warning(
43+
"The --private flag has no effect without authentication. "
44+
"Use -t/--token, -f/--token-fine, or -u/--username to authenticate."
45+
)
46+
4047
if args.quiet:
4148
logger.setLevel(logging.WARNING)
4249

github_backup/github_backup.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -561,7 +561,7 @@ def get_github_host(args):
561561

562562

563563
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()
565565

566566

567567
def get_github_repo_url(args, repository):
@@ -1672,9 +1672,10 @@ def backup_repositories(args, output_directory, repositories):
16721672
repo_url = get_github_repo_url(args, repository)
16731673

16741674
include_gists = args.include_gists or args.include_starred_gists
1675+
include_starred = args.all_starred and repository.get("is_starred")
16751676
if (args.include_repository or args.include_everything) or (
16761677
include_gists and repository.get("is_gist")
1677-
):
1678+
) or include_starred:
16781679
repo_name = (
16791680
repository.get("name")
16801681
if not repository.get("is_gist")
@@ -2023,12 +2024,9 @@ def fetch_repository(
20232024
):
20242025
if bare_clone:
20252026
if os.path.exists(local_dir):
2026-
clone_exists = (
2027-
subprocess.check_output(
2028-
["git", "rev-parse", "--is-bare-repository"], cwd=local_dir
2029-
)
2030-
== b"true\n"
2031-
)
2027+
clone_exists = subprocess.check_output(
2028+
["git", "rev-parse", "--is-bare-repository"], cwd=local_dir
2029+
) == b"true\n"
20322030
else:
20332031
clone_exists = False
20342032
else:
@@ -2043,11 +2041,14 @@ def fetch_repository(
20432041
"git ls-remote " + remote_url, stdout=FNULL, stderr=FNULL, shell=True
20442042
)
20452043
if initialized == 128:
2046-
logger.info(
2047-
"Skipping {0} ({1}) since it's not initialized".format(
2048-
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)
20492051
)
2050-
)
20512052
return
20522053

20532054
if clone_exists:

tests/test_all_starred.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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"])

tests/test_case_sensitivity.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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"])

0 commit comments

Comments
 (0)