gitacc-switcher 1.4.0__tar.gz → 1.6.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.6.0}/PKG-INFO +26 -1
  2. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/README.md +25 -0
  3. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher/__init__.py +1 -1
  4. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher/account_manager.py +95 -0
  5. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher/cli.py +136 -0
  6. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher/ssh_manager.py +190 -1
  7. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0/gitacc_switcher.egg-info}/PKG-INFO +26 -1
  8. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/pyproject.toml +1 -1
  9. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/setup.py +1 -1
  10. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/tests/test_account_manager.py +122 -0
  11. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/tests/test_cli.py +180 -0
  12. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/tests/test_ssh_manager.py +157 -0
  13. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/LICENSE +0 -0
  14. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/MANIFEST.in +0 -0
  15. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher/completion.py +0 -0
  16. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher/config_manager.py +0 -0
  17. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher/hook_manager.py +0 -0
  18. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher/rules_manager.py +0 -0
  19. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher/shell_hook.py +0 -0
  20. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher/utils.py +0 -0
  21. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher.egg-info/SOURCES.txt +0 -0
  22. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher.egg-info/dependency_links.txt +0 -0
  23. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher.egg-info/entry_points.txt +0 -0
  24. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher.egg-info/requires.txt +0 -0
  25. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher.egg-info/top_level.txt +0 -0
  26. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/requirements.txt +0 -0
  27. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/setup.cfg +0 -0
  28. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/tests/__init__.py +0 -0
  29. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/tests/test_completion.py +0 -0
  30. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/tests/test_config_manager.py +0 -0
  31. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/tests/test_hook_manager.py +0 -0
  32. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/tests/test_rules_manager.py +0 -0
  33. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/tests/test_shell_hook.py +0 -0
  34. {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.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.6.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
@@ -50,6 +50,7 @@ A Python CLI tool to easily switch between multiple Git SSH accounts. Manage mul
50
50
  - Colored terminal output with active account indicator
51
51
  - Pre-commit hook to prevent commits with the wrong account
52
52
  - Opt-in directory-based auto-switching (`gitacc rule`) with a bash/zsh shell hook
53
+ - Per-account SSH config `Host` aliases (`gitacc alias`) for `git@<alias>:org/repo` clone URLs
53
54
  - Shell autocomplete for bash and zsh
54
55
  - Cross-platform support (Linux / macOS)
55
56
 
@@ -219,6 +220,29 @@ The hook is silent unless it actually needs to switch accounts: no matching rule
219
220
  that isn't running, or an identity that's already correct all do nothing. This is entirely
220
221
  opt-in — `gitacc rule add` alone never touches your shell config.
221
222
 
223
+ ### `gitacc alias add <account> <host-alias> [--hostname HOSTNAME] [--user USER]` / `gitacc alias remove <host-alias>` / `gitacc alias list`
224
+
225
+ Bind an SSH config `Host` alias to an account's key by writing a clearly delimited, safely
226
+ add/updatable/removable block to `~/.ssh/config`. This lets you clone/push via a per-account host
227
+ alias (e.g. `git@github-work:org/repo`) without hand-editing your SSH config, and without the
228
+ account's key needing to be loaded into the SSH agent at all.
229
+
230
+ ```bash
231
+ gitacc alias add work github-work # git@github-work:org/repo uses "work"'s key
232
+ gitacc alias add personal github-personal --hostname github.com
233
+ gitacc alias list
234
+ gitacc alias remove github-work
235
+ ```
236
+
237
+ `--hostname` defaults to `github.com` and `--user` defaults to `git`. Running `alias add` again
238
+ for the same alias updates it in place; everything else already in `~/.ssh/config` (including
239
+ aliases for other accounts) is left untouched.
240
+
241
+ ```bash
242
+ # clone using the "work" account's key, without ever switching the active identity
243
+ git clone git@github-work:myorg/myrepo.git
244
+ ```
245
+
222
246
  ### `gitacc autocomplete install`
223
247
 
224
248
  Install tab-completion for your shell (bash or zsh). After running, restart your shell or `source` your profile.
@@ -250,6 +274,7 @@ gitacc --version
250
274
  | `~/.gitaccrules` | Directory → account rules for `gitacc rule` (opt-in auto-switch) |
251
275
  | `~/.ssh/id_<type>_<account>` | Private SSH key per account |
252
276
  | `~/.ssh/id_<type>_<account>.pub` | Public SSH key per account |
277
+ | `~/.ssh/config` | Holds gitacc-managed `Host` alias blocks (`gitacc alias`), clearly delimited from any other entries |
253
278
 
254
279
  `~/.gitacc` format:
255
280
 
@@ -17,6 +17,7 @@ A Python CLI tool to easily switch between multiple Git SSH accounts. Manage mul
17
17
  - Colored terminal output with active account indicator
18
18
  - Pre-commit hook to prevent commits with the wrong account
19
19
  - Opt-in directory-based auto-switching (`gitacc rule`) with a bash/zsh shell hook
20
+ - Per-account SSH config `Host` aliases (`gitacc alias`) for `git@<alias>:org/repo` clone URLs
20
21
  - Shell autocomplete for bash and zsh
21
22
  - Cross-platform support (Linux / macOS)
22
23
 
@@ -186,6 +187,29 @@ The hook is silent unless it actually needs to switch accounts: no matching rule
186
187
  that isn't running, or an identity that's already correct all do nothing. This is entirely
187
188
  opt-in — `gitacc rule add` alone never touches your shell config.
188
189
 
190
+ ### `gitacc alias add <account> <host-alias> [--hostname HOSTNAME] [--user USER]` / `gitacc alias remove <host-alias>` / `gitacc alias list`
191
+
192
+ Bind an SSH config `Host` alias to an account's key by writing a clearly delimited, safely
193
+ add/updatable/removable block to `~/.ssh/config`. This lets you clone/push via a per-account host
194
+ alias (e.g. `git@github-work:org/repo`) without hand-editing your SSH config, and without the
195
+ account's key needing to be loaded into the SSH agent at all.
196
+
197
+ ```bash
198
+ gitacc alias add work github-work # git@github-work:org/repo uses "work"'s key
199
+ gitacc alias add personal github-personal --hostname github.com
200
+ gitacc alias list
201
+ gitacc alias remove github-work
202
+ ```
203
+
204
+ `--hostname` defaults to `github.com` and `--user` defaults to `git`. Running `alias add` again
205
+ for the same alias updates it in place; everything else already in `~/.ssh/config` (including
206
+ aliases for other accounts) is left untouched.
207
+
208
+ ```bash
209
+ # clone using the "work" account's key, without ever switching the active identity
210
+ git clone git@github-work:myorg/myrepo.git
211
+ ```
212
+
189
213
  ### `gitacc autocomplete install`
190
214
 
191
215
  Install tab-completion for your shell (bash or zsh). After running, restart your shell or `source` your profile.
@@ -217,6 +241,7 @@ gitacc --version
217
241
  | `~/.gitaccrules` | Directory → account rules for `gitacc rule` (opt-in auto-switch) |
218
242
  | `~/.ssh/id_<type>_<account>` | Private SSH key per account |
219
243
  | `~/.ssh/id_<type>_<account>.pub` | Public SSH key per account |
244
+ | `~/.ssh/config` | Holds gitacc-managed `Host` alias blocks (`gitacc alias`), clearly delimited from any other entries |
220
245
 
221
246
  `~/.gitacc` format:
222
247
 
@@ -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.6.0"
4
4
  __author__ = "ktechhub"
@@ -384,6 +384,101 @@ class AccountManager:
384
384
 
385
385
  return True
386
386
 
387
+ def add_host_alias(
388
+ self,
389
+ account_name: str,
390
+ host_alias: str,
391
+ hostname: str = "github.com",
392
+ ssh_user: str = "git",
393
+ ) -> bool:
394
+ """Bind an SSH config `Host` alias to an account's key.
395
+
396
+ Args:
397
+ account_name: Account whose key the alias should use
398
+ host_alias: The `Host` alias to create/update (e.g. "github-work")
399
+ hostname: Real host to connect to (default: "github.com")
400
+ ssh_user: SSH user for the connection (default: "git")
401
+
402
+ Returns:
403
+ True if successful, False otherwise
404
+ """
405
+ account_info = self.config_manager.get_account(account_name)
406
+ if not account_info:
407
+ echo_color("r", f'Account "{account_name}" not found!')
408
+ return False
409
+
410
+ private_key = account_info.get("private_key")
411
+ if not private_key:
412
+ echo_color("r", f'Account "{account_name}" has no private key on record!')
413
+ return False
414
+
415
+ success = self.ssh_manager.add_host_alias(
416
+ host_alias, hostname, private_key, ssh_user
417
+ )
418
+ if success:
419
+ echo_color(
420
+ "g",
421
+ f'Host alias "{host_alias}" → account "{account_name}" '
422
+ f"({ssh_user}@{hostname})",
423
+ )
424
+ else:
425
+ echo_color("r", f'Failed to write host alias "{host_alias}"!')
426
+ return success
427
+
428
+ def remove_host_alias(self, host_alias: str) -> bool:
429
+ """Remove a managed SSH config host alias.
430
+
431
+ Args:
432
+ host_alias: The `Host` alias to remove
433
+
434
+ Returns:
435
+ True if a block was found and removed, False otherwise
436
+ """
437
+ success = self.ssh_manager.remove_host_alias(host_alias)
438
+ if success:
439
+ echo_color("g", f'Host alias "{host_alias}" removed.')
440
+ else:
441
+ echo_color("y", f'No managed host alias "{host_alias}" was found.')
442
+ return success
443
+
444
+ def list_host_aliases(self) -> Dict[str, Dict[str, str]]:
445
+ """List all gitacc-managed SSH config host aliases.
446
+
447
+ Returns:
448
+ Dictionary mapping host alias to its hostname, ssh user, and
449
+ identity file path.
450
+ """
451
+ return self.ssh_manager.list_host_aliases()
452
+
453
+ def run_doctor(self) -> Dict[str, object]:
454
+ """Run read-only health checks on the SSH agent and managed key files.
455
+
456
+ Checks whether the SSH agent is running and reachable, and whether
457
+ each registered account's private key file exists, is readable, and
458
+ has safe permissions. Makes no changes.
459
+
460
+ Returns:
461
+ Dictionary with 'agent_running' (bool) and 'accounts' (dict
462
+ mapping account name to its key-file check result — see
463
+ SSHManager.check_key_file).
464
+ """
465
+ agent_running = self.ssh_manager.is_ssh_agent_running()
466
+
467
+ accounts = self.config_manager.read_accounts()
468
+ account_checks = {}
469
+ for account_name, account_info in accounts.items():
470
+ private_key = account_info.get("private_key")
471
+ if not private_key:
472
+ account_checks[account_name] = {
473
+ "exists": False,
474
+ "readable": False,
475
+ "permissions_ok": False,
476
+ }
477
+ continue
478
+ account_checks[account_name] = self.ssh_manager.check_key_file(private_key)
479
+
480
+ return {"agent_running": agent_running, "accounts": account_checks}
481
+
387
482
  def get_status(self, repo_path: Optional[Path] = None) -> Dict[str, object]:
388
483
  """Report the currently active Git identity and SSH agent key, and
389
484
  which registered account (if any) they match.
@@ -48,11 +48,13 @@ 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)
54
55
  self._add_autocomplete_command(subparsers)
55
56
  self._add_rule_command(subparsers)
57
+ self._add_alias_command(subparsers)
56
58
 
57
59
  # Include account names in top-level completion so `gitacc <TAB>` works for
58
60
  # the `gitacc <account>` shorthand, not just for subcommand names.
@@ -79,11 +81,14 @@ Examples:
79
81
  gitacc list List all registered accounts
80
82
  gitacc logout Logout current account
81
83
  gitacc status Show the currently active account
84
+ gitacc doctor Run health checks on the SSH agent and keys
82
85
  gitacc init myaccount Initialize repo with account validation
83
86
  gitacc verify Verify current account matches repo
84
87
  gitacc update myaccount Update Git name for an account
85
88
  gitacc rule add work ~/work Auto-switch to "work" inside that directory
86
89
  gitacc rule hook bash Print the shell hook (add to ~/.bashrc)
90
+ gitacc alias add work github-work Bind a Host alias to an account's key
91
+ gitacc alias list List managed SSH config host aliases
87
92
  """
88
93
 
89
94
  def _add_add_command(self, subparsers: argparse._SubParsersAction) -> None:
@@ -175,6 +180,17 @@ Examples:
175
180
  )
