gitacc-switcher 1.4.0__tar.gz → 1.5.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 (34) hide show
  1. {gitacc_switcher-1.4.0/gitacc_switcher.egg-info → gitacc_switcher-1.5.0}/PKG-INFO +1 -1
  2. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/gitacc_switcher/__init__.py +1 -1
  3. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/gitacc_switcher/account_manager.py +29 -0
  4. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/gitacc_switcher/cli.py +47 -0
  5. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/gitacc_switcher/ssh_manager.py +27 -1
  6. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0/gitacc_switcher.egg-info}/PKG-INFO +1 -1
  7. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/pyproject.toml +1 -1
  8. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/setup.py +1 -1
  9. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/tests/test_account_manager.py +61 -0
  10. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/tests/test_cli.py +81 -0
  11. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/tests/test_ssh_manager.py +44 -0
  12. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/LICENSE +0 -0
  13. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/MANIFEST.in +0 -0
  14. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/README.md +0 -0
  15. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/gitacc_switcher/completion.py +0 -0
  16. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/gitacc_switcher/config_manager.py +0 -0
  17. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/gitacc_switcher/hook_manager.py +0 -0
  18. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/gitacc_switcher/rules_manager.py +0 -0
  19. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/gitacc_switcher/shell_hook.py +0 -0
  20. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/gitacc_switcher/utils.py +0 -0
  21. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/gitacc_switcher.egg-info/SOURCES.txt +0 -0
  22. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/gitacc_switcher.egg-info/dependency_links.txt +0 -0
  23. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/gitacc_switcher.egg-info/entry_points.txt +0 -0
  24. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/gitacc_switcher.egg-info/requires.txt +0 -0
  25. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/gitacc_switcher.egg-info/top_level.txt +0 -0
  26. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/requirements.txt +0 -0
  27. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/setup.cfg +0 -0
  28. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/tests/__init__.py +0 -0
  29. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/tests/test_completion.py +0 -0
  30. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/tests/test_config_manager.py +0 -0
  31. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/tests/test_hook_manager.py +0 -0
  32. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/tests/test_rules_manager.py +0 -0
  33. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/tests/test_shell_hook.py +0 -0
  34. {gitacc_switcher-1.4.0 → gitacc_switcher-1.5.0}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitacc-switcher
3
- Version: 1.4.0
3
+ Version: 1.5.0
4
4
  Summary: Git Account Switcher - Manage multiple Git SSH accounts easily
5
5
  Home-page: https://github.com/ktechhub/gitacc_switcher
6
6
  Author: ktechhub
@@ -1,4 +1,4 @@
1
1
  """Git Account Switcher - Manage multiple Git SSH accounts easily."""
2
2
 
3
- __version__ = "1.4.0"
3
+ __version__ = "1.5.0"
4
4
  __author__ = "ktechhub"
@@ -384,6 +384,35 @@ class AccountManager:
384
384
 
385
385
  return True
386
386
 
387
+ def run_doctor(self) -> Dict[str, object]:
388
+ """Run read-only health checks on the SSH agent and managed key files.
389
+
390
+ Checks whether the SSH agent is running and reachable, and whether
391
+ each registered account's private key file exists, is readable, and
392
+ has safe permissions. Makes no changes.
393
+
394
+ Returns:
395
+ Dictionary with 'agent_running' (bool) and 'accounts' (dict
396
+ mapping account name to its key-file check result — see
397
+ SSHManager.check_key_file).
398
+ """
399
+ agent_running = self.ssh_manager.is_ssh_agent_running()
400
+
401
+ accounts = self.config_manager.read_accounts()
402
+ account_checks = {}
403
+ for account_name, account_info in accounts.items():
404
+ private_key = account_info.get("private_key")
405
+ if not private_key:
406
+ account_checks[account_name] = {
407
+ "exists": False,
408
+ "readable": False,
409
+ "permissions_ok": False,
410
+ }
411
+ continue
412
+ account_checks[account_name] = self.ssh_manager.check_key_file(private_key)
413
+
414
+ return {"agent_running": agent_running, "accounts": account_checks}
415
+
387
416
  def get_status(self, repo_path: Optional[Path] = None) -> Dict[str, object]:
