gitacc-switcher 1.3.0__tar.gz → 1.4.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.3.0/gitacc_switcher.egg-info → gitacc_switcher-1.4.0}/PKG-INFO +44 -1
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/README.md +43 -0
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/gitacc_switcher/__init__.py +1 -1
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/gitacc_switcher/account_manager.py +2 -0
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/gitacc_switcher/cli.py +153 -0
- gitacc_switcher-1.4.0/gitacc_switcher/rules_manager.py +118 -0
- gitacc_switcher-1.4.0/gitacc_switcher/shell_hook.py +51 -0
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0/gitacc_switcher.egg-info}/PKG-INFO +44 -1
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/gitacc_switcher.egg-info/SOURCES.txt +4 -0
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/pyproject.toml +1 -1
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/setup.py +1 -1
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/tests/test_cli.py +140 -0
- gitacc_switcher-1.4.0/tests/test_rules_manager.py +142 -0
- gitacc_switcher-1.4.0/tests/test_shell_hook.py +34 -0
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/LICENSE +0 -0
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/MANIFEST.in +0 -0
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/gitacc_switcher/completion.py +0 -0
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/gitacc_switcher/config_manager.py +0 -0
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/gitacc_switcher/hook_manager.py +0 -0
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/gitacc_switcher/ssh_manager.py +0 -0
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/gitacc_switcher/utils.py +0 -0
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/gitacc_switcher.egg-info/dependency_links.txt +0 -0
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/gitacc_switcher.egg-info/entry_points.txt +0 -0
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/gitacc_switcher.egg-info/requires.txt +0 -0
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/gitacc_switcher.egg-info/top_level.txt +0 -0
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/requirements.txt +0 -0
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/setup.cfg +0 -0
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/tests/__init__.py +0 -0
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/tests/test_account_manager.py +0 -0
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/tests/test_completion.py +0 -0
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/tests/test_config_manager.py +0 -0
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/tests/test_hook_manager.py +0 -0
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.0}/tests/test_ssh_manager.py +0 -0
- {gitacc_switcher-1.3.0 → gitacc_switcher-1.4.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.4.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+
|
|
@@ -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.
|
|
@@ -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
|
|
|
@@ -50,6 +52,7 @@ class CLI:
|
|
|
50
52
|
self._add_verify_command(subparsers)
|
|
51
53
|
self._add_update_command(subparsers)
|
|
52
54
|
self._add_autocomplete_command(subparsers)
|
|
55
|
+
self._add_rule_command(subparsers)
|
|
53
56
|
|
|
54
57
|
# Include account names in top-level completion so `gitacc <TAB>` works for
|
|
55
58
|
# the `gitacc <account>` shorthand, not just for subcommand names.
|
|
@@ -79,6 +82,8 @@ Examples:
|
|
|
79
82
|
gitacc init myaccount Initialize repo with account validation
|
|
80
83
|
gitacc verify Verify current account matches repo
|
|
81
84
|
gitacc update myaccount Update Git name for an account
|
|
85
|
+
gitacc rule add work ~/work Auto-switch to "work" inside that directory
|
|
86
|
+
gitacc rule hook bash Print the shell hook (add to ~/.bashrc)
|
|
82
87
|
"""
|
|
83
88
|
|
|
84
89
|
def _add_add_command(self, subparsers: argparse._SubParsersAction) -> None:
|
|
@@ -254,6 +259,80 @@ Examples:
|
|
|
254
259
|
)
|
|
255
260
|
install_parser.set_defaults(func=self._handle_autocomplete_install)
|
|
256
261
|
|
|
262
|
+
def _add_rule_command(self, subparsers: argparse._SubParsersAction) -> None:
|
|
263
|
+
"""Add the 'rule' command parser for directory-based auto-switching."""
|
|
264
|
+
parser = subparsers.add_parser(
|
|
265
|
+
"rule",
|
|
266
|
+
help="Manage directory-based account auto-switch rules",
|
|
267
|
+
description="Add, remove, and list rules that auto-switch accounts by directory",
|
|
268
|
+
)
|
|
269
|
+
rule_subparsers = parser.add_subparsers(
|
|
270
|
+
dest="rule_command",
|
|
271
|
+
help="Rule commands",
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
add_parser = rule_subparsers.add_parser(
|
|
275
|
+
"add",
|
|
276
|
+
help="Add or update a directory rule",
|
|
277
|
+
description="Auto-switch to an account whenever inside the given "
|
|
278
|
+
"directory (or a subdirectory of it)",
|
|
279
|
+
)
|
|
280
|
+
account_arg = add_parser.add_argument(
|
|
281
|
+
"account_name",
|
|
282
|
+
help="Account to switch to",
|
|
283
|
+
)
|
|
284
|
+
add_parser.add_argument(
|
|
285
|
+
"directory",
|
|
286
|
+
nargs="?",
|
|
287
|
+
default=None,
|
|
288
|
+
help="Directory the rule applies to (default: current directory)",
|
|
289
|
+
)
|
|
290
|
+
if argcomplete:
|
|
291
|
+
account_arg.completer = lambda **kwargs: get_account_names()
|
|
292
|
+
add_parser.set_defaults(func=self._handle_rule_add)
|
|
293
|
+
|
|
294
|
+
remove_parser = rule_subparsers.add_parser(
|
|
295
|
+
"remove",
|
|
296
|
+
help="Remove a directory rule",
|
|
297
|
+
)
|
|
298
|
+
remove_parser.add_argument(
|
|
299
|
+
"directory",
|
|
300
|
+
nargs="?",
|
|
301
|
+
default=None,
|
|
302
|
+
help="Directory whose rule should be removed (default: current directory)",
|
|
303
|
+
)
|
|
304
|
+
remove_parser.set_defaults(func=self._handle_rule_remove)
|
|
305
|
+
|
|
306
|
+
list_parser = rule_subparsers.add_parser(
|
|
307
|
+
"list",
|
|
308
|
+
help="List all directory rules",
|
|
309
|
+
)
|
|
310
|
+
list_parser.set_defaults(func=self._handle_rule_list)
|
|
311
|
+
|
|
312
|
+
hook_parser = rule_subparsers.add_parser(
|
|
313
|
+
"hook",
|
|
314
|
+
help="Print a shell hook that auto-switches on directory change",
|
|
315
|
+
description="Print a snippet for your shell profile that calls "
|
|
316
|
+
"'gitacc rule check' so rules apply automatically as you move "
|
|
317
|
+
"between directories",
|
|
318
|
+
)
|
|
319
|
+
hook_parser.add_argument(
|
|
320
|
+
"shell",
|
|
321
|
+
choices=get_supported_shells(),
|
|
322
|
+
help="Shell to generate the hook for",
|
|
323
|
+
)
|
|
324
|
+
hook_parser.set_defaults(func=self._handle_rule_hook)
|
|
325
|
+
|
|
326
|
+
check_parser = rule_subparsers.add_parser(
|
|
327
|
+
"check",
|
|
328
|
+
help="Match the current directory against rules (used by the shell hook)",
|
|
329
|
+
description="Intended to be called by the shell hook on every "
|
|
330
|
+
"prompt render, not run directly; silently does nothing unless "
|
|
331
|
+
"the current directory matches a rule for a different account "
|
|
332
|
+
"than the one currently active",
|
|
333
|
+
)
|
|
334
|
+
check_parser.set_defaults(func=self._handle_rule_check)
|
|
335
|
+
|
|
257
336
|
def _handle_autocomplete_install(self, args: argparse.Namespace) -> int:
|
|
258
337
|
"""Handle the 'autocomplete install' command."""
|
|
259
338
|
if not argcomplete:
|
|
@@ -457,6 +536,80 @@ Examples:
|
|
|
457
536
|
)
|
|
458
537
|
return 0 if success else 1
|
|
459
538
|
|
|
539
|
+
def _handle_rule_add(self, args: argparse.Namespace) -> int:
|
|
540
|
+
"""Handle the 'rule add' command."""
|
|
541
|
+
if not self.account_manager.account_exists(args.account_name):
|
|
542
|
+
echo_color("r", f'Account "{args.account_name}" not found!')
|
|
543
|
+
return 1
|
|
544
|
+
|
|
545
|
+
directory = Path(args.directory) if args.directory else Path.cwd()
|
|
546
|
+
success = self.account_manager.rules_manager.add_rule(
|
|
547
|
+
directory, args.account_name
|
|
548
|
+
)
|
|
549
|
+
if success:
|
|
550
|
+
resolved = Path(directory).expanduser().resolve()
|
|
551
|
+
echo_color("g", f'Rule added: "{resolved}" → "{args.account_name}"')
|
|
552
|
+
else:
|
|
553
|
+
echo_color("r", "Failed to add rule!")
|
|
554
|
+
return 0 if success else 1
|
|
555
|
+
|
|
556
|
+
def _handle_rule_remove(self, args: argparse.Namespace) -> int:
|
|
557
|
+
"""Handle the 'rule remove' command."""
|
|
558
|
+
directory = Path(args.directory) if args.directory else Path.cwd()
|
|
559
|
+
success = self.account_manager.rules_manager.remove_rule(directory)
|
|
560
|
+
resolved = Path(directory).expanduser().resolve()
|
|
561
|
+
if success:
|
|
562
|
+
echo_color("g", f'Rule removed for "{resolved}"')
|
|
563
|
+
else:
|
|
564
|
+
echo_color("y", f'No rule was set for "{resolved}"')
|
|
565
|
+
return 0 if success else 1
|
|
566
|
+
|
|
567
|
+
def _handle_rule_list(self, args: argparse.Namespace) -> int:
|
|
568
|
+
"""Handle the 'rule list' command."""
|
|
569
|
+
rules = self.account_manager.rules_manager.list_rules()
|
|
570
|
+
if not rules:
|
|
571
|
+
echo_color("y", "No directory rules registered yet.")
|
|
572
|
+
return 0
|
|
573
|
+
|
|
574
|
+
echo_color("g", "Directory auto-switch rules:")
|
|
575
|
+
for directory, account_name in rules.items():
|
|
576
|
+
print(f" {directory} → {account_name}")
|
|
577
|
+
return 0
|
|
578
|
+
|
|
579
|
+
def _handle_rule_hook(self, args: argparse.Namespace) -> int:
|
|
580
|
+
"""Handle the 'rule hook' command."""
|
|
581
|
+
script = get_hook_script(args.shell)
|
|
582
|
+
if not script:
|
|
583
|
+
echo_color("r", f'Unsupported shell: "{args.shell}"')
|
|
584
|
+
return 1
|
|
585
|
+
print(script)
|
|
586
|
+
return 0
|
|
587
|
+
|
|
588
|
+
def _handle_rule_check(self, args: argparse.Namespace) -> int:
|
|
589
|
+
"""Handle the 'rule check' command.
|
|
590
|
+
|
|
591
|
+
Called by the installed shell hook on every prompt render, so it
|
|
592
|
+
must stay quiet unless there is actually something to do: no match,
|
|
593
|
+
an already-correct identity, and a down SSH agent (which
|
|
594
|
+
`switch_account` would otherwise report loudly on every prompt) are
|
|
595
|
+
all silent no-ops.
|
|
596
|
+
"""
|
|
597
|
+
account_name = self.account_manager.rules_manager.find_matching_account(
|
|
598
|
+
Path.cwd()
|
|
599
|
+
)
|
|
600
|
+
if not account_name or not self.account_manager.account_exists(account_name):
|
|
601
|
+
return 0
|
|
602
|
+
|
|
603
|
+
status = self.account_manager.get_status()
|
|
604
|
+
if status.get("matched_account") == account_name:
|
|
605
|
+
return 0
|
|
606
|
+
|
|
607
|
+
if not self.account_manager.ssh_manager.is_ssh_agent_running():
|
|
608
|
+
return 0
|
|
609
|
+
|
|
610
|
+
success = self.account_manager.switch_account(account_name)
|
|
611
|
+
return 0 if success else 1
|
|
612
|
+
|
|
460
613
|
def _handle_account_name_shortcut(self, account_name: str) -> int:
|
|
461
614
|
"""Handle account name as shortcut for switch command."""
|
|
462
615
|
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())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitacc-switcher
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.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+
|
|
@@ -10,6 +10,8 @@ gitacc_switcher/cli.py
|
|
|
10
10
|
gitacc_switcher/completion.py
|
|
11
11
|
gitacc_switcher/config_manager.py
|
|
12
12
|
gitacc_switcher/hook_manager.py
|
|
13
|
+
gitacc_switcher/rules_manager.py
|
|
14
|
+
gitacc_switcher/shell_hook.py
|
|
13
15
|
gitacc_switcher/ssh_manager.py
|
|
14
16
|
gitacc_switcher/utils.py
|
|
15
17
|
gitacc_switcher.egg-info/PKG-INFO
|
|
@@ -24,5 +26,7 @@ tests/test_cli.py
|
|
|
24
26
|
tests/test_completion.py
|
|
25
27
|
tests/test_config_manager.py
|
|
26
28
|
tests/test_hook_manager.py
|
|
29
|
+
tests/test_rules_manager.py
|
|
30
|
+
tests/test_shell_hook.py
|
|
27
31
|
tests/test_ssh_manager.py
|
|
28
32
|
tests/test_utils.py
|
|
@@ -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.4.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",
|
|
@@ -354,3 +354,143 @@ class TestRun:
|
|
|
354
354
|
with patch.object(sys, "argv", ["gitacc", "status"]):
|
|
355
355
|
result = cli.run()
|
|
356
356
|
assert result == 0
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ---------------------------------------------------------------------------
|
|
360
|
+
# `rule` command parsing
|
|
361
|
+
# ---------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
class TestRuleParserCommands:
|
|
365
|
+
def test_rule_add_parses_account_and_directory(self, cli):
|
|
366
|
+
args = cli.parser.parse_args(["rule", "add", "work", "/tmp/work"])
|
|
367
|
+
assert args.command == "rule"
|
|
368
|
+
assert args.rule_command == "add"
|
|
369
|
+
assert args.account_name == "work"
|
|
370
|
+
assert args.directory == "/tmp/work"
|
|
371
|
+
|
|
372
|
+
def test_rule_add_directory_defaults_to_none(self, cli):
|
|
373
|
+
args = cli.parser.parse_args(["rule", "add", "work"])
|
|
374
|
+
assert args.directory is None
|
|
375
|
+
|
|
376
|
+
def test_rule_remove_parses_directory(self, cli):
|
|
377
|
+
args = cli.parser.parse_args(["rule", "remove", "/tmp/work"])
|
|
378
|
+
assert args.directory == "/tmp/work"
|
|
379
|
+
|
|
380
|
+
def test_rule_remove_directory_defaults_to_none(self, cli):
|
|
381
|
+
args = cli.parser.parse_args(["rule", "remove"])
|
|
382
|
+
assert args.directory is None
|
|
383
|
+
|
|
384
|
+
def test_rule_list_command(self, cli):
|
|
385
|
+
args = cli.parser.parse_args(["rule", "list"])
|
|
386
|
+
assert args.rule_command == "list"
|
|
387
|
+
|
|
388
|
+
def test_rule_hook_parses_shell(self, cli):
|
|
389
|
+
args = cli.parser.parse_args(["rule", "hook", "zsh"])
|
|
390
|
+
assert args.shell == "zsh"
|
|
391
|
+
|
|
392
|
+
def test_rule_hook_rejects_unsupported_shell(self, cli):
|
|
393
|
+
with pytest.raises(SystemExit):
|
|
394
|
+
cli.parser.parse_args(["rule", "hook", "fish"])
|
|
395
|
+
|
|
396
|
+
def test_rule_check_command(self, cli):
|
|
397
|
+
args = cli.parser.parse_args(["rule", "check"])
|
|
398
|
+
assert args.rule_command == "check"
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ---------------------------------------------------------------------------
|
|
402
|
+
# `rule` command handlers
|
|
403
|
+
# ---------------------------------------------------------------------------
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class TestRuleHandlers:
|
|
407
|
+
def test_handle_rule_add_success(self, cli):
|
|
408
|
+
cli.account_manager.account_exists.return_value = True
|
|
409
|
+
cli.account_manager.rules_manager.add_rule.return_value = True
|
|
410
|
+
args = argparse.Namespace(account_name="work", directory="/tmp/work")
|
|
411
|
+
assert cli._handle_rule_add(args) == 0
|
|
412
|
+
|
|
413
|
+
def test_handle_rule_add_unknown_account(self, cli):
|
|
414
|
+
cli.account_manager.account_exists.return_value = False
|
|
415
|
+
args = argparse.Namespace(account_name="ghost", directory=None)
|
|
416
|
+
assert cli._handle_rule_add(args) == 1
|
|
417
|
+
cli.account_manager.rules_manager.add_rule.assert_not_called()
|
|
418
|
+
|
|
419
|
+
def test_handle_rule_add_failure(self, cli):
|
|
420
|
+
cli.account_manager.account_exists.return_value = True
|
|
421
|
+
cli.account_manager.rules_manager.add_rule.return_value = False
|
|
422
|
+
args = argparse.Namespace(account_name="work", directory="/tmp/work")
|
|
423
|
+
assert cli._handle_rule_add(args) == 1
|
|
424
|
+
|
|
425
|
+
def test_handle_rule_remove_success(self, cli):
|
|
426
|
+
cli.account_manager.rules_manager.remove_rule.return_value = True
|
|
427
|
+
args = argparse.Namespace(directory="/tmp/work")
|
|
428
|
+
assert cli._handle_rule_remove(args) == 0
|
|
429
|
+
|
|
430
|
+
def test_handle_rule_remove_no_rule(self, cli):
|
|
431
|
+
cli.account_manager.rules_manager.remove_rule.return_value = False
|
|
432
|
+
args = argparse.Namespace(directory="/tmp/work")
|
|
433
|
+
assert cli._handle_rule_remove(args) == 1
|
|
434
|
+
|
|
435
|
+
def test_handle_rule_list_empty(self, cli):
|
|
436
|
+
cli.account_manager.rules_manager.list_rules.return_value = {}
|
|
437
|
+
assert cli._handle_rule_list(argparse.Namespace()) == 0
|
|
438
|
+
|
|
439
|
+
def test_handle_rule_list_with_rules(self, cli, capsys):
|
|
440
|
+
cli.account_manager.rules_manager.list_rules.return_value = {
|
|
441
|
+
"/home/jane/work": "work"
|
|
442
|
+
}
|
|
443
|
+
result = cli._handle_rule_list(argparse.Namespace())
|
|
444
|
+
assert result == 0
|
|
445
|
+
assert "/home/jane/work" in capsys.readouterr().out
|
|
446
|
+
|
|
447
|
+
def test_handle_rule_hook_prints_script(self, cli, capsys):
|
|
448
|
+
result = cli._handle_rule_hook(argparse.Namespace(shell="bash"))
|
|
449
|
+
assert result == 0
|
|
450
|
+
assert "gitacc rule check" in capsys.readouterr().out
|
|
451
|
+
|
|
452
|
+
def test_handle_rule_hook_unsupported_shell(self, cli):
|
|
453
|
+
assert cli._handle_rule_hook(argparse.Namespace(shell="fish")) == 1
|
|
454
|
+
|
|
455
|
+
def test_handle_rule_check_no_match_is_noop(self, cli):
|
|
456
|
+
cli.account_manager.rules_manager.find_matching_account.return_value = None
|
|
457
|
+
assert cli._handle_rule_check(argparse.Namespace()) == 0
|
|
458
|
+
cli.account_manager.switch_account.assert_not_called()
|
|
459
|
+
|
|
460
|
+
def test_handle_rule_check_unknown_account_is_noop(self, cli):
|
|
461
|
+
cli.account_manager.rules_manager.find_matching_account.return_value = "ghost"
|
|
462
|
+
cli.account_manager.account_exists.return_value = False
|
|
463
|
+
assert cli._handle_rule_check(argparse.Namespace()) == 0
|
|
464
|
+
cli.account_manager.switch_account.assert_not_called()
|
|
465
|
+
|
|
466
|
+
def test_handle_rule_check_already_matching_is_noop(self, cli):
|
|
467
|
+
cli.account_manager.rules_manager.find_matching_account.return_value = "work"
|
|
468
|
+
cli.account_manager.account_exists.return_value = True
|
|
469
|
+
cli.account_manager.get_status.return_value = {"matched_account": "work"}
|
|
470
|
+
assert cli._handle_rule_check(argparse.Namespace()) == 0
|
|
471
|
+
cli.account_manager.switch_account.assert_not_called()
|
|
472
|
+
|
|
473
|
+
def test_handle_rule_check_agent_down_is_noop(self, cli):
|
|
474
|
+
cli.account_manager.rules_manager.find_matching_account.return_value = "work"
|
|
475
|
+
cli.account_manager.account_exists.return_value = True
|
|
476
|
+
cli.account_manager.get_status.return_value = {"matched_account": "personal"}
|
|
477
|
+
cli.account_manager.ssh_manager.is_ssh_agent_running.return_value = False
|
|
478
|
+
assert cli._handle_rule_check(argparse.Namespace()) == 0
|
|
479
|
+
cli.account_manager.switch_account.assert_not_called()
|
|
480
|
+
|
|
481
|
+
def test_handle_rule_check_switches_when_mismatched(self, cli):
|
|
482
|
+
cli.account_manager.rules_manager.find_matching_account.return_value = "work"
|
|
483
|
+
cli.account_manager.account_exists.return_value = True
|
|
484
|
+
cli.account_manager.get_status.return_value = {"matched_account": "personal"}
|
|
485
|
+
cli.account_manager.ssh_manager.is_ssh_agent_running.return_value = True
|
|
486
|
+
cli.account_manager.switch_account.return_value = True
|
|
487
|
+
assert cli._handle_rule_check(argparse.Namespace()) == 0
|
|
488
|
+
cli.account_manager.switch_account.assert_called_once_with("work")
|
|
489
|
+
|
|
490
|
+
def test_handle_rule_check_switch_failure(self, cli):
|
|
491
|
+
cli.account_manager.rules_manager.find_matching_account.return_value = "work"
|
|
492
|
+
cli.account_manager.account_exists.return_value = True
|
|
493
|
+
cli.account_manager.get_status.return_value = {"matched_account": "personal"}
|
|
494
|
+
cli.account_manager.ssh_manager.is_ssh_agent_running.return_value = True
|
|
495
|
+
cli.account_manager.switch_account.return_value = False
|
|
496
|
+
assert cli._handle_rule_check(argparse.Namespace()) == 1
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Tests for directory-based auto-switch rule storage and lookup."""
|
|
2
|
+
|
|
3
|
+
from gitacc_switcher.rules_manager import RulesManager
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def make_manager(tmp_path):
|
|
7
|
+
return RulesManager(rules_file=tmp_path / ".gitaccrules")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestListRules:
|
|
11
|
+
def test_empty_when_file_missing(self, tmp_path):
|
|
12
|
+
manager = make_manager(tmp_path)
|
|
13
|
+
assert manager.list_rules() == {}
|
|
14
|
+
|
|
15
|
+
def test_reads_added_entries(self, tmp_path):
|
|
16
|
+
manager = make_manager(tmp_path)
|
|
17
|
+
work_dir = tmp_path / "work"
|
|
18
|
+
work_dir.mkdir()
|
|
19
|
+
manager.add_rule(work_dir, "work")
|
|
20
|
+
|
|
21
|
+
rules = manager.list_rules()
|
|
22
|
+
assert rules == {str(work_dir.resolve()): "work"}
|
|
23
|
+
|
|
24
|
+
def test_ignores_blank_lines_and_comments(self, tmp_path):
|
|
25
|
+
rules_file = tmp_path / ".gitaccrules"
|
|
26
|
+
rules_file.write_text("\n# a comment\n" + f"{tmp_path} = personal\n" + "\n")
|
|
27
|
+
manager = RulesManager(rules_file=rules_file)
|
|
28
|
+
|
|
29
|
+
assert manager.list_rules() == {str(tmp_path): "personal"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestAddRule:
|
|
33
|
+
def test_creates_file_when_missing(self, tmp_path):
|
|
34
|
+
manager = make_manager(tmp_path)
|
|
35
|
+
assert not manager.rules_file.exists()
|
|
36
|
+
|
|
37
|
+
assert manager.add_rule(tmp_path, "work") is True
|
|
38
|
+
assert manager.rules_file.exists()
|
|
39
|
+
|
|
40
|
+
def test_normalizes_relative_path(self, tmp_path, monkeypatch):
|
|
41
|
+
sub_dir = tmp_path / "project"
|
|
42
|
+
sub_dir.mkdir()
|
|
43
|
+
monkeypatch.chdir(tmp_path)
|
|
44
|
+
|
|
45
|
+
manager = RulesManager(rules_file=tmp_path / "rules")
|
|
46
|
+
manager.add_rule("project", "work")
|
|
47
|
+
|
|
48
|
+
assert manager.list_rules() == {str(sub_dir.resolve()): "work"}
|
|
49
|
+
|
|
50
|
+
def test_updates_existing_rule_for_same_directory(self, tmp_path):
|
|
51
|
+
manager = make_manager(tmp_path)
|
|
52
|
+
manager.add_rule(tmp_path, "work")
|
|
53
|
+
manager.add_rule(tmp_path, "personal")
|
|
54
|
+
|
|
55
|
+
rules = manager.list_rules()
|
|
56
|
+
assert rules[str(tmp_path.resolve())] == "personal"
|
|
57
|
+
assert len(rules) == 1
|
|
58
|
+
|
|
59
|
+
def test_supports_multiple_directories(self, tmp_path):
|
|
60
|
+
manager = make_manager(tmp_path)
|
|
61
|
+
work_dir = tmp_path / "work"
|
|
62
|
+
personal_dir = tmp_path / "personal"
|
|
63
|
+
work_dir.mkdir()
|
|
64
|
+
personal_dir.mkdir()
|
|
65
|
+
|
|
66
|
+
manager.add_rule(work_dir, "work")
|
|
67
|
+
manager.add_rule(personal_dir, "personal")
|
|
68
|
+
|
|
69
|
+
rules = manager.list_rules()
|
|
70
|
+
assert rules[str(work_dir.resolve())] == "work"
|
|
71
|
+
assert rules[str(personal_dir.resolve())] == "personal"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TestRemoveRule:
|
|
75
|
+
def test_removes_existing_rule(self, tmp_path):
|
|
76
|
+
manager = make_manager(tmp_path)
|
|
77
|
+
manager.add_rule(tmp_path, "work")
|
|
78
|
+
|
|
79
|
+
assert manager.remove_rule(tmp_path) is True
|
|
80
|
+
assert manager.list_rules() == {}
|
|
81
|
+
|
|
82
|
+
def test_returns_false_when_no_rule_exists(self, tmp_path):
|
|
83
|
+
manager = make_manager(tmp_path)
|
|
84
|
+
assert manager.remove_rule(tmp_path) is False
|
|
85
|
+
|
|
86
|
+
def test_leaves_other_rules_intact(self, tmp_path):
|
|
87
|
+
manager = make_manager(tmp_path)
|
|
88
|
+
work_dir = tmp_path / "work"
|
|
89
|
+
personal_dir = tmp_path / "personal"
|
|
90
|
+
work_dir.mkdir()
|
|
91
|
+
personal_dir.mkdir()
|
|
92
|
+
manager.add_rule(work_dir, "work")
|
|
93
|
+
manager.add_rule(personal_dir, "personal")
|
|
94
|
+
|
|
95
|
+
manager.remove_rule(work_dir)
|
|
96
|
+
|
|
97
|
+
rules = manager.list_rules()
|
|
98
|
+
assert str(work_dir.resolve()) not in rules
|
|
99
|
+
assert rules[str(personal_dir.resolve())] == "personal"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TestFindMatchingAccount:
|
|
103
|
+
def test_no_match_when_no_rules(self, tmp_path):
|
|
104
|
+
manager = make_manager(tmp_path)
|
|
105
|
+
assert manager.find_matching_account(tmp_path) is None
|
|
106
|
+
|
|
107
|
+
def test_exact_directory_match(self, tmp_path):
|
|
108
|
+
manager = make_manager(tmp_path)
|
|
109
|
+
manager.add_rule(tmp_path, "work")
|
|
110
|
+
|
|
111
|
+
assert manager.find_matching_account(tmp_path) == "work"
|
|
112
|
+
|
|
113
|
+
def test_subdirectory_matches_parent_rule(self, tmp_path):
|
|
114
|
+
manager = make_manager(tmp_path)
|
|
115
|
+
work_dir = tmp_path / "work"
|
|
116
|
+
nested = work_dir / "repo" / "src"
|
|
117
|
+
nested.mkdir(parents=True)
|
|
118
|
+
manager.add_rule(work_dir, "work")
|
|
119
|
+
|
|
120
|
+
assert manager.find_matching_account(nested) == "work"
|
|
121
|
+
|
|
122
|
+
def test_unrelated_directory_does_not_match(self, tmp_path):
|
|
123
|
+
manager = make_manager(tmp_path)
|
|
124
|
+
work_dir = tmp_path / "work"
|
|
125
|
+
other_dir = tmp_path / "other"
|
|
126
|
+
work_dir.mkdir()
|
|
127
|
+
other_dir.mkdir()
|
|
128
|
+
manager.add_rule(work_dir, "work")
|
|
129
|
+
|
|
130
|
+
assert manager.find_matching_account(other_dir) is None
|
|
131
|
+
|
|
132
|
+
def test_most_specific_rule_wins(self, tmp_path):
|
|
133
|
+
manager = make_manager(tmp_path)
|
|
134
|
+
work_dir = tmp_path / "work"
|
|
135
|
+
client_dir = work_dir / "client-a"
|
|
136
|
+
client_dir.mkdir(parents=True)
|
|
137
|
+
manager.add_rule(work_dir, "work")
|
|
138
|
+
manager.add_rule(client_dir, "client-a")
|
|
139
|
+
|
|
140
|
+
assert manager.find_matching_account(client_dir) == "client-a"
|
|
141
|
+
assert manager.find_matching_account(work_dir) == "work"
|
|
142
|
+
assert manager.find_matching_account(client_dir / "src") == "client-a"
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Tests for shell hook snippet generation."""
|
|
2
|
+
|
|
3
|
+
from gitacc_switcher.shell_hook import get_hook_script, get_supported_shells
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestGetHookScript:
|
|
7
|
+
def test_bash_hook_calls_rule_check(self):
|
|
8
|
+
script = get_hook_script("bash")
|
|
9
|
+
assert script is not None
|
|
10
|
+
assert "gitacc rule check" in script
|
|
11
|
+
assert "PROMPT_COMMAND" in script
|
|
12
|
+
|
|
13
|
+
def test_zsh_hook_calls_rule_check(self):
|
|
14
|
+
script = get_hook_script("zsh")
|
|
15
|
+
assert script is not None
|
|
16
|
+
assert "gitacc rule check" in script
|
|
17
|
+
assert "chpwd_functions" in script
|
|
18
|
+
|
|
19
|
+
def test_is_case_insensitive(self):
|
|
20
|
+
assert get_hook_script("Bash") == get_hook_script("bash")
|
|
21
|
+
|
|
22
|
+
def test_unsupported_shell_returns_none(self):
|
|
23
|
+
assert get_hook_script("fish") is None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestGetSupportedShells:
|
|
27
|
+
def test_includes_bash_and_zsh(self):
|
|
28
|
+
shells = get_supported_shells()
|
|
29
|
+
assert "bash" in shells
|
|
30
|
+
assert "zsh" in shells
|
|
31
|
+
|
|
32
|
+
def test_every_supported_shell_has_a_script(self):
|
|
33
|
+
for shell in get_supported_shells():
|
|
34
|
+
assert get_hook_script(shell)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{gitacc_switcher-1.3.0 → gitacc_switcher-1.4.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
|