gitacc-switcher 1.2.1__tar.gz → 1.3.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 (30) hide show
  1. {gitacc_switcher-1.2.1/gitacc_switcher.egg-info → gitacc_switcher-1.3.0}/PKG-INFO +1 -1
  2. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/gitacc_switcher/__init__.py +1 -1
  3. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/gitacc_switcher/account_manager.py +61 -0
  4. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/gitacc_switcher/cli.py +41 -0
  5. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/gitacc_switcher/ssh_manager.py +49 -1
  6. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0/gitacc_switcher.egg-info}/PKG-INFO +1 -1
  7. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/pyproject.toml +1 -1
  8. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/setup.py +1 -1
  9. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/tests/test_account_manager.py +105 -0
  10. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/tests/test_cli.py +59 -0
  11. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/tests/test_ssh_manager.py +48 -0
  12. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/LICENSE +0 -0
  13. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/MANIFEST.in +0 -0
  14. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/README.md +0 -0
  15. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/gitacc_switcher/completion.py +0 -0
  16. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/gitacc_switcher/config_manager.py +0 -0
  17. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/gitacc_switcher/hook_manager.py +0 -0
  18. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/gitacc_switcher/utils.py +0 -0
  19. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/gitacc_switcher.egg-info/SOURCES.txt +0 -0
  20. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/gitacc_switcher.egg-info/dependency_links.txt +0 -0
  21. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/gitacc_switcher.egg-info/entry_points.txt +0 -0
  22. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/gitacc_switcher.egg-info/requires.txt +0 -0
  23. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/gitacc_switcher.egg-info/top_level.txt +0 -0
  24. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/requirements.txt +0 -0
  25. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/setup.cfg +0 -0
  26. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/tests/__init__.py +0 -0
  27. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/tests/test_completion.py +0 -0
  28. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/tests/test_config_manager.py +0 -0
  29. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.0}/tests/test_hook_manager.py +0 -0
  30. {gitacc_switcher-1.2.1 → gitacc_switcher-1.3.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.2.1
3
+ Version: 1.3.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.2.1"
3
+ __version__ = "1.3.0"
4
4
  __author__ = "ktechhub"
@@ -382,6 +382,67 @@ class AccountManager:
382
382
 
383
383
  return True
384
384
 
385
+ def get_status(self, repo_path: Optional[Path] = None) -> Dict[str, object]:
386
+ """Report the currently active Git identity and SSH agent key, and
387
+ which registered account (if any) they match.
388
+
389
+ Read-only: makes no changes to Git config, the SSH agent, or any
390
+ managed files.
391
+
392
+ Args:
393
+ repo_path: Path to repository (default: current directory). Used
394
+ to resolve local Git config when run inside a repo.
395
+
396
+ Returns:
397
+ Dictionary with the current name/email, the matched account name
398
+ (or None), whether the SSH agent is running, and which account
399
+ (if any) the currently loaded agent key belongs to.
400
+ """
401
+ if self.config_manager.is_git_repo(repo_path):
402
+ current_config = self.config_manager.get_current_git_config_local(repo_path)
403
+ else:
404
+ current_config = self.config_manager.get_current_git_config()
405
+
406
+ current_name = current_config.get("name")
407
+ current_email = current_config.get("email")
408
+
409
+ accounts = self.config_manager.read_accounts()
410
+
411
+ matched_account = None
412
+ for account_name, account_info in accounts.items():
413
+ if (
414
+ current_name
415
+ and current_email
416
+ and account_info.get("name") == current_name
417
+ and account_info.get("email") == current_email
418
+ ):
419
+ matched_account = account_name
420
+ break
421
+
422
+ agent_running = self.ssh_manager.is_ssh_agent_running()
423
+ agent_fingerprints = (
424
+ self.ssh_manager.get_agent_fingerprints() if agent_running else []
425
+ )
426
+
427
+ matched_key_account = None
428
+ if agent_fingerprints:
429
+ for account_name, account_info in accounts.items():
430
+ public_key = account_info.get("public_key")
431
+ if not public_key:
432
+ continue
433
+ fingerprint = self.ssh_manager.get_key_fingerprint(public_key)
434
+ if fingerprint and fingerprint in agent_fingerprints:
435
+ matched_key_account = account_name
436
+ break
437
+
438
+ return {
439
+ "name": current_name,
440
+ "email": current_email,
441
+ "matched_account": matched_account,
442
+ "agent_running": agent_running,
443
+ "matched_key_account": matched_key_account,
444
+ }
445
+
385
446
  def init_repo(self, account_name: str, repo_path: Optional[Path] = None) -> bool:
386
447
  """Initialize repository with expected account and install pre-commit hook.
387
448
 
@@ -45,6 +45,7 @@ class CLI:
45
45
  self._add_switch_command(subparsers)
46
46
  self._add_list_command(subparsers)
47
47
  self._add_logout_command(subparsers)
48
+ self._add_status_command(subparsers)
48
49
  self._add_init_command(subparsers)
49
50
  self._add_verify_command(subparsers)
50
51
  self._add_update_command(subparsers)
@@ -74,6 +75,7 @@ Examples:
74
75
  gitacc remove myaccount Remove an account
75
76
  gitacc list List all registered accounts
76
77
  gitacc logout Logout current account
78
+ gitacc status Show the currently active account
77
79
  gitacc init myaccount Initialize repo with account validation
78
80
  gitacc verify Verify current account matches repo
79
81
  gitacc update myaccount Update Git name for an account
@@ -158,6 +160,16 @@ Examples:
158
160
  )
159
161
  parser.set_defaults(func=self._handle_logout)
160
162
 
163
+ def _add_status_command(self, subparsers: argparse._SubParsersAction) -> None:
164
+ """Add the 'status' command parser."""
165
+ parser = subparsers.add_parser(
166
+ "status",
167
+ help="Show the currently active account",
168
+ description="Show the current Git identity and SSH agent key, "
169
+ "and which managed account (if any) matches",
170
+ )
171
+ parser.set_defaults(func=self._handle_status)
172
+
161
173
  def _add_init_command(self, subparsers: argparse._SubParsersAction) -> None:
162
174
  """Add the 'init' command parser."""
163
175
  parser = subparsers.add_parser(
@@ -396,6 +408,35 @@ Examples:
396
408
  success = self.account_manager.logout()
397
409
  return 0 if success else 1
398
410
 
411
+ def _handle_status(self, args: argparse.Namespace) -> int:
412
+ """Handle the 'status' command."""
413
+ status = self.account_manager.get_status()
414
+ name = status.get("name")
415
+ email = status.get("email")
416
+
417
+ echo_color("g", "Current Git identity:")
418
+ echo_color("g", f" Name: {name or '(not set)'}")
419
+ echo_color("g", f" Email: {email or '(not set)'}")
420
+
421
+ matched_account = status.get("matched_account")
422
+ if matched_account:
423
+ echo_color("g", f'Matched account: "{matched_account}"')
424
+ else:
425
+ echo_color("y", "No managed account matches the current Git identity.")
426
+
427
+ if status.get("agent_running"):
428
+ matched_key_account = status.get("matched_key_account")
429
+ if matched_key_account:
430
+ echo_color(
431
+ "g", f'SSH agent key matches account: "{matched_key_account}"'
432
+ )
433
+ else:
434
+ echo_color("y", "SSH agent key does not match any managed account.")
435
+ else:
436
+ echo_color("y", "SSH agent is not running.")
437
+
438
+ return 0
439
+
399
440
  def _handle_init(self, args: argparse.Namespace) -> int:
400
441
  """Handle the 'init' command."""
401
442
  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 Optional, Tuple
9
+ from typing import List, Optional, Tuple
10
10
 
11
11
 
12
12
  class SSHManager:
@@ -221,6 +221,54 @@ class SSHManager:
221
221
  "ssh-add command not found. Please ensure SSH tools are installed.",
222
222
  )
223
223
 
224
+ def get_agent_fingerprints(self) -> List[str]:
225
+ """Get fingerprints of keys currently loaded in the SSH agent.
226
+
227
+ Read-only; does not modify the agent.
228
+
229
+ Returns:
230
+ List of fingerprint strings (e.g. "SHA256:abc..."). Empty if the
231
+ agent is not running, has no keys loaded, or can't be reached.
232
+ """
233
+ try:
234
+ result = subprocess.run(["ssh-add", "-l"], capture_output=True, text=True)
235
+ except FileNotFoundError:
236
+ return []
237
+
238
+ if result.returncode != 0:
239
+ return []
240
+
241
+ fingerprints = []
242
+ for line in result.stdout.strip().split("\n"):
243
+ parts = line.strip().split()
244
+ if len(parts) >= 2:
245
+ fingerprints.append(parts[1])
246
+ return fingerprints
247
+
248
+ def get_key_fingerprint(self, public_key_path: str) -> Optional[str]:
249
+ """Get the fingerprint of a public key file.
250
+
251
+ Args:
252
+ public_key_path: Path to the public key file
253
+
254
+ Returns:
255
+ Fingerprint string (e.g. "SHA256:abc...") or None on error
256
+ """
257
+ try:
258
+ result = subprocess.run(
259
+ ["ssh-keygen", "-lf", str(public_key_path)],
260
+ capture_output=True,
261
+ text=True,
262
+ )
263
+ except FileNotFoundError:
264
+ return None
265
+
266
+ if result.returncode != 0:
267
+ return None
268
+
269
+ parts = result.stdout.strip().split()
270
+ return parts[1] if len(parts) >= 2 else None
271
+
224
272
  def get_public_key_content(self, public_key_path: str) -> Optional[str]:
225
273
  """Read public key content.
226
274
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitacc-switcher
3
- Version: 1.2.1
3
+ Version: 1.3.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.2.1"
7
+ version = "1.3.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.2.1",
12
+ version="1.3.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",
@@ -426,3 +426,108 @@ class TestAddAccount:
426
426
  assert result is True
427
427
  _, kwargs = manager.config_manager.add_account.call_args
428
428
  assert kwargs["sign"] is False
429
+
430
+
431
+ class TestGetStatus:
432
+ def test_matches_account_by_identity(self, manager):
433
+ manager.config_manager.is_git_repo.return_value = False
434
+ manager.config_manager.get_current_git_config.return_value = {
435
+ "name": "Work User",
436
+ "email": "work@company.com",
437
+ }
438
+ manager.config_manager.read_accounts.return_value = {"work": WORK_ACCOUNT}
439
+ manager.ssh_manager.is_ssh_agent_running.return_value = False
440
+
441
+ status = manager.get_status()
442
+
443
+ assert status["name"] == "Work User"
444
+ assert status["email"] == "work@company.com"
445
+ assert status["matched_account"] == "work"
446
+ assert status["agent_running"] is False
447
+ assert status["matched_key_account"] is None
448
+
449
+ def test_no_match_when_identity_unknown(self, manager):
450
+ manager.config_manager.is_git_repo.return_value = False
451
+ manager.config_manager.get_current_git_config.return_value = {
452
+ "name": "Someone Else",
453
+ "email": "someone@else.com",
454
+ }
455
+ manager.config_manager.read_accounts.return_value = {"work": WORK_ACCOUNT}
456
+ manager.ssh_manager.is_ssh_agent_running.return_value = False
457
+
458
+ status = manager.get_status()
459
+
460
+ assert status["matched_account"] is None
461
+
462
+ def test_no_match_when_identity_unset(self, manager):
463
+ manager.config_manager.is_git_repo.return_value = False
464
+ manager.config_manager.get_current_git_config.return_value = {
465
+ "name": None,
466
+ "email": None,
467
+ }
468
+ manager.config_manager.read_accounts.return_value = {"work": WORK_ACCOUNT}
469
+ manager.ssh_manager.is_ssh_agent_running.return_value = False
470
+
471
+ status = manager.get_status()
472
+
473
+ assert status["matched_account"] is None
474
+
475
+ def test_uses_local_git_config_inside_repo(self, manager):
476
+ manager.config_manager.is_git_repo.return_value = True
477
+ manager.config_manager.get_current_git_config_local.return_value = {
478
+ "name": "Work User",
479
+ "email": "work@company.com",
480
+ }
481
+ manager.config_manager.read_accounts.return_value = {"work": WORK_ACCOUNT}
482
+ manager.ssh_manager.is_ssh_agent_running.return_value = False
483
+
484
+ status = manager.get_status()
485
+
486
+ manager.config_manager.get_current_git_config_local.assert_called_once()
487
+ manager.config_manager.get_current_git_config.assert_not_called()
488
+ assert status["matched_account"] == "work"
489
+
490
+ def test_matches_account_by_agent_key_fingerprint(self, manager):
491
+ manager.config_manager.is_git_repo.return_value = False
492
+ manager.config_manager.get_current_git_config.return_value = {
493
+ "name": None,
494
+ "email": None,
495
+ }
496
+ manager.config_manager.read_accounts.return_value = {"work": WORK_ACCOUNT}
497
+ manager.ssh_manager.is_ssh_agent_running.return_value = True
498
+ manager.ssh_manager.get_agent_fingerprints.return_value = ["SHA256:abc123"]
499
+ manager.ssh_manager.get_key_fingerprint.return_value = "SHA256:abc123"
500
+
501
+ status = manager.get_status()
502
+
503
+ assert status["agent_running"] is True
504
+ assert status["matched_key_account"] == "work"
505
+
506
+ def test_no_key_match_when_fingerprints_differ(self, manager):
507
+ manager.config_manager.is_git_repo.return_value = False
508
+ manager.config_manager.get_current_git_config.return_value = {
509
+ "name": None,
510
+ "email": None,
511
+ }
512
+ manager.config_manager.read_accounts.return_value = {"work": WORK_ACCOUNT}
513
+ manager.ssh_manager.is_ssh_agent_running.return_value = True
514
+ manager.ssh_manager.get_agent_fingerprints.return_value = ["SHA256:other"]
515
+ manager.ssh_manager.get_key_fingerprint.return_value = "SHA256:abc123"
516
+
517
+ status = manager.get_status()
518
+
519
+ assert status["matched_key_account"] is None
520
+
521
+ def test_no_key_lookup_when_agent_not_running(self, manager):
522
+ manager.config_manager.is_git_repo.return_value = False
523
+ manager.config_manager.get_current_git_config.return_value = {
524
+ "name": None,
525
+ "email": None,
526
+ }
527
+ manager.config_manager.read_accounts.return_value = {"work": WORK_ACCOUNT}
528
+ manager.ssh_manager.is_ssh_agent_running.return_value = False
529
+
530
+ status = manager.get_status()
531
+
532
+ manager.ssh_manager.get_agent_fingerprints.assert_not_called()
533
+ assert status["matched_key_account"] is None
@@ -69,6 +69,9 @@ class TestParserCommands:
69
69
  def test_logout_command(self, cli):
70
70
  assert cli.parser.parse_args(["logout"]).command == "logout"
71
71
 
72
+ def test_status_command(self, cli):
73
+ assert cli.parser.parse_args(["status"]).command == "status"
74
+
72
75
  def test_init_parses_account(self, cli):
73
76
  args = cli.parser.parse_args(["init", "mywork"])
74
77
  assert args.account_name == "mywork"
@@ -170,6 +173,50 @@ class TestHandlers:
170
173
  cli.account_manager.verify_account.return_value = False
171
174
  assert cli._handle_verify(argparse.Namespace()) == 1
172
175
 
176
+ def test_handle_status_matched(self, cli, capsys):
177
+ cli.account_manager.get_status.return_value = {
178
+ "name": "Work User",
179
+ "email": "work@co.com",
180
+ "matched_account": "work",
181
+ "agent_running": True,
182
+ "matched_key_account": "work",
183
+ }
184
+ result = cli._handle_status(argparse.Namespace())
185
+ out = capsys.readouterr().out
186
+ assert result == 0
187
+ assert "Work User" in out
188
+ assert "work@co.com" in out
189
+ assert 'Matched account: "work"' in out
190
+ assert 'SSH agent key matches account: "work"' in out
191
+
192
+ def test_handle_status_no_match(self, cli, capsys):
193
+ cli.account_manager.get_status.return_value = {
194
+ "name": "Someone",
195
+ "email": "someone@else.com",
196
+ "matched_account": None,
197
+ "agent_running": True,
198
+ "matched_key_account": None,
199
+ }
200
+ result = cli._handle_status(argparse.Namespace())
201
+ out = capsys.readouterr().out
202
+ assert result == 0
203
+ assert "No managed account matches" in out
204
+ assert "does not match any managed account" in out
205
+
206
+ def test_handle_status_no_identity_set(self, cli, capsys):
207
+ cli.account_manager.get_status.return_value = {
208
+ "name": None,
209
+ "email": None,
210
+ "matched_account": None,
211
+ "agent_running": False,
212
+ "matched_key_account": None,
213
+ }
214
+ result = cli._handle_status(argparse.Namespace())
215
+ out = capsys.readouterr().out
216
+ assert result == 0
217
+ assert "(not set)" in out
218
+ assert "SSH agent is not running." in out
219
+
173
220
  def test_handle_init_success(self, cli):
174
221
  cli.account_manager.init_repo.return_value = True
175
222
  assert cli._handle_init(argparse.Namespace(account_name="work")) == 0
@@ -295,3 +342,15 @@ class TestRun:
295
342
  with patch.object(sys, "argv", ["gitacc", "logout"]):
296
343
  result = cli.run()
297
344
  assert result == 0
345
+
346
+ def test_status_command_runs(self, cli):
347
+ cli.account_manager.get_status.return_value = {
348
+ "name": None,
349
+ "email": None,
350
+ "matched_account": None,
351
+ "agent_running": False,
352
+ "matched_key_account": None,
353
+ }
354
+ with patch.object(sys, "argv", ["gitacc", "status"]):
355
+ result = cli.run()
356
+ assert result == 0
@@ -183,3 +183,51 @@ class TestGetPublicKeyContent:
183
183
 
184
184
  def test_returns_none_for_missing_file(self, ssh, tmp_path):
185
185
  assert ssh.get_public_key_content(str(tmp_path / "nonexistent.pub")) is None
186
+
187
+
188
+ class TestGetAgentFingerprints:
189
+ def test_returns_fingerprints_for_loaded_keys(self, ssh):
190
+ output = (
191
+ "2048 SHA256:abc123 work@co.com (RSA)\n"
192
+ "256 SHA256:def456 personal@co.com (ED25519)\n"
193
+ )
194
+ with patch(
195
+ "subprocess.run",
196
+ return_value=MagicMock(returncode=0, stdout=output),
197
+ ):
198
+ fingerprints = ssh.get_agent_fingerprints()
199
+ assert fingerprints == ["SHA256:abc123", "SHA256:def456"]
200
+
201
+ def test_returns_empty_when_agent_has_no_keys(self, ssh):
202
+ with patch(
203
+ "subprocess.run",
204
+ return_value=MagicMock(returncode=1, stdout=""),
205
+ ):
206
+ assert ssh.get_agent_fingerprints() == []
207
+
208
+ def test_returns_empty_when_ssh_add_missing(self, ssh):
209
+ with patch("subprocess.run", side_effect=FileNotFoundError):
210
+ assert ssh.get_agent_fingerprints() == []
211
+
212
+
213
+ class TestGetKeyFingerprint:
214
+ def test_returns_fingerprint(self, ssh, tmp_path):
215
+ pub_key = tmp_path / "id_rsa_test.pub"
216
+ pub_key.write_text("ssh-rsa AAAA test@test.com")
217
+ with patch(
218
+ "subprocess.run",
219
+ return_value=MagicMock(
220
+ returncode=0, stdout="2048 SHA256:abc123 test@test.com (RSA)\n"
221
+ ),
222
+ ):
223
+ assert ssh.get_key_fingerprint(str(pub_key)) == "SHA256:abc123"
224
+
225
+ def test_returns_none_on_failure(self, ssh, tmp_path):
226
+ pub_key = tmp_path / "id_rsa_test.pub"
227
+ with patch("subprocess.run", return_value=MagicMock(returncode=1, stdout="")):
228
+ assert ssh.get_key_fingerprint(str(pub_key)) is None
229
+
230
+ def test_returns_none_when_ssh_keygen_missing(self, ssh, tmp_path):
231
+ pub_key = tmp_path / "id_rsa_test.pub"
232
+ with patch("subprocess.run", side_effect=FileNotFoundError):
233
+ assert ssh.get_key_fingerprint(str(pub_key)) is None
File without changes