388
417
  """Report the currently active Git identity and SSH agent key, and
389
418
  which registered account (if any) they match.
@@ -48,6 +48,7 @@ class CLI:
48
48
  self._add_list_command(subparsers)
49
49
  self._add_logout_command(subparsers)
50
50
  self._add_status_command(subparsers)
51
+ self._add_doctor_command(subparsers)
51
52
  self._add_init_command(subparsers)
52
53
  self._add_verify_command(subparsers)
53
54
  self._add_update_command(subparsers)
@@ -79,6 +80,7 @@ Examples:
79
80
  gitacc list List all registered accounts
80
81
  gitacc logout Logout current account
81
82
  gitacc status Show the currently active account
83
+ gitacc doctor Run health checks on the SSH agent and keys
82
84
  gitacc init myaccount Initialize repo with account validation
83
85
  gitacc verify Verify current account matches repo
84
86
  gitacc update myaccount Update Git name for an account
@@ -175,6 +177,17 @@ Examples:
175
177
  )
176
178
  parser.set_defaults(func=self._handle_status)
177
179
 
180
+ def _add_doctor_command(self, subparsers: argparse._SubParsersAction) -> None:
181
+ """Add the 'doctor' command parser."""
182
+ parser = subparsers.add_parser(
183
+ "doctor",
184
+ help="Run health checks on the SSH agent and managed keys",
185
+ description="Check that the SSH agent is running and reachable, "
186
+ "and that each registered account's key file exists and has "
187
+ "safe permissions. Read-only.",
188
+ )
189
+ parser.set_defaults(func=self._handle_doctor)
190
+
178
191
  def _add_init_command(self, subparsers: argparse._SubParsersAction) -> None:
179
192
  """Add the 'init' command parser."""
180
193
  parser = subparsers.add_parser(
@@ -516,6 +529,40 @@ Examples:
516
529
 
517
530
  return 0
518
531
 
532
+ def _handle_doctor(self, args: argparse.Namespace) -> int:
533
+ """Handle the 'doctor' command."""
534
+ report = self.account_manager.run_doctor()
535
+ all_ok = True
536
+
537
+ if report.get("agent_running"):
538
+ echo_color("g", "✓ SSH agent is running and reachable")
539
+ else:
540
+ echo_color("r", "✗ SSH agent is not running or not reachable")
541
+ all_ok = False
542
+
543
+ accounts = report.get("accounts", {})
544
+ if not accounts:
545
+ echo_color("y", "No accounts registered yet.")
546
+
547
+ for account_name, check in accounts.items():
548
+ if not check.get("exists"):
549
+ echo_color("r", f'✗ Account "{account_name}": key file is missing')
550
+ all_ok = False
551
+ elif not check.get("readable"):
552
+ echo_color("r", f'✗ Account "{account_name}": key file is unreadable')
553
+ all_ok = False
554
+ elif not check.get("permissions_ok"):
555
+ echo_color(
556
+ "r",
557
+ f'✗ Account "{account_name}": key file permissions are too '
558
+ "permissive (should be readable by owner only)",
559
+ )
560
+ all_ok = False
561
+ else:
562
+ echo_color("g", f'✓ Account "{account_name}": key file OK')
563
+
564
+ return 0 if all_ok else 1
565
+
519
566
  def _handle_init(self, args: argparse.Namespace) -> int:
520
567
  """Handle the 'init' command."""
521
568
  success = self.account_manager.init_repo(args.account_name)
@@ -6,7 +6,7 @@ import subprocess
6
6
  import tempfile
7
7
  import re
8
8
  from pathlib import Path
9
- from typing import List, Optional, Tuple
9
+ from typing import Dict, List, Optional, Tuple
10
10
 
11
11
 
12
12
  class SSHManager:
@@ -269,6 +269,32 @@ class SSHManager:
269
269
  parts = result.stdout.strip().split()
270
270
  return parts[1] if len(parts) >= 2 else None
271
271
 
272
+ def check_key_file(self, private_key_path: str) -> Dict[str, bool]:
273
+ """Check a managed private key file's existence, readability, and permissions.
274
+
275
+ Read-only; does not modify anything it checks.
276
+
277
+ Args:
278
+ private_key_path: Path to the private key file to check
279
+
280
+ Returns:
281
+ Dictionary with 'exists', 'readable', and 'permissions_ok' booleans.
282
+ 'permissions_ok' is True when the file is not accessible to
283
+ group/other, matching the 0600 mode ssh-keygen gives private keys
284
+ on creation; it is only meaningful when 'exists' and 'readable'
285
+ are both True.
286
+ """
287
+ path = Path(private_key_path)
288
+ if not path.is_file():
289
+ return {"exists": False, "readable": False, "permissions_ok": False}
290
+
291
+ if not os.access(path, os.R_OK):
292
+ return {"exists": True, "readable": False, "permissions_ok": False}
293
+
294
+ mode = path.stat().st_mode
295
+ permissions_ok = (mode & 0o077) == 0
296
+ return {"exists": True, "readable": True, "permissions_ok": permissions_ok}
297
+
272
298
  def get_public_key_content(self, public_key_path: str) -> Optional[str]:
273
299
  """Read public key content.