176
181
  parser.set_defaults(func=self._handle_status)
177
182
 
183
+ def _add_doctor_command(self, subparsers: argparse._SubParsersAction) -> None:
184
+ """Add the 'doctor' command parser."""
185
+ parser = subparsers.add_parser(
186
+ "doctor",
187
+ help="Run health checks on the SSH agent and managed keys",
188
+ description="Check that the SSH agent is running and reachable, "
189
+ "and that each registered account's key file exists and has "
190
+ "safe permissions. Read-only.",
191
+ )
192
+ parser.set_defaults(func=self._handle_doctor)
193
+
178
194
  def _add_init_command(self, subparsers: argparse._SubParsersAction) -> None:
179
195
  """Add the 'init' command parser."""
180
196
  parser = subparsers.add_parser(
@@ -333,6 +349,65 @@ Examples:
333
349
  )
334
350
  check_parser.set_defaults(func=self._handle_rule_check)
335
351
 
352
+ def _add_alias_command(self, subparsers: argparse._SubParsersAction) -> None:
353
+ """Add the 'alias' command parser for SSH config Host aliases."""
354
+ parser = subparsers.add_parser(
355
+ "alias",
356
+ help="Manage per-account SSH config Host aliases",
357
+ description="Add, remove, and list managed Host alias blocks in "
358
+ "~/.ssh/config, so you can clone/push via e.g. "
359
+ "git@<alias>:org/repo using a specific account's key",
360
+ )
361
+ alias_subparsers = parser.add_subparsers(
362
+ dest="alias_command",
363
+ help="Alias commands",
364
+ )
365
+
366
+ add_parser = alias_subparsers.add_parser(
367
+ "add",
368
+ help="Add or update a Host alias for an account",
369
+ description="Bind an SSH config Host alias to an account's key",
370
+ )
371
+ account_arg = add_parser.add_argument(
372
+ "account_name",
373
+ help="Account whose key the alias should use",
374
+ )
375
+ add_parser.add_argument(
376
+ "host_alias",
377
+ help="Host alias to create/update (e.g. github-work)",
378
+ )
379
+ add_parser.add_argument(
380
+ "--hostname",
381
+ dest="hostname",
382
+ default="github.com",
383
+ help="Real host to connect to (default: github.com)",
384
+ )
385
+ add_parser.add_argument(
386
+ "--user",
387
+ dest="ssh_user",
388
+ default="git",
389
+ help="SSH user for the connection (default: git)",
390
+ )
391
+ if argcomplete:
392
+ account_arg.completer = lambda **kwargs: get_account_names()
393
+ add_parser.set_defaults(func=self._handle_alias_add)
394
+
395
+ remove_parser = alias_subparsers.add_parser(
396
+ "remove",
397
+ help="Remove a Host alias",
398
+ )
399
+ remove_parser.add_argument(
400
+ "host_alias",
401
+ help="Host alias to remove",
402
+ )
403
+ remove_parser.set_defaults(func=self._handle_alias_remove)
404
+
405
+ list_parser = alias_subparsers.add_parser(
406
+ "list",
407
+ help="List all managed Host aliases",
408
+ )
409
+ list_parser.set_defaults(func=self._handle_alias_list)
410
+
336
411
  def _handle_autocomplete_install(self, args: argparse.Namespace) -> int:
