gitacc-switcher 1.0.1__py3-none-any.whl
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/__init__.py +4 -0
- gitacc_switcher/account_manager.py +421 -0
- gitacc_switcher/cli.py +426 -0
- gitacc_switcher/completion.py +17 -0
- gitacc_switcher/config_manager.py +422 -0
- gitacc_switcher/hook_manager.py +230 -0
- gitacc_switcher/ssh_manager.py +237 -0
- gitacc_switcher/utils.py +82 -0
- gitacc_switcher-1.0.1.dist-info/METADATA +227 -0
- gitacc_switcher-1.0.1.dist-info/RECORD +22 -0
- gitacc_switcher-1.0.1.dist-info/WHEEL +5 -0
- gitacc_switcher-1.0.1.dist-info/entry_points.txt +2 -0
- gitacc_switcher-1.0.1.dist-info/licenses/LICENSE +21 -0
- gitacc_switcher-1.0.1.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/test_account_manager.py +257 -0
- tests/test_cli.py +242 -0
- tests/test_completion.py +30 -0
- tests/test_config_manager.py +231 -0
- tests/test_hook_manager.py +122 -0
- tests/test_ssh_manager.py +185 -0
- tests/test_utils.py +98 -0
gitacc_switcher/cli.py
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""Main CLI entry point with argparse commands."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
import argcomplete
|
|
9
|
+
except ImportError:
|
|
10
|
+
argcomplete = None
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .account_manager import AccountManager
|
|
14
|
+
from .completion import get_account_names
|
|
15
|
+
from .utils import echo_color, get_ssh_key_types
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CLI:
|
|
19
|
+
"""Command-line interface for Git Account Switcher."""
|
|
20
|
+
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self.account_manager = AccountManager()
|
|
23
|
+
self.parser = self._create_parser()
|
|
24
|
+
|
|
25
|
+
def _create_parser(self) -> argparse.ArgumentParser:
|
|
26
|
+
"""Create and configure the argument parser."""
|
|
27
|
+
parser = argparse.ArgumentParser(
|
|
28
|
+
prog="gitacc",
|
|
29
|
+
description="Git Account Switcher - Manage multiple Git SSH accounts easily",
|
|
30
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
31
|
+
epilog=self._get_examples(),
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--version", action="version", version=f"%(prog)s {__version__}"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
subparsers = parser.add_subparsers(
|
|
38
|
+
dest="command",
|
|
39
|
+
help="Available commands",
|
|
40
|
+
metavar="COMMAND",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
self._add_add_command(subparsers)
|
|
44
|
+
self._add_remove_command(subparsers)
|
|
45
|
+
self._add_switch_command(subparsers)
|
|
46
|
+
self._add_list_command(subparsers)
|
|
47
|
+
self._add_logout_command(subparsers)
|
|
48
|
+
self._add_init_command(subparsers)
|
|
49
|
+
self._add_verify_command(subparsers)
|
|
50
|
+
self._add_update_command(subparsers)
|
|
51
|
+
self._add_autocomplete_command(subparsers)
|
|
52
|
+
|
|
53
|
+
# Include account names in top-level completion so `gitacc <TAB>` works for
|
|
54
|
+
# the `gitacc <account>` shorthand, not just for subcommand names.
|
|
55
|
+
if argcomplete:
|
|
56
|
+
subparsers.completer = lambda prefix, **kwargs: (
|
|
57
|
+
list(subparsers.choices.keys()) + get_account_names()
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return parser
|
|
61
|
+
|
|
62
|
+
def _get_examples(self) -> str:
|
|
63
|
+
"""Get usage examples string."""
|
|
64
|
+
key_types = ", ".join(get_ssh_key_types())
|
|
65
|
+
return f"""
|
|
66
|
+
Examples:
|
|
67
|
+
gitacc add Add a new Git account
|
|
68
|
+
gitacc add --type ed25519 Add account with specific SSH key type
|
|
69
|
+
Available types: {key_types}
|
|
70
|
+
gitacc switch myaccount Switch to an account
|
|
71
|
+
gitacc myaccount Switch to an account (short form)
|
|
72
|
+
gitacc remove myaccount Remove an account
|
|
73
|
+
gitacc list List all registered accounts
|
|
74
|
+
gitacc logout Logout current account
|
|
75
|
+
gitacc init myaccount Initialize repo with account validation
|
|
76
|
+
gitacc verify Verify current account matches repo
|
|
77
|
+
gitacc update myaccount Update Git name for an account
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def _add_add_command(self, subparsers: argparse._SubParsersAction) -> None:
|
|
81
|
+
"""Add the 'add' command parser."""
|
|
82
|
+
parser = subparsers.add_parser(
|
|
83
|
+
"add",
|
|
84
|
+
help="Add a new Git account",
|
|
85
|
+
description="Add a new Git account with SSH key generation",
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"-t",
|
|
89
|
+
"--type",
|
|
90
|
+
dest="key_type",
|
|
91
|
+
default="rsa",
|
|
92
|
+
choices=get_ssh_key_types(),
|
|
93
|
+
help="SSH key type (default: rsa). Available: dsa, ecdsa, ecdsa-sk, ed25519, ed25519-sk, rsa",
|
|
94
|
+
)
|
|
95
|
+
parser.set_defaults(func=self._handle_add)
|
|
96
|
+
|
|
97
|
+
def _add_remove_command(self, subparsers: argparse._SubParsersAction) -> None:
|
|
98
|
+
"""Add the 'remove' command parser."""
|
|
99
|
+
parser = subparsers.add_parser(
|
|
100
|
+
"remove",
|
|
101
|
+
help="Remove a Git account",
|
|
102
|
+
description="Remove a Git account and its SSH keys",
|
|
103
|
+
)
|
|
104
|
+
account_arg = parser.add_argument(
|
|
105
|
+
"account_name",
|
|
106
|
+
nargs="?",
|
|
107
|
+
help="Account name to remove (will prompt if not provided)",
|
|
108
|
+
)
|
|
109
|
+
if argcomplete:
|
|
110
|
+
account_arg.completer = lambda **kwargs: get_account_names()
|
|
111
|
+
parser.set_defaults(func=self._handle_remove)
|
|
112
|
+
|
|
113
|
+
def _add_switch_command(self, subparsers: argparse._SubParsersAction) -> None:
|
|
114
|
+
"""Add the 'switch' command parser."""
|
|
115
|
+
parser = subparsers.add_parser(
|
|
116
|
+
"switch",
|
|
117
|
+
help="Switch to a Git account",
|
|
118
|
+
description="Switch to a registered Git account",
|
|
119
|
+
)
|
|
120
|
+
account_arg = parser.add_argument(
|
|
121
|
+
"account_name",
|
|
122
|
+
help="Account name to switch to",
|
|
123
|
+
)
|
|
124
|
+
if argcomplete:
|
|
125
|
+
account_arg.completer = lambda **kwargs: get_account_names()
|
|
126
|
+
parser.set_defaults(func=self._handle_switch)
|
|
127
|
+
|
|
128
|
+
def _add_list_command(self, subparsers: argparse._SubParsersAction) -> None:
|
|
129
|
+
"""Add the 'list' command parser."""
|
|
130
|
+
parser = subparsers.add_parser(
|
|
131
|
+
"list",
|
|
132
|
+
help="List all registered accounts",
|
|
133
|
+
description="Display all registered Git accounts",
|
|
134
|
+
)
|
|
135
|
+
parser.set_defaults(func=self._handle_list)
|
|
136
|
+
|
|
137
|
+
def _add_logout_command(self, subparsers: argparse._SubParsersAction) -> None:
|
|
138
|
+
"""Add the 'logout' command parser."""
|
|
139
|
+
parser = subparsers.add_parser(
|
|
140
|
+
"logout",
|
|
141
|
+
help="Logout current Git account",
|
|
142
|
+
description="Logout from the current Git account",
|
|
143
|
+
)
|
|
144
|
+
parser.set_defaults(func=self._handle_logout)
|
|
145
|
+
|
|
146
|
+
def _add_init_command(self, subparsers: argparse._SubParsersAction) -> None:
|
|
147
|
+
"""Add the 'init' command parser."""
|
|
148
|
+
parser = subparsers.add_parser(
|
|
149
|
+
"init",
|
|
150
|
+
help="Initialize repository with account validation",
|
|
151
|
+
description="Set expected account for repository and install pre-commit hook",
|
|
152
|
+
)
|
|
153
|
+
account_arg = parser.add_argument(
|
|
154
|
+
"account_name",
|
|
155
|
+
help="Account name to set as expected for this repository",
|
|
156
|
+
)
|
|
157
|
+
if argcomplete:
|
|
158
|
+
account_arg.completer = lambda **kwargs: get_account_names()
|
|
159
|
+
parser.set_defaults(func=self._handle_init)
|
|
160
|
+
|
|
161
|
+
def _add_verify_command(self, subparsers: argparse._SubParsersAction) -> None:
|
|
162
|
+
"""Add the 'verify' command parser."""
|
|
163
|
+
parser = subparsers.add_parser(
|
|
164
|
+
"verify",
|
|
165
|
+
help="Verify current account matches repository",
|
|
166
|
+
description="Check if current Git account matches the expected account for the repository",
|
|
167
|
+
)
|
|
168
|
+
parser.set_defaults(func=self._handle_verify)
|
|
169
|
+
|
|
170
|
+
def _add_update_command(self, subparsers: argparse._SubParsersAction) -> None:
|
|
171
|
+
"""Add the 'update' command parser."""
|
|
172
|
+
parser = subparsers.add_parser(
|
|
173
|
+
"update",
|
|
174
|
+
help="Update Git name and/or email for an account",
|
|
175
|
+
description="Update the Git name and/or email for an existing account",
|
|
176
|
+
)
|
|
177
|
+
account_arg = parser.add_argument(
|
|
178
|
+
"account_name",
|
|
179
|
+
help="Account identifier to update",
|
|
180
|
+
)
|
|
181
|
+
parser.add_argument(
|
|
182
|
+
"--name",
|
|
183
|
+
dest="new_git_name",
|
|
184
|
+
help="New Git name (will prompt if not provided)",
|
|
185
|
+
)
|
|
186
|
+
parser.add_argument(
|
|
187
|
+
"--email",
|
|
188
|
+
dest="new_email",
|
|
189
|
+
help="New email address (will prompt if not provided)",
|
|
190
|
+
)
|
|
191
|
+
if argcomplete:
|
|
192
|
+
account_arg.completer = lambda **kwargs: get_account_names()
|
|
193
|
+
parser.set_defaults(func=self._handle_update)
|
|
194
|
+
|
|
195
|
+
def _add_autocomplete_command(self, subparsers: argparse._SubParsersAction) -> None:
|
|
196
|
+
"""Add the 'autocomplete' command parser."""
|
|
197
|
+
parser = subparsers.add_parser(
|
|
198
|
+
"autocomplete",
|
|
199
|
+
help="Install shell autocomplete",
|
|
200
|
+
description="Install shell autocomplete for gitacc command",
|
|
201
|
+
)
|
|
202
|
+
subparsers_autocomplete = parser.add_subparsers(
|
|
203
|
+
dest="autocomplete_command",
|
|
204
|
+
help="Autocomplete commands",
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
install_parser = subparsers_autocomplete.add_parser(
|
|
208
|
+
"install",
|
|
209
|
+
help="Install autocomplete for your shell",
|
|
210
|
+
description="Install autocomplete for bash/zsh",
|
|
211
|
+
)
|
|
212
|
+
install_parser.set_defaults(func=self._handle_autocomplete_install)
|
|
213
|
+
|
|
214
|
+
def _handle_autocomplete_install(self, args: argparse.Namespace) -> int:
|
|
215
|
+
"""Handle the 'autocomplete install' command."""
|
|
216
|
+
if not argcomplete:
|
|
217
|
+
echo_color("r", "argcomplete is not installed!")
|
|
218
|
+
echo_color(
|
|
219
|
+
"y",
|
|
220
|
+
"This should have been installed automatically with gitacc-switcher.",
|
|
221
|
+
)
|
|
222
|
+
echo_color("y", "Please reinstall the package:")
|
|
223
|
+
echo_color("b", " pip install --upgrade gitacc-switcher")
|
|
224
|
+
echo_color("y", "Or if developing locally:")
|
|
225
|
+
echo_color("b", " pip install -e .")
|
|
226
|
+
return 1
|
|
227
|
+
|
|
228
|
+
import os
|
|
229
|
+
import shutil
|
|
230
|
+
|
|
231
|
+
shell = os.environ.get("SHELL", "")
|
|
232
|
+
shell_name = os.path.basename(shell) if shell else ""
|
|
233
|
+
|
|
234
|
+
if shell_name in ["bash", "zsh"]:
|
|
235
|
+
try:
|
|
236
|
+
# Get the completion script
|
|
237
|
+
completion_script = argcomplete.shellcode(["gitacc"], shell=shell_name)
|
|
238
|
+
|
|
239
|
+
# Determine shell config file
|
|
240
|
+
if shell_name == "bash":
|
|
241
|
+
config_file = os.path.expanduser("~/.bashrc")
|
|
242
|
+
# Check for .bash_profile on macOS
|
|
243
|
+
if sys.platform == "darwin" and os.path.exists(
|
|
244
|
+
os.path.expanduser("~/.bash_profile")
|
|
245
|
+
):
|
|
246
|
+
config_file = os.path.expanduser("~/.bash_profile")
|
|
247
|
+
else: # zsh
|
|
248
|
+
config_file = os.path.expanduser("~/.zshrc")
|
|
249
|
+
|
|
250
|
+
# Check if already installed
|
|
251
|
+
if os.path.exists(config_file):
|
|
252
|
+
with open(config_file, "r") as f:
|
|
253
|
+
if "register-python-argcomplete gitacc" in f.read():
|
|
254
|
+
echo_color(
|
|
255
|
+
"y", f"Autocomplete already installed in {config_file}"
|
|
256
|
+
)
|
|
257
|
+
echo_color(
|
|
258
|
+
"y",
|
|
259
|
+
f"To activate in this session: source {config_file}",
|
|
260
|
+
)
|
|
261
|
+
return 0
|
|
262
|
+
|
|
263
|
+
# Add to config file
|
|
264
|
+
with open(config_file, "a") as f:
|
|
265
|
+
f.write("\n# Git Account Switcher autocomplete\n")
|
|
266
|
+
f.write(completion_script)
|
|
267
|
+
f.write("\n")
|
|
268
|
+
|
|
269
|
+
echo_color("g", f"Autocomplete installed successfully!")
|
|
270
|
+
echo_color("g", f"Added to {config_file}")
|
|
271
|
+
|
|
272
|
+
# Try to source it automatically for current shell
|
|
273
|
+
# Note: We can't modify the parent shell, but we can validate and provide instructions
|
|
274
|
+
try:
|
|
275
|
+
import subprocess
|
|
276
|
+
|
|
277
|
+
# Validate the installation works
|
|
278
|
+
result = subprocess.run(
|
|
279
|
+
[shell, "-c", f"source {config_file} && echo 'OK'"],
|
|
280
|
+
capture_output=True,
|
|
281
|
+
text=True,
|
|
282
|
+
timeout=2,
|
|
283
|
+
)
|
|
284
|
+
if result.returncode == 0:
|
|
285
|
+
echo_color("g", "✓ Installation validated!")
|
|
286
|
+
echo_color(
|
|
287
|
+
"y",
|
|
288
|
+
"Autocomplete will work automatically in new shell sessions.",
|
|
289
|
+
)
|
|
290
|
+
echo_color(
|
|
291
|
+
"y", f"To activate in this session: source {config_file}"
|
|
292
|
+
)
|
|
293
|
+
else:
|
|
294
|
+
echo_color("y", f"To activate: source {config_file}")
|
|
295
|
+
except Exception:
|
|
296
|
+
echo_color("y", f"To activate: source {config_file}")
|
|
297
|
+
|
|
298
|
+
return 0
|
|
299
|
+
|
|
300
|
+
except Exception as e:
|
|
301
|
+
echo_color("r", f"Failed to install autocomplete: {e}")
|
|
302
|
+
echo_color("y", "You can manually install by running:")
|
|
303
|
+
echo_color("b", f' eval "$(register-python-argcomplete gitacc)"')
|
|
304
|
+
return 1
|
|
305
|
+
else:
|
|
306
|
+
echo_color("y", f"Shell '{shell_name}' detected.")
|
|
307
|
+
echo_color("y", "Automatic installation is supported for bash and zsh.")
|
|
308
|
+
echo_color("y", "For other shells, run manually:")
|
|
309
|
+
echo_color("b", ' eval "$(register-python-argcomplete gitacc)"')
|
|
310
|
+
return 0
|
|
311
|
+
|
|
312
|
+
def _handle_add(self, args: argparse.Namespace) -> int:
|
|
313
|
+
"""Handle the 'add' command."""
|
|
314
|
+
success = self.account_manager.add_account(key_type=args.key_type)
|
|
315
|
+
return 0 if success else 1
|
|
316
|
+
|
|
317
|
+
def _handle_remove(self, args: argparse.Namespace) -> int:
|
|
318
|
+
"""Handle the 'remove' command."""
|
|
319
|
+
success = self.account_manager.remove_account(account_name=args.account_name)
|
|
320
|
+
return 0 if success else 1
|
|
321
|
+
|
|
322
|
+
def _handle_switch(self, args: argparse.Namespace) -> int:
|
|
323
|
+
"""Handle the 'switch' command."""
|
|
324
|
+
success = self.account_manager.switch_account(args.account_name)
|
|
325
|
+
return 0 if success else 1
|
|
326
|
+
|
|
327
|
+
def _handle_list(self, args: argparse.Namespace) -> int:
|
|
328
|
+
"""Handle the 'list' command."""
|
|
329
|
+
accounts = self.account_manager.list_accounts_detailed()
|
|
330
|
+
if not accounts:
|
|
331
|
+
echo_color("y", "No accounts registered yet.")
|
|
332
|
+
return 0
|
|
333
|
+
|
|
334
|
+
current_config = self.account_manager.config_manager.get_current_git_config()
|
|
335
|
+
current_name = current_config.get("name")
|
|
336
|
+
current_email = current_config.get("email")
|
|
337
|
+
|
|
338
|
+
echo_color("g", "Registered accounts:")
|
|
339
|
+
for account_name, account_info in accounts.items():
|
|
340
|
+
git_name = account_info.get("name", account_name)
|
|
341
|
+
email = account_info.get("email", "N/A")
|
|
342
|
+
is_active = bool(
|
|
343
|
+
current_name
|
|
344
|
+
and current_email
|
|
345
|
+
and git_name == current_name
|
|
346
|
+
and email == current_email
|
|
347
|
+
)
|
|
348
|
+
marker = "*" if is_active else "-"
|
|
349
|
+
label = (
|
|
350
|
+
f"{account_name} → Git name: {git_name}"
|
|
351
|
+
if git_name != account_name
|
|
352
|
+
else account_name
|
|
353
|
+
)
|
|
354
|
+
suffix = " (active)" if is_active else ""
|
|
355
|
+
print(f" {marker} {label} ({email}){suffix}")
|
|
356
|
+
|
|
357
|
+
return 0
|
|
358
|
+
|
|
359
|
+
def _handle_logout(self, args: argparse.Namespace) -> int:
|
|
360
|
+
"""Handle the 'logout' command."""
|
|
361
|
+
success = self.account_manager.logout()
|
|
362
|
+
return 0 if success else 1
|
|
363
|
+
|
|
364
|
+
def _handle_init(self, args: argparse.Namespace) -> int:
|
|
365
|
+
"""Handle the 'init' command."""
|
|
366
|
+
success = self.account_manager.init_repo(args.account_name)
|
|
367
|
+
return 0 if success else 1
|
|
368
|
+
|
|
369
|
+
def _handle_verify(self, args: argparse.Namespace) -> int:
|
|
370
|
+
"""Handle the 'verify' command."""
|
|
371
|
+
success = self.account_manager.verify_account()
|
|
372
|
+
return 0 if success else 1
|
|
373
|
+
|
|
374
|
+
def _handle_update(self, args: argparse.Namespace) -> int:
|
|
375
|
+
"""Handle the 'update' command."""
|
|
376
|
+
success = self.account_manager.update_account(
|
|
377
|
+
args.account_name, args.new_git_name, args.new_email
|
|
378
|
+
)
|
|
379
|
+
return 0 if success else 1
|
|
380
|
+
|
|
381
|
+
def _handle_account_name_shortcut(self, account_name: str) -> int:
|
|
382
|
+
"""Handle account name as shortcut for switch command."""
|
|
383
|
+
success = self.account_manager.switch_account(account_name)
|
|
384
|
+
return 0 if success else 1
|
|
385
|
+
|
|
386
|
+
def run(self) -> int:
|
|
387
|
+
"""Run the CLI application.
|
|
388
|
+
|
|
389
|
+
Returns:
|
|
390
|
+
Exit code (0 for success, non-zero for failure)
|
|
391
|
+
"""
|
|
392
|
+
args = self.parser.parse_args()
|
|
393
|
+
|
|
394
|
+
# Handle account name shortcut (gitacc <account_name>)
|
|
395
|
+
if not args.command and len(sys.argv) > 1:
|
|
396
|
+
account_name = sys.argv[1]
|
|
397
|
+
if account_name not in ["-h", "--help", "--version"]:
|
|
398
|
+
if not self.account_manager.account_exists(account_name):
|
|
399
|
+
echo_color("r", f'Unknown command or account: "{account_name}"')
|
|
400
|
+
self.parser.print_help()
|
|
401
|
+
return 1
|
|
402
|
+
return self._handle_account_name_shortcut(account_name)
|
|
403
|
+
|
|
404
|
+
# Handle commands
|
|
405
|
+
if hasattr(args, "func"):
|
|
406
|
+
return args.func(args)
|
|
407
|
+
|
|
408
|
+
# No command provided, show help
|
|
409
|
+
self.parser.print_help()
|
|
410
|
+
return 0
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def main() -> None:
|
|
414
|
+
"""Main entry point for the CLI."""
|
|
415
|
+
cli = CLI()
|
|
416
|
+
|
|
417
|
+
# Enable argcomplete if available
|
|
418
|
+
if argcomplete:
|
|
419
|
+
argcomplete.autocomplete(cli.parser)
|
|
420
|
+
|
|
421
|
+
exit_code = cli.run()
|
|
422
|
+
sys.exit(exit_code)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
if __name__ == "__main__":
|
|
426
|
+
main()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Completion functions for shell autocomplete."""
|
|
2
|
+
|
|
3
|
+
from typing import List
|
|
4
|
+
from .config_manager import ConfigManager
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_account_names() -> List[str]:
|
|
8
|
+
"""Get list of account names for completion.
|
|
9
|
+
|
|
10
|
+
Returns:
|
|
11
|
+
List of account names
|
|
12
|
+
"""
|
|
13
|
+
try:
|
|
14
|
+
config_manager = ConfigManager()
|
|
15
|
+
return config_manager.list_account_names()
|
|
16
|
+
except Exception:
|
|
17
|
+
return []
|