274
300
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitacc-switcher
3
- Version: 1.4.0
3
+ Version: 1.5.0
4
4
  Summary: Git Account Switcher - Manage multiple Git SSH accounts easily
5
5
  Home-page: https://github.com/ktechhub/gitacc_switcher
6
6
  Author: ktechhub
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gitacc-switcher"
7
- version = "1.4.0"
7
+ version = "1.5.0"
8
8
  description = "Git Account Switcher - Manage multiple Git SSH accounts easily"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.7"
@@ -9,7 +9,7 @@ long_description = readme_file.read_text() if readme_file.exists() else ""
9
9
 
10
10
  setup(
11
11
  name="gitacc-switcher",
12
- version="1.4.0",
12
+ version="1.5.0",
13
13
  description="Git Account Switcher - Manage multiple Git SSH accounts easily",
14
14
  long_description=long_description,
15
15
  long_description_content_type="text/markdown",
@@ -531,3 +531,64 @@ class TestGetStatus:
531
531
 
532
532
  manager.ssh_manager.get_agent_fingerprints.assert_not_called()
533
533
  assert status["matched_key_account"] is None
534
+
535
+
536
+ class TestRunDoctor:
537
+ def test_agent_running_and_all_keys_ok(self, manager, tmp_path):
538
+ key = tmp_path / "id_rsa_work"
539
+ key.write_text("private")
540
+ key.chmod(0o600)
541
+ manager.ssh_manager.is_ssh_agent_running.return_value = True
542
+ manager.config_manager.read_accounts.return_value = {
543
+ "work": {**WORK_ACCOUNT, "private_key": str(key)}
544
+ }
545
+ manager.ssh_manager.check_key_file.return_value = {
546
+ "exists": True,
547
+ "readable": True,
548
+ "permissions_ok": True,
549
+ }
550
+
551
+ report = manager.run_doctor()
552
+
553
+ assert report["agent_running"] is True
554
+ assert report["accounts"] == {
555
+ "work": {"exists": True, "readable": True, "permissions_ok": True}
556
+ }
557
+ manager.ssh_manager.check_key_file.assert_called_once_with(str(key))
558
+
559
+ def test_agent_not_running(self, manager):
560
+ manager.ssh_manager.is_ssh_agent_running.return_value = False
561
+ manager.config_manager.read_accounts.return_value = {}
562
+
563
+ report = manager.run_doctor()
564
+
565
+ assert report["agent_running"] is False
566
+ assert report["accounts"] == {}
567
+
568
+ def test_flags_account_with_no_private_key_recorded(self, manager):
569
+ manager.ssh_manager.is_ssh_agent_running.return_value = True
570
+ manager.config_manager.read_accounts.return_value = {
571
+ "broken": {"name": "Broken", "email": "b@co.com"}
572
+ }
573
+
574
+ report = manager.run_doctor()
575
+
576
+ assert report["accounts"] == {
577
+ "broken": {"exists": False, "readable": False, "permissions_ok": False}
578
+ }
579
+ manager.ssh_manager.check_key_file.assert_not_called()
580
+
581
+ def test_flags_missing_key_file(self, manager):
582
+ manager.ssh_manager.is_ssh_agent_running.return_value = True
583
+ manager.config_manager.read_accounts.return_value = {
584
+ "work": {**WORK_ACCOUNT, "private_key": "/nonexistent/id_rsa_work"}
585
+ }
586
+ manager.ssh_manager.check_key_file.return_value = {
587
+ "exists": False,
588
+ "readable": False,
589
+ "permissions_ok": False,
590
+ }
591
+
592
+ report = manager.run_doctor()
593
+
594
+ assert report["accounts"]["work"]["exists"] is False
@@ -79,6 +79,9 @@ class TestParserCommands:
79
79
  def test_verify_command(self, cli):
80
80
  assert cli.parser.parse_args(["verify"]).command == "verify"
81
81
 
82
+ def test_doctor_command(self, cli):
83
+ assert cli.parser.parse_args(["doctor"]).command == "doctor"
84
+
82
85
  def test_update_no_flags(self, cli):
83
86
  args = cli.parser.parse_args(["update", "mywork"])
84
87
  assert args.account_name == "mywork"
@@ -217,6 +220,75 @@ class TestHandlers:
217
220
  assert "(not set)" in out
218
221
  assert "SSH agent is not running." in out
219
222
 
223
+ def test_handle_doctor_all_ok(self, cli, capsys):
224
+ cli.account_manager.run_doctor.return_value = {
225
+ "agent_running": True,
226
+ "accounts": {
227
+ "work": {"exists": True, "readable": True, "permissions_ok": True}
228
+ },
229
+ }
230
+ result = cli._handle_doctor(argparse.Namespace())
231
+ out = capsys.readouterr().out
232
+ assert result == 0
233
+ assert "SSH agent is running and reachable" in out
234
+ assert 'Account "work": key file OK' in out
235
+
236
+ def test_handle_doctor_agent_not_running(self, cli, capsys):
237
+ cli.account_manager.run_doctor.return_value = {
238
+ "agent_running": False,
239
+ "accounts": {},
240
+ }
241
+ result = cli._handle_doctor(argparse.Namespace())
242
+ out = capsys.readouterr().out
243
+ assert result == 1
244
+ assert "not running or not reachable" in out
245
+
246
+ def test_handle_doctor_missing_key(self, cli, capsys):
247
+ cli.account_manager.run_doctor.return_value = {
248
+ "agent_running": True,
249
+ "accounts": {
250
+ "work": {"exists": False, "readable": False, "permissions_ok": False}
251
+ },
252
+ }
253
+ result = cli._handle_doctor(argparse.Namespace())
254
+ out = capsys.readouterr().out
255
+ assert result == 1
256
+ assert 'Account "work": key file is missing' in out
257
+
258
+ def test_handle_doctor_unreadable_key(self, cli, capsys):
259
+ cli.account_manager.run_doctor.return_value = {
260
+ "agent_running": True,
261
+ "accounts": {
262
+ "work": {"exists": True, "readable": False, "permissions_ok": False}
263
+ },
264
+ }
265
+ result = cli._handle_doctor(argparse.Namespace())
266
+ out = capsys.readouterr().out
267
+ assert result == 1
268
+ assert 'Account "work": key file is unreadable' in out
269
+
270
+ def test_handle_doctor_bad_permissions(self, cli, capsys):
271
+ cli.account_manager.run_doctor.return_value = {
272
+ "agent_running": True,
273
+ "accounts": {
274
+ "work": {"exists": True, "readable": True, "permissions_ok": False}
275
+ },
276
+ }
277
+ result = cli._handle_doctor(argparse.Namespace())
278
+ out = capsys.readouterr().out
279
+ assert result == 1
280
+ assert "permissions are too permissive" in out
281
+
282
+ def test_handle_doctor_no_accounts(self, cli, capsys):
283
+ cli.account_manager.run_doctor.return_value = {
284
+ "agent_running": True,
285
+ "accounts": {},
286
+ }
287
+ result = cli._handle_doctor(argparse.Namespace())
288
+ out = capsys.readouterr().out
289
+ assert result == 0
290
+ assert "No accounts registered yet." in out
291
+
220
292
  def test_handle_init_success(self, cli):
221
293
  cli.account_manager.init_repo.return_value = True
222
294
  assert cli._handle_init(argparse.Namespace(account_name="work")) == 0
@@ -355,6 +427,15 @@ class TestRun:
355
427
  result = cli.run()
356
428
  assert result == 0
357
429
 
430
+ def test_doctor_command_runs(self, cli):
431
+ cli.account_manager.run_doctor.return_value = {
432
+ "agent_running": True,
433
+ "accounts": {},
434
+ }
435
+ with patch.object(sys, "argv", ["gitacc", "doctor"]):
436
+ result = cli.run()
437
+ assert result == 0
438
+
358
439
 
359
440
  # ---------------------------------------------------------------------------
360
441
  # `rule` command parsing
@@ -210,6 +210,50 @@ class TestGetAgentFingerprints:
210
210
  assert ssh.get_agent_fingerprints() == []
211
211
 
212
212
 
213
+ class TestCheckKeyFile:
214
+ def test_missing_file(self, ssh, tmp_path):
215
+ result = ssh.check_key_file(str(tmp_path / "nonexistent"))
216
+ assert result == {
217
+ "exists": False,
218
+ "readable": False,
219
+ "permissions_ok": False,
220
+ }
221
+
222
+ def test_correct_permissions(self, ssh, tmp_path):
223
+ key = tmp_path / "id_rsa_test"
224
+ key.write_text("private key data")
225
+ key.chmod(0o600)
226
+ result = ssh.check_key_file(str(key))
227
+ assert result == {
228
+ "exists": True,
229
+ "readable": True,
230
+ "permissions_ok": True,
231
+ }
232
+
233
+ def test_too_permissive(self, ssh, tmp_path):
234
+ key = tmp_path / "id_rsa_test"
235
+ key.write_text("private key data")
236
+ key.chmod(0o644)
237
+ result = ssh.check_key_file(str(key))
238
+ assert result == {
239
+ "exists": True,
240
+ "readable": True,
241
+ "permissions_ok": False,
242
+ }
243
+
244
+ def test_unreadable_file(self, ssh, tmp_path):
245
+ key = tmp_path / "id_rsa_test"
246
+ key.write_text("private key data")
247
+ key.chmod(0o600)
248
+ with patch("os.access", return_value=False):
249
+ result = ssh.check_key_file(str(key))
250
+ assert result == {
251
+ "exists": True,
252
+ "readable": False,
253
+ "permissions_ok": False,
254
+ }
255
+
256
+
213
257
  class TestGetKeyFingerprint:
214
258
  def test_returns_fingerprint(self, ssh, tmp_path):
215
259
  pub_key = tmp_path / "id_rsa_test.pub"
File without changes