337
412
  """Handle the 'autocomplete install' command."""
338
413
  if not argcomplete:
@@ -516,6 +591,40 @@ Examples:
516
591
 
517
592
  return 0
518
593
 
594
+ def _handle_doctor(self, args: argparse.Namespace) -> int:
595
+ """Handle the 'doctor' command."""
596
+ report = self.account_manager.run_doctor()
597
+ all_ok = True
598
+
599
+ if report.get("agent_running"):
600
+ echo_color("g", "✓ SSH agent is running and reachable")
601
+ else:
602
+ echo_color("r", "✗ SSH agent is not running or not reachable")
603
+ all_ok = False
604
+
605
+ accounts = report.get("accounts", {})
606
+ if not accounts:
607
+ echo_color("y", "No accounts registered yet.")
608
+
609
+ for account_name, check in accounts.items():
610
+ if not check.get("exists"):
611
+ echo_color("r", f'✗ Account "{account_name}": key file is missing')
612
+ all_ok = False
613
+ elif not check.get("readable"):
614
+ echo_color("r", f'✗ Account "{account_name}": key file is unreadable')
615
+ all_ok = False
616
+ elif not check.get("permissions_ok"):
617
+ echo_color(
618
+ "r",
619
+ f'✗ Account "{account_name}": key file permissions are too '
620
+ "permissive (should be readable by owner only)",
621
+ )
622
+ all_ok = False
623
+ else:
624
+ echo_color("g", f'✓ Account "{account_name}": key file OK')
625
+
626
+ return 0 if all_ok else 1
627
+
519
628
  def _handle_init(self, args: argparse.Namespace) -> int:
