gitacc-switcher 1.3.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.3.0/gitacc_switcher.egg-info → gitacc_switcher-1.5.0}/PKG-INFO +44 -1
  2. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/README.md +43 -0
  3. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/gitacc_switcher/__init__.py +1 -1
  4. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/gitacc_switcher/account_manager.py +31 -0
  5. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/gitacc_switcher/cli.py +200 -0
  6. gitacc_switcher-1.5.0/gitacc_switcher/rules_manager.py +118 -0
  7. gitacc_switcher-1.5.0/gitacc_switcher/shell_hook.py +51 -0
  8. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/gitacc_switcher/ssh_manager.py +27 -1
  9. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0/gitacc_switcher.egg-info}/PKG-INFO +44 -1
  10. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/gitacc_switcher.egg-info/SOURCES.txt +4 -0
  11. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/pyproject.toml +1 -1
  12. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/setup.py +1 -1
  13. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/tests/test_account_manager.py +61 -0
  14. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/tests/test_cli.py +221 -0
  15. gitacc_switcher-1.5.0/tests/test_rules_manager.py +142 -0
  16. gitacc_switcher-1.5.0/tests/test_shell_hook.py +34 -0
  17. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/tests/test_ssh_manager.py +44 -0
  18. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/LICENSE +0 -0
  19. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/MANIFEST.in +0 -0
  20. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/gitacc_switcher/completion.py +0 -0
  21. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/gitacc_switcher/config_manager.py +0 -0
  22. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/gitacc_switcher/hook_manager.py +0 -0
  23. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/gitacc_switcher/utils.py +0 -0
  24. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/gitacc_switcher.egg-info/dependency_links.txt +0 -0
  25. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/gitacc_switcher.egg-info/entry_points.txt +0 -0
  26. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/gitacc_switcher.egg-info/requires.txt +0 -0
  27. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/gitacc_switcher.egg-info/top_level.txt +0 -0
  28. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/requirements.txt +0 -0
  29. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/setup.cfg +0 -0
  30. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/tests/__init__.py +0 -0
  31. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/tests/test_completion.py +0 -0
  32. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/tests/test_config_manager.py +0 -0
  33. {gitacc_switcher-1.3.0 → gitacc_switcher-1.5.0}/tests/test_hook_manager.py +0 -0
  34. {gitacc_switcher-1.3.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.3.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
@@ -49,6 +49,7 @@ A Python CLI tool to easily switch between multiple Git SSH accounts. Manage mul
49
49
  - Opt-in SSH commit signing per account
50
50
  - Colored terminal output with active account indicator
51
51
  - Pre-commit hook to prevent commits with the wrong account
52
+ - Opt-in directory-based auto-switching (`gitacc rule`) with a bash/zsh shell hook
52
53
  - Shell autocomplete for bash and zsh
53
54
  - Cross-platform support (Linux / macOS)
54
55
 
@@ -184,6 +185,40 @@ Kill the SSH agent and unset the global Git user config.
184
185
  gitacc logout
185
186
  ```
186
187
 
188
+ ### `gitacc rule add <account> [directory]` / `gitacc rule remove [directory]` / `gitacc rule list`
189
+
190
+ Opt-in, direnv-style directory-based auto-switching: bind an account to a directory (and
191
+ everything beneath it) so the right identity is picked automatically as you move around, instead
192
+ of running `gitacc switch` by hand. `directory` defaults to the current directory. The most
193
+ specific matching directory wins, so a rule on a subdirectory overrides one on its parent.
194
+
195
+ ```bash
196
+ gitacc rule add work ~/projects/work # anything under ~/projects/work uses "work"
197
+ gitacc rule add personal ~/projects/side # anything under ~/projects/side uses "personal"
198
+ gitacc rule list
199
+ gitacc rule remove ~/projects/side
200
+ ```
201
+
202
+ Rules by themselves don't do anything — nothing triggers on `cd` until you install the shell hook
203
+ below.
204
+
205
+ ### `gitacc rule hook <bash|zsh>`
206
+
207
+ Print a shell snippet that calls `gitacc rule check` so rules apply automatically. Add it to your
208
+ shell profile and restart your shell (or `source` it):
209
+
210
+ ```bash
211
+ # bash
212
+ gitacc rule hook bash >> ~/.bashrc
213
+
214
+ # zsh
215
+ gitacc rule hook zsh >> ~/.zshrc
216
+ ```
217
+
218
+ The hook is silent unless it actually needs to switch accounts: no matching rule, an SSH agent
219
+ that isn't running, or an identity that's already correct all do nothing. This is entirely
220
+ opt-in — `gitacc rule add` alone never touches your shell config.
221
+
187
222
  ### `gitacc autocomplete install`
188
223
 
189
224
  Install tab-completion for your shell (bash or zsh). After running, restart your shell or `source` your profile.
@@ -212,6 +247,7 @@ gitacc --version
212
247
  | Path | Purpose |
213
248
  |------|---------|
214
249
  | `~/.gitacc` | Stores all account entries (INI format) |
250
+ | `~/.gitaccrules` | Directory → account rules for `gitacc rule` (opt-in auto-switch) |
215
251
  | `~/.ssh/id_<type>_<account>` | Private SSH key per account |
216
252
  | `~/.ssh/id_<type>_<account>.pub` | Public SSH key per account |
217
253
 
@@ -226,6 +262,13 @@ gitacc --version
226
262
  sign = false
227
263
  ```
228
264
 
265
+ `~/.gitaccrules` format (one rule per line, most-specific directory wins):
266
+
267
+ ```
268
+ /home/jane/projects/work = work
269
+ /home/jane/projects/work/client-a = client-a
270
+ ```
271
+
229
272
  ## Requirements
230
273
 
231
274
  - Python 3.8+
@@ -16,6 +16,7 @@ A Python CLI tool to easily switch between multiple Git SSH accounts. Manage mul
16
16
  - Opt-in SSH commit signing per account
17
17
  - Colored terminal output with active account indicator
18
18
  - Pre-commit hook to prevent commits with the wrong account
19
+ - Opt-in directory-based auto-switching (`gitacc rule`) with a bash/zsh shell hook
19
20
  - Shell autocomplete for bash and zsh
20
21
  - Cross-platform support (Linux / macOS)
21
22
 
@@ -151,6 +152,40 @@ Kill the SSH agent and unset the global Git user config.
151
152
  gitacc logout
152
153
  ```
153
154
 
155
+ ### `gitacc rule add <account> [directory]` / `gitacc rule remove [directory]` / `gitacc rule list`
156
+
157
+ Opt-in, direnv-style directory-based auto-switching: bind an account to a directory (and
158
+ everything beneath it) so the right identity is picked automatically as you move around, instead
159
+ of running `gitacc switch` by hand. `directory` defaults to the current directory. The most
160
+ specific matching directory wins, so a rule on a subdirectory overrides one on its parent.
161
+
162
+ ```bash
163
+ gitacc rule add work ~/projects/work # anything under ~/projects/work uses "work"
164
+ gitacc rule add personal ~/projects/side # anything under ~/projects/side uses "personal"
165
+ gitacc rule list
166
+ gitacc rule remove ~/projects/side
167
+ ```
168
+
169
+ Rules by themselves don't do anything — nothing triggers on `cd` until you install the shell hook
170
+ below.
171
+
172
+ ### `gitacc rule hook <bash|zsh>`
173
+
174
+ Print a shell snippet that calls `gitacc rule check` so rules apply automatically. Add it to your
175
+ shell profile and restart your shell (or `source` it):
176
+
177
+ ```bash
178
+ # bash
179
+ gitacc rule hook bash >> ~/.bashrc
180
+
181
+ # zsh
182
+ gitacc rule hook zsh >> ~/.zshrc
183
+ ```
184
+
185
+ The hook is silent unless it actually needs to switch accounts: no matching rule, an SSH agent
186
+ that isn't running, or an identity that's already correct all do nothing. This is entirely
187
+ opt-in — `gitacc rule add` alone never touches your shell config.
188
+
154
189
  ### `gitacc autocomplete install`
155
190
 
156
191
  Install tab-completion for your shell (bash or zsh). After running, restart your shell or `source` your profile.
@@ -179,6 +214,7 @@ gitacc --version
179
214
  | Path | Purpose |
180
215
  |------|---------|
181
216
  | `~/.gitacc` | Stores all account entries (INI format) |
217
+ | `~/.gitaccrules` | Directory → account rules for `gitacc rule` (opt-in auto-switch) |
182
218
  | `~/.ssh/id_<type>_<account>` | Private SSH key per account |
183
219
  | `~/.ssh/id_<type>_<account>.pub` | Public SSH key per account |
184
220
 
@@ -193,6 +229,13 @@ gitacc --version
193
229
  sign = false
194
230
  ```
195
231
 
232
+ `~/.gitaccrules` format (one rule per line, most-specific directory wins):
233
+
234
+ ```
235
+ /home/jane/projects/work = work
236
+ /home/jane/projects/work/client-a = client-a
237
+ ```
238
+
196
239
  ## Requirements
197
240
 
198
241
  - Python 3.8+
@@ -1,4 +1,4 @@
1
1
  """Git Account Switcher - Manage multiple Git SSH accounts easily."""
2
2
 
3
- __version__ = "1.3.0"
3
+ __version__ = "1.5.0"
4
4
  __author__ = "ktechhub"
@@ -7,6 +7,7 @@ from typing import Optional, Dict, List
7
7
  from .config_manager import ConfigManager
8
8
  from .ssh_manager import SSHManager
9
9
  from .hook_manager import HookManager
10
+ from .rules_manager import RulesManager
10
11
  from .utils import echo_color, ask_yes_no, validate_ssh_key_type
11
12
 
12
13
 
@@ -17,6 +18,7 @@ class AccountManager:
17
18
  self.config_manager = ConfigManager()
18
19
  self.ssh_manager = SSHManager()
19
20
  self.hook_manager = HookManager()
21
+ self.rules_manager = RulesManager()
20
22
 
21
23
  def add_account(self, key_type: str = "rsa", sign: bool = False) -> bool:
22
24
  """Add a new Git account.
@@ -382,6 +384,35 @@ class AccountManager:
382
384
 
383
385
  return True
384
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
+
385
416
  def get_status(self, repo_path: Optional[Path] = None) -> Dict[str, object]:
386
417
  """Report the currently active Git identity and SSH agent key, and
387
418
  which registered account (if any) they match.
@@ -2,6 +2,7 @@
2
2
 
3
3
  import argparse
4
4
  import sys
5
+ from pathlib import Path
5
6
  from typing import Optional
6
7
 
7
8
  try:
@@ -12,6 +13,7 @@ except ImportError:
12
13
  from . import __version__
13
14
  from .account_manager import AccountManager
14
15
  from .completion import get_account_names
16
+ from .shell_hook import get_hook_script, get_supported_shells
15
17
  from .utils import echo_color, get_ssh_key_types
16
18
 
17
19
 
@@ -46,10 +48,12 @@ class CLI:
46
48
  self._add_list_command(subparsers)
47
49
  self._add_logout_command(subparsers)
48
50
  self._add_status_command(subparsers)
51
+ self._add_doctor_command(subparsers)
49
52
  self._add_init_command(subparsers)
50
53
  self._add_verify_command(subparsers)
51
54
  self._add_update_command(subparsers)
52
55
  self._add_autocomplete_command(subparsers)
56
+ self._add_rule_command(subparsers)
53
57
 
54
58
  # Include account names in top-level completion so `gitacc <TAB>` works for
55
59
  # the `gitacc <account>` shorthand, not just for subcommand names.
@@ -76,9 +80,12 @@ Examples:
76
80
  gitacc list List all registered accounts
77
81
  gitacc logout Logout current account
78
82
  gitacc status Show the currently active account
83
+ gitacc doctor Run health checks on the SSH agent and keys
79
84
  gitacc init myaccount Initialize repo with account validation
80
85
  gitacc verify Verify current account matches repo
81
86
  gitacc update myaccount Update Git name for an account
87
+ gitacc rule add work ~/work Auto-switch to "work" inside that directory
88
+ gitacc rule hook bash Print the shell hook (add to ~/.bashrc)
82
89
  """
83
90
 
84
91
  def _add_add_command(self, subparsers: argparse._SubParsersAction) -> None:
@@ -170,6 +177,17 @@ Examples:
170
177
  )
171
178
  parser.set_defaults(func=self._handle_status)
172
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
+
173
191
  def _add_init_command(self, subparsers: argparse._SubParsersAction) -> None:
174
192
  """Add the 'init' command parser."""
175
193
  parser = subparsers.add_parser(
@@ -254,6 +272,80 @@ Examples:
254
272
  )
255
273
  install_parser.set_defaults(func=self._handle_autocomplete_install)
256
274
 
275
+ def _add_rule_command(self, subparsers: argparse._SubParsersAction) -> None:
276
+ """Add the 'rule' command parser for directory-based auto-switching."""
277
+ parser = subparsers.add_parser(
278
+ "rule",
279
+ help="Manage directory-based account auto-switch rules",
280
+ description="Add, remove, and list rules that auto-switch accounts by directory",
281
+ )
282
+ rule_subparsers = parser.add_subparsers(
283
+ dest="rule_command",
284
+ help="Rule commands",
285
+ )
286
+
287
+ add_parser = rule_subparsers.add_parser(
288
+ "add",
289
+ help="Add or update a directory rule",
290
+ description="Auto-switch to an account whenever inside the given "
291
+ "directory (or a subdirectory of it)",
292
+ )
293
+ account_arg = add_parser.add_argument(
294
+ "account_name",
295
+ help="Account to switch to",
296
+ )
297
+ add_parser.add_argument(
298
+ "directory",
299
+ nargs="?",
300
+ default=None,
301
+ help="Directory the rule applies to (default: current directory)",
302
+ )
303
+ if argcomplete:
304
+ account_arg.completer = lambda **kwargs: get_account_names()
305
+ add_parser.set_defaults(func=self._handle_rule_add)
306
+
307
+ remove_parser = rule_subparsers.add_parser(
308
+ "remove",
309
+ help="Remove a directory rule",
310
+ )
311
+ remove_parser.add_argument(
312
+ "directory",
313
+ nargs="?",
314
+ default=None,
315
+ help="Directory whose rule should be removed (default: current directory)",
316
+ )
317
+ remove_parser.set_defaults(func=self._handle_rule_remove)
318
+
319
+ list_parser = rule_subparsers.add_parser(
320
+ "list",
321
+ help="List all directory rules",
322
+ )
323
+ list_parser.set_defaults(func=self._handle_rule_list)
324
+
325
+ hook_parser = rule_subparsers.add_parser(
326
+ "hook",
327
+ help="Print a shell hook that auto-switches on directory change",
328
+ description="Print a snippet for your shell profile that calls "
329
+ "'gitacc rule check' so rules apply automatically as you move "
330
+ "between directories",
331
+ )
332
+ hook_parser.add_argument(
333
+ "shell",
334
+ choices=get_supported_shells(),
335
+ help="Shell to generate the hook for",
336
+ )
337
+ hook_parser.set_defaults(func=self._handle_rule_hook)
338
+
339
+ check_parser = rule_subparsers.add_parser(
340
+ "check",
341
+ help="Match the current directory against rules (used by the shell hook)",
342
+ description="Intended to be called by the shell hook on every "
343
+ "prompt render, not run directly; silently does nothing unless "
344
+ "the current directory matches a rule for a different account "
345
+ "than the one currently active",
346
+ )
347
+ check_parser.set_defaults(func=self._handle_rule_check)
348
+
257
349
  def _handle_autocomplete_install(self, args: argparse.Namespace) -> int:
258
350
  """Handle the 'autocomplete install' command."""
259
351
  if not argcomplete:
@@ -437,6 +529,40 @@ Examples:
437
529
 
438
530
  return 0
439
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
+
440
566
  def _handle_init(self, args: argparse.Namespace) -> int:
441
567
  """Handle the 'init' command."""
442
568
  success = self.account_manager.init_repo(args.account_name)
@@ -457,6 +583,80 @@ Examples:
457
583
  )
458
584
  return 0 if success else 1
459
585
 
586
+ def _handle_rule_add(self, args: argparse.Namespace) -> int:
587
+ """Handle the 'rule add' command."""
588
+ if not self.account_manager.account_exists(args.account_name):
589
+ echo_color("r", f'Account "{args.account_name}" not found!')
590
+ return 1
591
+
592
+ directory = Path(args.directory) if args.directory else Path.cwd()
593
+ success = self.account_manager.rules_manager.add_rule(
594
+ directory, args.account_name
595
+ )
596
+ if success:
597
+ resolved = Path(directory).expanduser().resolve()
598
+ echo_color("g", f'Rule added: "{resolved}" → "{args.account_name}"')
599
+ else:
600
+ echo_color("r", "Failed to add rule!")
601
+ return 0 if success else 1
602
+
603
+ def _handle_rule_remove(self, args: argparse.Namespace) -> int:
604
+ """Handle the 'rule remove' command."""
605
+ directory = Path(args.directory) if args.directory else Path.cwd()
606
+ success = self.account_manager.rules_manager.remove_rule(directory)
607
+ resolved = Path(directory).expanduser().resolve()
608
+ if success:
609
+ echo_color("g", f'Rule removed for "{resolved}"')
610
+ else:
611
+ echo_color("y", f'No rule was set for "{resolved}"')
612
+ return 0 if success else 1
613
+
614
+ def _handle_rule_list(self, args: argparse.Namespace) -> int:
615
+ """Handle the 'rule list' command."""
616
+ rules = self.account_manager.rules_manager.list_rules()
617
+ if not rules:
618
+ echo_color("y", "No directory rules registered yet.")
619
+ return 0
620
+
621
+ echo_color("g", "Directory auto-switch rules:")
622
+ for directory, account_name in rules.items():
623
+ print(f" {directory} → {account_name}")
624
+ return 0
625
+
626
+ def _handle_rule_hook(self, args: argparse.Namespace) -> int:
627
+ """Handle the 'rule hook' command."""
628
+ script = get_hook_script(args.shell)
629
+ if not script:
630
+ echo_color("r", f'Unsupported shell: "{args.shell}"')
631
+ return 1
632
+ print(script)
633
+ return 0
634
+
635
+ def _handle_rule_check(self, args: argparse.Namespace) -> int:
636
+ """Handle the 'rule check' command.
637
+
638
+ Called by the installed shell hook on every prompt render, so it
639
+ must stay quiet unless there is actually something to do: no match,
640
+ an already-correct identity, and a down SSH agent (which
641
+ `switch_account` would otherwise report loudly on every prompt) are
642
+ all silent no-ops.
643
+ """
644
+ account_name = self.account_manager.rules_manager.find_matching_account(
645
+ Path.cwd()
646
+ )
647
+ if not account_name or not self.account_manager.account_exists(account_name):
648
+ return 0
649
+
650
+ status = self.account_manager.get_status()
651
+ if status.get("matched_account") == account_name:
652
+ return 0
653
+
654
+ if not self.account_manager.ssh_manager.is_ssh_agent_running():
655
+ return 0
656
+
657
+ success = self.account_manager.switch_account(account_name)
658
+ return 0 if success else 1
659
+
460
660
  def _handle_account_name_shortcut(self, account_name: str) -> int:
461
661
  """Handle account name as shortcut for switch command."""
462
662
  success = self.account_manager.switch_account(account_name)
@@ -0,0 +1,118 @@
1
+ """Directory-based auto-switch rule storage and lookup.
2
+
3
+ Rules map a directory (and everything beneath it) to the account that should
4
+ become active whenever the shell's current working directory is there. This
5
+ is the pure-Python half of the opt-in auto-switch feature; the actual
6
+ "notice I changed directories" trigger comes from a shell hook (see
7
+ `shell_hook.py`) that calls `gitacc rule check` on every prompt render.
8
+ """
9
+
10
+ import re
11
+ from pathlib import Path
12
+ from typing import Dict, Optional
13
+
14
+
15
+ class RulesManager:
16
+ """Manages directory-to-account auto-switch rules."""
17
+
18
+ def __init__(self, rules_file: Optional[Path] = None):
19
+ self.rules_file = rules_file or (Path.home() / ".gitaccrules")
20
+
21
+ def _normalize_path(self, directory: Path) -> str:
22
+ """Resolve a directory to its canonical absolute form."""
23
+ return str(Path(directory).expanduser().resolve())
24
+
25
+ def list_rules(self) -> Dict[str, str]:
26
+ """Read all rules from the rules file.
27
+
28
+ Returns:
29
+ Dictionary mapping normalized directory path to account name, in
30
+ file order. Blank lines and lines starting with '#' are ignored.
31
+ """
32
+ rules: Dict[str, str] = {}
33
+ if not self.rules_file.exists():
34
+ return rules
35
+
36
+ with open(self.rules_file, "r") as f:
37
+ for raw_line in f:
38
+ line = raw_line.strip()
39
+ if not line or line.startswith("#"):
40
+ continue
41
+ match = re.match(r"^(.+?)\s*=\s*(.+)$", line)
42
+ if not match:
43
+ continue
44
+ directory = match.group(1).strip()
45
+ account_name = match.group(2).strip()
46
+ if directory and account_name:
47
+ rules[directory] = account_name
48
+
49
+ return rules
50
+
51
+ def _write_rules(self, rules: Dict[str, str]) -> None:
52
+ with open(self.rules_file, "w") as f:
53
+ for directory, account_name in rules.items():
54
+ f.write(f"{directory} = {account_name}\n")
55
+
56
+ def add_rule(self, directory: Path, account_name: str) -> bool:
57
+ """Add or update the rule for a directory.
58
+
59
+ Args:
60
+ directory: Directory the rule applies to (and its subdirectories)
61
+ account_name: Account to switch to while inside that directory
62
+
63
+ Returns:
64
+ True if successful, False otherwise
65
+ """
66
+ try:
67
+ normalized = self._normalize_path(directory)
68
+ rules = self.list_rules()
69
+ rules[normalized] = account_name
70
+ self._write_rules(rules)
71
+ return True
72
+ except OSError:
73
+ return False
74
+
75
+ def remove_rule(self, directory: Path) -> bool:
76
+ """Remove the rule for a directory, if one exists.
77
+
78
+ Args:
79
+ directory: Directory whose rule should be removed
80
+
81
+ Returns:
82
+ True if a rule was removed, False if none existed or on error
83
+ """
84
+ try:
85
+ normalized = self._normalize_path(directory)
86
+ rules = self.list_rules()
87
+ if normalized not in rules:
88
+ return False
89
+ del rules[normalized]
90
+ self._write_rules(rules)
91
+ return True
92
+ except OSError:
93
+ return False
94
+
95
+ def find_matching_account(self, cwd: Path) -> Optional[str]:
96
+ """Find the account whose rule most specifically matches a directory.
97
+
98
+ Walks from the given directory up through its parents, direnv-style,
99
+ and returns the account for the first (deepest, most specific) rule
100
+ directory that is the given directory or an ancestor of it.
101
+
102
+ Args:
103
+ cwd: Directory to match against registered rules
104
+
105
+ Returns:
106
+ Matching account name, or None if no rule applies
107
+ """
108
+ rules = self.list_rules()
109
+ if not rules:
110
+ return None
111
+
112
+ current = Path(cwd).expanduser().resolve()
113
+ for directory in [current, *current.parents]:
114
+ account_name = rules.get(str(directory))
115
+ if account_name:
116
+ return account_name
117
+
118
+ return None
@@ -0,0 +1,51 @@
1
+ """Shell hook snippets for directory-based auto-switching.
2
+
3
+ These are opt-in: nothing in gitacc installs them automatically. A user adds
4
+ the output of `gitacc rule hook <shell>` to their shell profile, which then
5
+ calls `gitacc rule check` whenever the prompt is rendered (bash) or the
6
+ working directory changes (zsh), so the active account follows whatever
7
+ rules were registered with `gitacc rule add`.
8
+ """
9
+
10
+ from typing import Optional
11
+
12
+ _BASH_HOOK = """\
13
+ _gitacc_hook() {
14
+ local previous_exit_status=$?
15
+ command gitacc rule check
16
+ return $previous_exit_status
17
+ }
18
+ if [[ ";${PROMPT_COMMAND:-};" != *";_gitacc_hook;"* ]]; then
19
+ PROMPT_COMMAND="_gitacc_hook${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
20
+ fi"""
21
+
22
+ _ZSH_HOOK = """\
23
+ _gitacc_hook() {
24
+ command gitacc rule check
25
+ }
26
+ if [[ -z "${chpwd_functions[(r)_gitacc_hook]}" ]]; then
27
+ chpwd_functions+=(_gitacc_hook)
28
+ fi
29
+ _gitacc_hook"""
30
+
31
+ _HOOKS = {
32
+ "bash": _BASH_HOOK,
33
+ "zsh": _ZSH_HOOK,
34
+ }
35
+
36
+
37
+ def get_hook_script(shell: str) -> Optional[str]:
38
+ """Get the shell hook snippet for a given shell.
39
+
40
+ Args:
41
+ shell: Shell name ("bash" or "zsh")
42
+
43
+ Returns:
44
+ Hook script text, or None if the shell isn't supported
45
+ """
46
+ return _HOOKS.get(shell.lower())
47
+
48
+
49
+ def get_supported_shells() -> list:
50
+ """Get the list of shells a hook script can be generated for."""
51
+ return list(_HOOKS.keys())
@@ -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