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.
- {gitacc_switcher-1.4.0/gitacc_switcher.egg-info → gitacc_switcher-1.6.0}/PKG-INFO +26 -1
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/README.md +25 -0
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher/__init__.py +1 -1
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher/account_manager.py +95 -0
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher/cli.py +136 -0
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher/ssh_manager.py +190 -1
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0/gitacc_switcher.egg-info}/PKG-INFO +26 -1
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/pyproject.toml +1 -1
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/setup.py +1 -1
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/tests/test_account_manager.py +122 -0
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/tests/test_cli.py +180 -0
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/tests/test_ssh_manager.py +157 -0
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/LICENSE +0 -0
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/MANIFEST.in +0 -0
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher/completion.py +0 -0
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher/config_manager.py +0 -0
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher/hook_manager.py +0 -0
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher/rules_manager.py +0 -0
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher/shell_hook.py +0 -0
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher/utils.py +0 -0
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher.egg-info/SOURCES.txt +0 -0
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher.egg-info/dependency_links.txt +0 -0
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher.egg-info/entry_points.txt +0 -0
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher.egg-info/requires.txt +0 -0
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher.egg-info/top_level.txt +0 -0
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/requirements.txt +0 -0
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/setup.cfg +0 -0
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/tests/__init__.py +0 -0
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/tests/test_completion.py +0 -0
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/tests/test_config_manager.py +0 -0
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/tests/test_hook_manager.py +0 -0
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/tests/test_rules_manager.py +0 -0
- {gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/tests/test_shell_hook.py +0 -0
- {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.
|
|
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
|
|
|
@@ -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.
|
|
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
|
|
|
@@ -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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gitacc_switcher-1.4.0 → gitacc_switcher-1.6.0}/gitacc_switcher.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|