520
629
  """Handle the 'init' command."""
521
630
  success = self.account_manager.init_repo(args.account_name)
@@ -610,6 +719,33 @@ Examples:
610
719
  success = self.account_manager.switch_account(account_name)
611
720
  return 0 if success else 1
612
721
 
722
+ def _handle_alias_add(self, args: argparse.Namespace) -> int:
723
+ """Handle the 'alias add' command."""
724
+ success = self.account_manager.add_host_alias(
725
+ args.account_name, args.host_alias, args.hostname, args.ssh_user
726
+ )
727
+ return 0 if success else 1
728
+
729
+ def _handle_alias_remove(self, args: argparse.Namespace) -> int:
730
+ """Handle the 'alias remove' command."""
731
+ success = self.account_manager.remove_host_alias(args.host_alias)
732
+ return 0 if success else 1
733
+
734
+ def _handle_alias_list(self, args: argparse.Namespace) -> int:
735
+ """Handle the 'alias list' command."""
736
+ aliases = self.account_manager.list_host_aliases()
737
+ if not aliases:
738
+ echo_color("y", "No managed host aliases yet.")
739
+ return 0
740
+
741
+ echo_color("g", "Managed SSH config host aliases:")
742
+ for host_alias, info in aliases.items():
743
+ print(
744
+ f" {host_alias} → {info['user']}@{info['hostname']} "
745
+ f"({info['identity_file']})"
746
+ )
747
+ return 0
748
+
613
749
  def _handle_account_name_shortcut(self, account_name: str) -> int:
614
750
  """Handle account name as shortcut for switch command."""
615
751
  success = self.account_manager.switch_account(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
 
@@ -283,3 +309,166 @@ class SSHManager:
283
309
  return f.read().strip()
284
310
  except Exception:
285
311
  return None
312
+
313
+ def get_ssh_config_path(self) -> Path:
314
+ """Get the path to the user's SSH config file (~/.ssh/config)."""
315
+ return self.ssh_dir / "config"
316
+
317
+ @staticmethod
318
+ def _host_alias_markers(host_alias: str) -> Tuple[str, str]:
319
+ """Begin/end marker lines that delimit a managed host-alias block.
320
+
321
+ Keyed by host_alias so each alias's block can be found, replaced, or
322
+ removed independently without touching anything else in the file.
323
+ """
324
+ return (
325
+ f"# >>> gitacc-switcher host-alias: {host_alias} >>>",
326
+ f"# <<< gitacc-switcher host-alias: {host_alias} <<<",
327
+ )
328
+
329
+ def _build_host_alias_block(
330
+ self, host_alias: str, hostname: str, private_key_path: str, ssh_user: str
331
+ ) -> str:
332
+ start, end = self._host_alias_markers(host_alias)
333
+ lines = [
334
+ start,
335
+ f"Host {host_alias}",
336
+ f" HostName {hostname}",
337
+ f" User {ssh_user}",
338
+ f" IdentityFile {private_key_path}",
339
+ " IdentitiesOnly yes",
340
+ end,
341
+ ]
342
+ return "\n".join(lines) + "\n"
343
+
344
+ def _read_ssh_config(self) -> str:
345
+ config_path = self.get_ssh_config_path()
346
+ if not config_path.exists():
347
+ return ""
348
+ return config_path.read_text()
349
+
350
+ def has_host_alias(self, host_alias: str) -> bool:
351
+ """Check whether a managed host-alias block already exists.
352
+
353
+ Args:
354
+ host_alias: The `Host` alias to look for (e.g. "github-work")
355
+
356
+ Returns:
357
+ True if a gitacc-managed block for this alias is present
358
+ """
359
+ start, _ = self._host_alias_markers(host_alias)
360
+ return start in self._read_ssh_config()
361
+
362
+ def add_host_alias(
363
+ self,
364
+ host_alias: str,
365
+ hostname: str,
366
+ private_key_path: str,
367
+ ssh_user: str = "git",
368
+ ) -> bool:
369
+ """Add or update a managed SSH config `Host` alias block.
370
+
371
+ Writes a clearly delimited block to ~/.ssh/config pointing the alias
372
+ at the given key, so the user can clone/push via e.g.
373
+ `git@<host_alias>:org/repo` without hand-editing their SSH config.
374
+ Safe to call again for the same alias: only the block between its
375
+ own markers is replaced, leaving the rest of the file untouched.
376
+
377
+ Args:
378
+ host_alias: The `Host` alias to create/update (e.g. "github-work")
379
+ hostname: Real host to connect to (e.g. "github.com")
380
+ private_key_path: Path to the account's private SSH key
381
+ ssh_user: SSH user for the connection (default: "git")
382
+
383
+ Returns:
384
+ True if successful, False otherwise
385
+ """
386
+ start, end = self._host_alias_markers(host_alias)
387
+ block = self._build_host_alias_block(
388
+ host_alias, hostname, private_key_path, ssh_user
389
+ )
390
+ existing = self._read_ssh_config()
391
+
392
+ pattern = re.compile(
393
+ rf"^{re.escape(start)}\n.*?^{re.escape(end)}\n?",
394
+ re.MULTILINE | re.DOTALL,
395
+ )
396
+ if pattern.search(existing):
397
+ new_content = pattern.sub(block, existing, count=1)
398
+ else:
399
+ new_content = existing
400
+ if new_content and not new_content.endswith("\n"):
401
+ new_content += "\n"
402
+ if new_content and not new_content.endswith("\n\n"):
403
+ new_content += "\n"
404
+ new_content += block
405
+
406
+ try:
407
+ config_path = self.get_ssh_config_path()
408
+ is_new_file = not config_path.exists()
409
+ config_path.write_text(new_content)
410
+ if is_new_file:
411
+ config_path.chmod(0o600)
412
+ return True
413
+ except OSError:
414
+ return False
415
+
416
+ def remove_host_alias(self, host_alias: str) -> bool:
417
+ """Remove a managed SSH config `Host` alias block, if present.
418
+
419
+ Only the block delimited by this alias's own markers is removed;
420
+ everything else in ~/.ssh/config is left exactly as it was.
421
+
422
+ Args:
423
+ host_alias: The `Host` alias to remove
424
+
425
+ Returns:
426
+ True if a block was found and removed, False if none existed or
427
+ on error
428
+ """
429
+ start, end = self._host_alias_markers(host_alias)
430
+ existing = self._read_ssh_config()
431
+
432
+ pattern = re.compile(
433
+ rf"\n?^{re.escape(start)}\n.*?^{re.escape(end)}\n?",
434
+ re.MULTILINE | re.DOTALL,
435
+ )
436
+ new_content, count = pattern.subn("", existing)
437
+ if count == 0:
438
+ return False
439
+
440
+ try:
441
+ self.get_ssh_config_path().write_text(new_content)
442
+ return True
443
+ except OSError:
444
+ return False
445
+
446
+ def list_host_aliases(self) -> Dict[str, Dict[str, str]]:
447
+ """List all gitacc-managed SSH config host aliases.
448
+
449
+ Read-only; does not modify anything.
450
+
451
+ Returns:
452
+ Dictionary mapping host alias to its hostname, ssh user, and
453
+ identity file path.
454
+ """
455
+ existing = self._read_ssh_config()
456
+ pattern = re.compile(
457
+ r"^# >>> gitacc-switcher host-alias: (?P<alias>.+?) >>>\n"
458
+ r"Host .+?\n"
459
+ r"\s*HostName (?P<hostname>.+?)\n"
460
+ r"\s*User (?P<user>.+?)\n"
461
+ r"\s*IdentityFile (?P<identity>.+?)\n"
462
+ r"\s*IdentitiesOnly yes\n"
463
+ r"# <<< gitacc-switcher host-alias: (?P=alias) <<<",
464
+ re.MULTILINE,
465
+ )
466
+
467
+ aliases = {}
468
+ for match in pattern.finditer(existing):
469
+ aliases[match.group("alias")] = {
470
+ "hostname": match.group("hostname"),
471
+ "user": match.group("user"),
472
+ "identity_file": match.group("identity"),
473
+ }
474
+ return aliases
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitacc-switcher
3
- Version: 1.4.0
3
+ Version: 1.6.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
@@ -50,6 +50,7 @@ A Python CLI tool to easily switch between multiple Git SSH accounts. Manage mul
50
50
  - Colored terminal output with active account indicator
51
51
  - Pre-commit hook to prevent commits with the wrong account
52
52
  - Opt-in directory-based auto-switching (`gitacc rule`) with a bash/zsh shell hook
53
+ - Per-account SSH config `Host` aliases (`gitacc alias`) for `git@<alias>:org/repo` clone URLs
53
54
  - Shell autocomplete for bash and zsh
54
55
  - Cross-platform support (Linux / macOS)
55
56
 
@@ -219,6 +220,29 @@ The hook is silent unless it actually needs to switch accounts: no matching rule
219
220
  that isn't running, or an identity that's already correct all do nothing. This is entirely
220
221
  opt-in — `gitacc rule add` alone never touches your shell config.
221
222
 
223
+ ### `gitacc alias add <account> <host-alias> [--hostname HOSTNAME] [--user USER]` / `gitacc alias remove <host-alias>` / `gitacc alias list`
224
+
225
+ Bind an SSH config `Host` alias to an account's key by writing a clearly delimited, safely
226
+ add/updatable/removable block to `~/.ssh/config`. This lets you clone/push via a per-account host
227
+ alias (e.g. `git@github-work:org/repo`) without hand-editing your SSH config, and without the
228
+ account's key needing to be loaded into the SSH agent at all.
229
+
230
+ ```bash
231
+ gitacc alias add work github-work # git@github-work:org/repo uses "work"'s key
232
+ gitacc alias add personal github-personal --hostname github.com
233
+ gitacc alias list
234
+ gitacc alias remove github-work
235
+ ```
236
+
237
+ `--hostname` defaults to `github.com` and `--user` defaults to `git`. Running `alias add` again
238
+ for the same alias updates it in place; everything else already in `~/.ssh/config` (including
239
+ aliases for other accounts) is left untouched.
240
+
241
+ ```bash
242
+ # clone using the "work" account's key, without ever switching the active identity
243
+ git clone git@github-work:myorg/myrepo.git
244
+ ```
245
+
222
246
  ### `gitacc autocomplete install`
223
247
 
224
248
  Install tab-completion for your shell (bash or zsh). After running, restart your shell or `source` your profile.
@@ -250,6 +274,7 @@ gitacc --version
250
274
  | `~/.gitaccrules` | Directory → account rules for `gitacc rule` (opt-in auto-switch) |
251
275
  | `~/.ssh/id_<type>_<account>` | Private SSH key per account |
252
276
  | `~/.ssh/id_<type>_<account>.pub` | Public SSH key per account |
277
+ | `~/.ssh/config` | Holds gitacc-managed `Host` alias blocks (`gitacc alias`), clearly delimited from any other entries |
253
278
 
254
279
  `~/.gitacc` format:
255
280
 
@@ -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.6.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.6.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,125 @@ 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
595
+
596
+
597
+ class TestAddHostAlias:
598
+ def test_fails_when_account_not_found(self, manager):
599
+ manager.config_manager.get_account.return_value = None
600
+ assert manager.add_host_alias("nobody", "github-work") is False
601
+ manager.ssh_manager.add_host_alias.assert_not_called()
602
+
603
+ def test_fails_when_account_has_no_private_key(self, manager):
604
+ manager.config_manager.get_account.return_value = {"email": "w@co.com"}
605
+ assert manager.add_host_alias("work", "github-work") is False
606
+ manager.ssh_manager.add_host_alias.assert_not_called()
607
+
608
+ def test_success_uses_defaults(self, manager):
609
+ manager.config_manager.get_account.return_value = WORK_ACCOUNT
610
+ manager.ssh_manager.add_host_alias.return_value = True
611
+
612
+ assert manager.add_host_alias("work", "github-work") is True
613
+
614
+ manager.ssh_manager.add_host_alias.assert_called_once_with(
615
+ "github-work", "github.com", WORK_ACCOUNT["private_key"], "git"
616
+ )
617
+
618
+ def test_success_with_custom_hostname_and_user(self, manager):
619
+ manager.config_manager.get_account.return_value = WORK_ACCOUNT
620
+ manager.ssh_manager.add_host_alias.return_value = True
621
+
622
+ result = manager.add_host_alias(
623
+ "work", "gitlab-work", hostname="gitlab.com", ssh_user="deploy"
624
+ )
625
+
626
+ assert result is True
627
+ manager.ssh_manager.add_host_alias.assert_called_once_with(
628
+ "gitlab-work", "gitlab.com", WORK_ACCOUNT["private_key"], "deploy"
629
+ )
630
+
631
+ def test_returns_false_when_ssh_manager_fails(self, manager):
632
+ manager.config_manager.get_account.return_value = WORK_ACCOUNT
633
+ manager.ssh_manager.add_host_alias.return_value = False
634
+ assert manager.add_host_alias("work", "github-work") is False
635
+
636
+
637
+ class TestRemoveHostAlias:
638
+ def test_success(self, manager):
639
+ manager.ssh_manager.remove_host_alias.return_value = True
640
+ assert manager.remove_host_alias("github-work") is True
641
+
642
+ def test_not_found(self, manager):
643
+ manager.ssh_manager.remove_host_alias.return_value = False
644
+ assert manager.remove_host_alias("nonexistent") is False
645
+
646
+
647
+ class TestListHostAliases:
648
+ def test_delegates_to_ssh_manager(self, manager):
649
+ data = {"github-work": {"hostname": "github.com"}}
650
+ manager.ssh_manager.list_host_aliases.return_value = data
651
+ assert manager.list_host_aliases() == data
652
+
653
+ def test_empty(self, manager):
654
+ manager.ssh_manager.list_host_aliases.return_value = {}
655
+ assert manager.list_host_aliases() == {}
@@ -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
@@ -494,3 +575,102 @@ class TestRuleHandlers:
494
575
  cli.account_manager.ssh_manager.is_ssh_agent_running.return_value = True
495
576
  cli.account_manager.switch_account.return_value = False
496
577
  assert cli._handle_rule_check(argparse.Namespace()) == 1
578
+
579
+
580
+ # ---------------------------------------------------------------------------
581
+ # `alias` command parsing
582
+ # ---------------------------------------------------------------------------
583
+
584
+
585
+ class TestAliasCommandParsing:
586
+ def test_alias_add_parses_defaults(self, cli):
587
+ args = cli.parser.parse_args(["alias", "add", "work", "github-work"])
588
+ assert args.command == "alias"
589
+ assert args.alias_command == "add"
590
+ assert args.account_name == "work"
591
+ assert args.host_alias == "github-work"
592
+ assert args.hostname == "github.com"
593
+ assert args.ssh_user == "git"
594
+
595
+ def test_alias_add_parses_custom_hostname_and_user(self, cli):
596
+ args = cli.parser.parse_args(
597
+ [
598
+ "alias",
599
+ "add",
600
+ "work",
601
+ "gitlab-work",
602
+ "--hostname",
603
+ "gitlab.com",
604
+ "--user",
605
+ "deploy",
606
+ ]
607
+ )
608
+ assert args.hostname == "gitlab.com"
609
+ assert args.ssh_user == "deploy"
610
+
611
+ def test_alias_remove_parses_host_alias(self, cli):
612
+ args = cli.parser.parse_args(["alias", "remove", "github-work"])
613
+ assert args.alias_command == "remove"
614
+ assert args.host_alias == "github-work"
615
+
616
+ def test_alias_list_command(self, cli):
617
+ args = cli.parser.parse_args(["alias", "list"])
618
+ assert args.alias_command == "list"
619
+
620
+
621
+ # ---------------------------------------------------------------------------
622
+ # `alias` command handlers
623
+ # ---------------------------------------------------------------------------
624
+
625
+
626
+ class TestAliasCommandHandlers:
627
+ def test_handle_alias_add_success(self, cli):
628
+ args = argparse.Namespace(
629
+ account_name="work",
630
+ host_alias="github-work",
631
+ hostname="github.com",
632
+ ssh_user="git",
633
+ )
634
+ cli.account_manager.add_host_alias.return_value = True
635
+ assert cli._handle_alias_add(args) == 0
636
+ cli.account_manager.add_host_alias.assert_called_once_with(
637
+ "work", "github-work", "github.com", "git"
638
+ )
639
+
640
+ def test_handle_alias_add_failure(self, cli):
641
+ args = argparse.Namespace(
642
+ account_name="nobody",
643
+ host_alias="github-work",
644
+ hostname="github.com",
645
+ ssh_user="git",
646
+ )
647
+ cli.account_manager.add_host_alias.return_value = False
648
+ assert cli._handle_alias_add(args) == 1
649
+
650
+ def test_handle_alias_remove_success(self, cli):
651
+ cli.account_manager.remove_host_alias.return_value = True
652
+ args = argparse.Namespace(host_alias="github-work")
653
+ assert cli._handle_alias_remove(args) == 0
654
+
655
+ def test_handle_alias_remove_not_found(self, cli):
656
+ cli.account_manager.remove_host_alias.return_value = False
657
+ args = argparse.Namespace(host_alias="nonexistent")
658
+ assert cli._handle_alias_remove(args) == 1
659
+
660
+ def test_handle_alias_list_empty(self, cli):
661
+ cli.account_manager.list_host_aliases.return_value = {}
662
+ assert cli._handle_alias_list(argparse.Namespace()) == 0
663
+
664
+ def test_handle_alias_list_with_aliases(self, cli, capsys):
665
+ cli.account_manager.list_host_aliases.return_value = {
666
+ "github-work": {
667
+ "hostname": "github.com",
668
+ "user": "git",
669
+ "identity_file": "/home/u/.ssh/id_work",
670
+ }
671
+ }
672
+ result = cli._handle_alias_list(argparse.Namespace())
673
+ assert result == 0
674
+ out = capsys.readouterr().out
675
+ assert "github-work" in out
676
+ assert "git@github.com" in out
@@ -210,6 +210,163 @@ 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
+
257
+ class TestHostAlias:
258
+ def test_add_creates_config_file(self, ssh):
259
+ config_path = ssh.get_ssh_config_path()
260
+ assert not config_path.exists()
261
+
262
+ assert ssh.add_host_alias("github-work", "github.com", "/home/u/.ssh/id_work")
263
+ assert config_path.exists()
264
+ content = config_path.read_text()
265
+ assert "Host github-work" in content
266
+ assert "HostName github.com" in content
267
+ assert "IdentityFile /home/u/.ssh/id_work" in content
268
+ assert "User git" in content
269
+ assert "IdentitiesOnly yes" in content
270
+
271
+ def test_add_sets_restrictive_permissions_on_new_file(self, ssh):
272
+ ssh.add_host_alias("github-work", "github.com", "/home/u/.ssh/id_work")
273
+ mode = ssh.get_ssh_config_path().stat().st_mode & 0o777
274
+ assert mode == 0o600
275
+
276
+ def test_add_uses_custom_user(self, ssh):
277
+ ssh.add_host_alias(
278
+ "gitlab-work", "gitlab.com", "/home/u/.ssh/id_work", ssh_user="deploy"
279
+ )
280
+ assert "User deploy" in ssh.get_ssh_config_path().read_text()
281
+
282
+ def test_add_preserves_unrelated_existing_content(self, ssh):
283
+ config_path = ssh.get_ssh_config_path()
284
+ config_path.write_text("Host other\n HostName example.com\n")
285
+
286
+ ssh.add_host_alias("github-work", "github.com", "/home/u/.ssh/id_work")
287
+
288
+ content = config_path.read_text()
289
+ assert "Host other" in content
290
+ assert "HostName example.com" in content
291
+ assert "Host github-work" in content
292
+
293
+ def test_add_twice_updates_in_place_without_duplicating(self, ssh):
294
+ ssh.add_host_alias("github-work", "github.com", "/home/u/.ssh/id_work")
295
+ ssh.add_host_alias("github-work", "github.com", "/home/u/.ssh/id_work_new")
296
+
297
+ content = ssh.get_ssh_config_path().read_text()
298
+ assert content.count("Host github-work") == 1
299
+ assert "/home/u/.ssh/id_work_new" in content
300
+ assert "/home/u/.ssh/id_work\n" not in content
301
+
302
+ def test_add_second_alias_keeps_first_intact(self, ssh):
303
+ ssh.add_host_alias("github-work", "github.com", "/home/u/.ssh/id_work")
304
+ ssh.add_host_alias("github-personal", "github.com", "/home/u/.ssh/id_personal")
305
+
306
+ content = ssh.get_ssh_config_path().read_text()
307
+ assert "Host github-work" in content
308
+ assert "Host github-personal" in content
309
+ assert "/home/u/.ssh/id_work" in content
310
+ assert "/home/u/.ssh/id_personal" in content
311
+
312
+ def test_has_host_alias(self, ssh):
313
+ assert ssh.has_host_alias("github-work") is False
314
+ ssh.add_host_alias("github-work", "github.com", "/home/u/.ssh/id_work")
315
+ assert ssh.has_host_alias("github-work") is True
316
+
317
+ def test_remove_returns_false_when_absent(self, ssh):
318
+ assert ssh.remove_host_alias("nonexistent") is False
319
+
320
+ def test_remove_deletes_only_its_own_block(self, ssh):
321
+ config_path = ssh.get_ssh_config_path()
322
+ config_path.write_text("Host other\n HostName example.com\n")
323
+ ssh.add_host_alias("github-work", "github.com", "/home/u/.ssh/id_work")
324
+ ssh.add_host_alias("github-personal", "github.com", "/home/u/.ssh/id_personal")
325
+
326
+ assert ssh.remove_host_alias("github-work") is True
327
+
328
+ content = config_path.read_text()
329
+ assert "Host github-work" not in content
330
+ assert "Host github-personal" in content
331
+ assert "Host other" in content
332
+ assert "HostName example.com" in content
333
+
334
+ def test_remove_leaves_file_clean_when_last_alias(self, ssh):
335
+ config_path = ssh.get_ssh_config_path()
336
+ config_path.write_text("Host other\n HostName example.com\n")
337
+ ssh.add_host_alias("github-work", "github.com", "/home/u/.ssh/id_work")
338
+
339
+ assert ssh.remove_host_alias("github-work") is True
340
+
341
+ content = config_path.read_text()
342
+ assert "gitacc-switcher" not in content
343
+ assert "Host other" in content
344
+
345
+ def test_list_host_aliases_empty(self, ssh):
346
+ assert ssh.list_host_aliases() == {}
347
+
348
+ def test_list_host_aliases_returns_all(self, ssh):
349
+ ssh.add_host_alias("github-work", "github.com", "/home/u/.ssh/id_work")
350
+ ssh.add_host_alias(
351
+ "gitlab-personal",
352
+ "gitlab.com",
353
+ "/home/u/.ssh/id_personal",
354
+ ssh_user="deploy",
355
+ )
356
+
357
+ aliases = ssh.list_host_aliases()
358
+ assert aliases["github-work"] == {
359
+ "hostname": "github.com",
360
+ "user": "git",
361
+ "identity_file": "/home/u/.ssh/id_work",
362
+ }
363
+ assert aliases["gitlab-personal"] == {
364
+ "hostname": "gitlab.com",
365
+ "user": "deploy",
366
+ "identity_file": "/home/u/.ssh/id_personal",
367
+ }
368
+
369
+
213
370
  class TestGetKeyFingerprint:
214
371
  def test_returns_fingerprint(self, ssh, tmp_path):
215
372
  pub_key = tmp_path / "id_rsa_test.pub"
File without changes