gwitch 0.1.0__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.
- gw/__init__.py +0 -0
- gw/cli.py +30 -0
- gw/commands/__init__.py +0 -0
- gw/commands/account.py +111 -0
- gw/commands/credential_helper.py +49 -0
- gw/commands/init_cmd.py +108 -0
- gw/commands/status.py +55 -0
- gw/commands/use.py +82 -0
- gw/config.py +78 -0
- gw/gh_ops.py +50 -0
- gw/git_ops.py +138 -0
- gw/prompt_install.py +95 -0
- gw/remote_check_install.py +87 -0
- gw/ssh_ops.py +38 -0
- gwitch-0.1.0.dist-info/METADATA +143 -0
- gwitch-0.1.0.dist-info/RECORD +19 -0
- gwitch-0.1.0.dist-info/WHEEL +4 -0
- gwitch-0.1.0.dist-info/entry_points.txt +2 -0
- gwitch-0.1.0.dist-info/licenses/LICENSE +21 -0
gw/__init__.py
ADDED
|
File without changes
|
gw/cli.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
|
|
3
|
+
from gw.commands.account import account_app
|
|
4
|
+
from gw.commands.credential_helper import credential_helper_command
|
|
5
|
+
from gw.commands.init_cmd import init_command
|
|
6
|
+
from gw.commands.status import status_command
|
|
7
|
+
from gw.commands.use import use_command
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(
|
|
10
|
+
name="gw",
|
|
11
|
+
help="Switch between git/GitHub accounts per repository.",
|
|
12
|
+
no_args_is_help=True,
|
|
13
|
+
)
|
|
14
|
+
app.add_typer(account_app, name="account")
|
|
15
|
+
app.command("use")(use_command)
|
|
16
|
+
app.command("status")(status_command)
|
|
17
|
+
app.command("whoami")(status_command)
|
|
18
|
+
app.command("init")(init_command)
|
|
19
|
+
app.command(
|
|
20
|
+
"credential-helper",
|
|
21
|
+
help="Internal: invoked by git as this repo's HTTPS credential helper. Not meant to be run directly.",
|
|
22
|
+
)(credential_helper_command)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main() -> None:
|
|
26
|
+
app()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
main()
|
gw/commands/__init__.py
ADDED
|
File without changes
|
gw/commands/account.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from gw import config, gh_ops, ssh_ops
|
|
9
|
+
|
|
10
|
+
account_app = typer.Typer(help="Manage gw accounts.")
|
|
11
|
+
|
|
12
|
+
_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@account_app.command("add")
|
|
16
|
+
def add(name: str, force: bool = typer.Option(False, "--force", help="Overwrite an existing account")) -> None:
|
|
17
|
+
if not _NAME_RE.match(name):
|
|
18
|
+
typer.echo(
|
|
19
|
+
f"Error: invalid account name '{name}'. "
|
|
20
|
+
"Names may only contain letters, numbers, '.', '_', and '-'.",
|
|
21
|
+
err=True,
|
|
22
|
+
)
|
|
23
|
+
raise typer.Exit(code=1)
|
|
24
|
+
|
|
25
|
+
config_path = config.default_config_path()
|
|
26
|
+
if not force:
|
|
27
|
+
try:
|
|
28
|
+
existing_names = {a.name for a in config.load_accounts(config_path)}
|
|
29
|
+
except config.ConfigError as e:
|
|
30
|
+
typer.echo(f"Error: {e}", err=True)
|
|
31
|
+
raise typer.Exit(code=1)
|
|
32
|
+
if name in existing_names:
|
|
33
|
+
typer.echo(f"Error: account '{name}' already exists. Use --force to overwrite.", err=True)
|
|
34
|
+
raise typer.Exit(code=1)
|
|
35
|
+
|
|
36
|
+
git_name = typer.prompt("Git user.name")
|
|
37
|
+
git_email = typer.prompt("Git user.email")
|
|
38
|
+
gh_username = typer.prompt("GitHub username")
|
|
39
|
+
gh_host = typer.prompt("GitHub host", default="github.com")
|
|
40
|
+
|
|
41
|
+
generate = typer.confirm(f"Generate a new SSH key for '{name}'?", default=True)
|
|
42
|
+
if generate:
|
|
43
|
+
ssh_key = str(Path.home() / ".ssh" / f"id_ed25519_{name}")
|
|
44
|
+
if ssh_ops.key_exists(ssh_key):
|
|
45
|
+
typer.echo(f"Key already exists at {ssh_key}, using existing key.")
|
|
46
|
+
else:
|
|
47
|
+
try:
|
|
48
|
+
ssh_ops.generate_key(ssh_key, git_email)
|
|
49
|
+
except ssh_ops.SshOpsError as e:
|
|
50
|
+
typer.echo(f"Error: could not generate SSH key: {e}", err=True)
|
|
51
|
+
raise typer.Exit(code=1)
|
|
52
|
+
pub = ssh_ops.read_public_key(ssh_ops.public_key_path(ssh_key))
|
|
53
|
+
typer.echo(f"Public key (add this at https://github.com/settings/keys):\n{pub}")
|
|
54
|
+
else:
|
|
55
|
+
ssh_key = typer.prompt("Path to existing private SSH key")
|
|
56
|
+
if not ssh_ops.key_exists(ssh_key):
|
|
57
|
+
typer.echo(f"Error: no key found at {ssh_key}", err=True)
|
|
58
|
+
raise typer.Exit(code=1)
|
|
59
|
+
|
|
60
|
+
account = config.Account(
|
|
61
|
+
name=name,
|
|
62
|
+
git_name=git_name,
|
|
63
|
+
git_email=git_email,
|
|
64
|
+
ssh_key=ssh_key,
|
|
65
|
+
gh_host=gh_host,
|
|
66
|
+
gh_username=gh_username,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
config.add_account(config_path, account, force=force)
|
|
71
|
+
except config.AccountExistsError:
|
|
72
|
+
typer.echo(f"Error: account '{name}' already exists. Use --force to overwrite.", err=True)
|
|
73
|
+
raise typer.Exit(code=1)
|
|
74
|
+
except config.ConfigError as e:
|
|
75
|
+
typer.echo(f"Error: {e}", err=True)
|
|
76
|
+
raise typer.Exit(code=1)
|
|
77
|
+
|
|
78
|
+
typer.echo(f"Account '{name}' added.")
|
|
79
|
+
|
|
80
|
+
if gh_ops.is_gh_installed():
|
|
81
|
+
if typer.confirm(f"Run 'gh auth login' now to authenticate '{gh_username}'?", default=False):
|
|
82
|
+
gh_ops.auth_login(gh_host)
|
|
83
|
+
else:
|
|
84
|
+
typer.echo("Note: gh CLI not found — skipping GitHub CLI auth setup.")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@account_app.command("list")
|
|
88
|
+
def list_accounts() -> None:
|
|
89
|
+
try:
|
|
90
|
+
accounts = config.load_accounts(config.default_config_path())
|
|
91
|
+
except config.ConfigError as e:
|
|
92
|
+
typer.echo(f"Error: {e}", err=True)
|
|
93
|
+
raise typer.Exit(code=1)
|
|
94
|
+
if not accounts:
|
|
95
|
+
typer.echo("No accounts configured. Add one with `gw account add <name>`.")
|
|
96
|
+
return
|
|
97
|
+
for a in accounts:
|
|
98
|
+
typer.echo(f"{a.name}\t{a.git_email}\t{a.ssh_key}\t{a.gh_username}@{a.gh_host}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@account_app.command("remove")
|
|
102
|
+
def remove(name: str) -> None:
|
|
103
|
+
try:
|
|
104
|
+
config.remove_account(config.default_config_path(), name)
|
|
105
|
+
except config.AccountNotFoundError:
|
|
106
|
+
typer.echo(f"Error: no account named '{name}'.", err=True)
|
|
107
|
+
raise typer.Exit(code=1)
|
|
108
|
+
except config.ConfigError as e:
|
|
109
|
+
typer.echo(f"Error: {e}", err=True)
|
|
110
|
+
raise typer.Exit(code=1)
|
|
111
|
+
typer.echo(f"Account '{name}' removed.")
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from gw import config, gh_ops, git_ops
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def credential_helper_command(operation: str) -> None:
|
|
11
|
+
raw_input = sys.stdin.read()
|
|
12
|
+
|
|
13
|
+
if operation != "get":
|
|
14
|
+
return
|
|
15
|
+
|
|
16
|
+
request = _parse_credential_input(raw_input)
|
|
17
|
+
|
|
18
|
+
account_name = git_ops.get_local_config("gw.account")
|
|
19
|
+
if not account_name:
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
account = config.get_account(config.default_config_path(), account_name)
|
|
24
|
+
except config.ConfigError:
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
requested_host = request.get("host")
|
|
28
|
+
if requested_host and requested_host != account.gh_host:
|
|
29
|
+
return
|
|
30
|
+
|
|
31
|
+
if not gh_ops.is_gh_installed():
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
token = gh_ops.get_token(account.gh_host, account.gh_username)
|
|
35
|
+
if not token:
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
typer.echo(f"username={account.gh_username}")
|
|
39
|
+
typer.echo(f"password={token}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _parse_credential_input(raw: str) -> dict[str, str]:
|
|
43
|
+
parsed: dict[str, str] = {}
|
|
44
|
+
for line in raw.splitlines():
|
|
45
|
+
if not line or "=" not in line:
|
|
46
|
+
continue
|
|
47
|
+
key, _, value = line.partition("=")
|
|
48
|
+
parsed[key] = value
|
|
49
|
+
return parsed
|
gw/commands/init_cmd.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from gw import prompt_install, remote_check_install
|
|
6
|
+
|
|
7
|
+
SNIPPETS = {
|
|
8
|
+
"zsh": (
|
|
9
|
+
'gw_prompt() {\n'
|
|
10
|
+
' local acct\n'
|
|
11
|
+
' acct=$(git config --local --get gw.account 2>/dev/null)\n'
|
|
12
|
+
' [[ -n "$acct" ]] && printf \'[%s]\' "$acct"\n'
|
|
13
|
+
' return 0\n'
|
|
14
|
+
'}'
|
|
15
|
+
),
|
|
16
|
+
"bash": (
|
|
17
|
+
'gw_prompt() {\n'
|
|
18
|
+
' local acct\n'
|
|
19
|
+
' acct=$(git config --local --get gw.account 2>/dev/null)\n'
|
|
20
|
+
' [[ -n "$acct" ]] && printf \'[%s]\' "$acct"\n'
|
|
21
|
+
' return 0\n'
|
|
22
|
+
'}'
|
|
23
|
+
),
|
|
24
|
+
"fish": (
|
|
25
|
+
'function gw_prompt\n'
|
|
26
|
+
' set -l acct (git config --local --get gw.account 2>/dev/null)\n'
|
|
27
|
+
' if test -n "$acct"\n'
|
|
28
|
+
' printf \'[%s]\' "$acct"\n'
|
|
29
|
+
' end\n'
|
|
30
|
+
'end'
|
|
31
|
+
),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
INSTRUCTIONS = {
|
|
35
|
+
"zsh": (
|
|
36
|
+
"Add to ~/.zshrc (along with 'setopt PROMPT_SUBST' if not already set), "
|
|
37
|
+
"then reference $(gw_prompt) in your PROMPT/PS1."
|
|
38
|
+
),
|
|
39
|
+
"bash": "Add to ~/.bashrc, then reference $(gw_prompt) in your PS1.",
|
|
40
|
+
"fish": (
|
|
41
|
+
"Add to ~/.config/fish/functions/gw_prompt.fish (or config.fish), "
|
|
42
|
+
"then reference (gw_prompt) in your fish_prompt function."
|
|
43
|
+
),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
INSTALLABLE_SHELLS = {"zsh", "bash"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def init_command(
|
|
50
|
+
shell: str,
|
|
51
|
+
install: bool = typer.Option(
|
|
52
|
+
False,
|
|
53
|
+
"--install",
|
|
54
|
+
help="Write the gw-prompt and gw-check scripts to disk and wire them into your shell rc file.",
|
|
55
|
+
),
|
|
56
|
+
) -> None:
|
|
57
|
+
shell = shell.lower()
|
|
58
|
+
if shell not in SNIPPETS:
|
|
59
|
+
supported = ", ".join(SNIPPETS)
|
|
60
|
+
typer.echo(f"Error: unsupported shell '{shell}'. Supported: {supported}", err=True)
|
|
61
|
+
raise typer.Exit(code=1)
|
|
62
|
+
|
|
63
|
+
if install:
|
|
64
|
+
if shell not in INSTALLABLE_SHELLS:
|
|
65
|
+
typer.echo(
|
|
66
|
+
f"Error: --install is not supported for {shell} yet — run "
|
|
67
|
+
f"`gw init {shell}` to see the manual setup instructions instead.",
|
|
68
|
+
err=True,
|
|
69
|
+
)
|
|
70
|
+
raise typer.Exit(code=1)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
prompt_result = prompt_install.install(shell)
|
|
74
|
+
check_result = remote_check_install.install_check(shell)
|
|
75
|
+
except prompt_install.PromptInstallError as e:
|
|
76
|
+
typer.echo(f"Error: {e}", err=True)
|
|
77
|
+
raise typer.Exit(code=1)
|
|
78
|
+
|
|
79
|
+
if prompt_result.newly_installed:
|
|
80
|
+
typer.echo(f"Installed gw-prompt script to {prompt_result.script_path}")
|
|
81
|
+
if check_result.newly_installed:
|
|
82
|
+
typer.echo(f"Installed gw-check script to {check_result.script_path}")
|
|
83
|
+
|
|
84
|
+
if prompt_result.newly_installed:
|
|
85
|
+
typer.echo(f"Added prompt integration to {prompt_result.rc_path}")
|
|
86
|
+
else:
|
|
87
|
+
typer.echo(
|
|
88
|
+
f"gw-prompt script refreshed at {prompt_result.script_path}; "
|
|
89
|
+
f"prompt integration already present in {prompt_result.rc_path}."
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if check_result.newly_installed:
|
|
93
|
+
typer.echo(f"Added remote-account check to {check_result.rc_path}")
|
|
94
|
+
else:
|
|
95
|
+
typer.echo(
|
|
96
|
+
f"gw-check script refreshed at {check_result.script_path}; "
|
|
97
|
+
f"remote-account check already present in {check_result.rc_path}."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if prompt_result.newly_installed or check_result.newly_installed:
|
|
101
|
+
typer.echo(
|
|
102
|
+
f"Restart your shell (or run: source {prompt_result.rc_path}) to see it take effect."
|
|
103
|
+
)
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
typer.echo(SNIPPETS[shell])
|
|
107
|
+
typer.echo()
|
|
108
|
+
typer.echo(f"# {INSTRUCTIONS[shell]}")
|
gw/commands/status.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from gw import config, gh_ops, git_ops
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def status_command() -> None:
|
|
9
|
+
if not git_ops.is_inside_work_tree():
|
|
10
|
+
typer.echo("Error: not inside a git repository.", err=True)
|
|
11
|
+
raise typer.Exit(code=1)
|
|
12
|
+
|
|
13
|
+
account_name = git_ops.get_local_config("gw.account")
|
|
14
|
+
if not account_name:
|
|
15
|
+
typer.echo("No gw account set for this repo. Run `gw use <name>`.")
|
|
16
|
+
return
|
|
17
|
+
|
|
18
|
+
git_name = git_ops.get_local_config("user.name") or "(unset)"
|
|
19
|
+
git_email = git_ops.get_local_config("user.email") or "(unset)"
|
|
20
|
+
ssh_cmd = git_ops.get_local_config("core.sshCommand") or "(unset)"
|
|
21
|
+
|
|
22
|
+
typer.echo(f"Repo account: {account_name}")
|
|
23
|
+
typer.echo(f" user.name: {git_name}")
|
|
24
|
+
typer.echo(f" user.email: {git_email}")
|
|
25
|
+
typer.echo(f" sshCommand: {ssh_cmd}")
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
account = config.get_account(config.default_config_path(), account_name)
|
|
29
|
+
except config.AccountNotFoundError:
|
|
30
|
+
typer.echo(f"Warning: '{account_name}' is not in the account config (was it removed?).")
|
|
31
|
+
return
|
|
32
|
+
except config.ConfigError as e:
|
|
33
|
+
typer.echo(f"Error: {e}", err=True)
|
|
34
|
+
raise typer.Exit(code=1)
|
|
35
|
+
|
|
36
|
+
cred_helper = git_ops.get_local_config(f"credential.https://{account.gh_host}.helper") or "(unset)"
|
|
37
|
+
typer.echo(f" credHelper: {cred_helper}")
|
|
38
|
+
|
|
39
|
+
if not gh_ops.is_gh_installed():
|
|
40
|
+
typer.echo("gh account: gh CLI not installed")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
gh_active = gh_ops.current_account(account.gh_host)
|
|
44
|
+
if gh_active is None:
|
|
45
|
+
typer.echo(
|
|
46
|
+
f"gh account: could not determine (not logged in to {account.gh_host}, "
|
|
47
|
+
"or the gh API call failed)"
|
|
48
|
+
)
|
|
49
|
+
elif gh_active == account.gh_username:
|
|
50
|
+
typer.echo(f"gh account: {gh_active} (matches)")
|
|
51
|
+
else:
|
|
52
|
+
typer.echo(
|
|
53
|
+
f"gh account: {gh_active} (WARNING: expected '{account.gh_username}' — "
|
|
54
|
+
f"gh auth is global; run `gw use {account_name}` again or `gh auth switch` manually)"
|
|
55
|
+
)
|
gw/commands/use.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from gw import config, gh_ops, git_ops, ssh_ops
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def use_command(name: str) -> None:
|
|
9
|
+
if not git_ops.is_inside_work_tree():
|
|
10
|
+
typer.echo("Error: not inside a git repository.", err=True)
|
|
11
|
+
raise typer.Exit(code=1)
|
|
12
|
+
|
|
13
|
+
if git_ops.is_linked_worktree():
|
|
14
|
+
typer.echo(
|
|
15
|
+
"Warning: this is a linked git worktree — 'gw use' here changes git "
|
|
16
|
+
"identity for the main checkout and all sibling worktrees too (git "
|
|
17
|
+
"shares local config across worktrees by default), not just this one.",
|
|
18
|
+
err=True,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
account = config.get_account(config.default_config_path(), name)
|
|
23
|
+
except config.AccountNotFoundError:
|
|
24
|
+
accounts = config.load_accounts(config.default_config_path())
|
|
25
|
+
names = ", ".join(a.name for a in accounts) or "(none configured)"
|
|
26
|
+
typer.echo(f"Error: no account named '{name}'. Available: {names}", err=True)
|
|
27
|
+
raise typer.Exit(code=1)
|
|
28
|
+
except config.ConfigError as e:
|
|
29
|
+
typer.echo(f"Error: {e}", err=True)
|
|
30
|
+
raise typer.Exit(code=1)
|
|
31
|
+
|
|
32
|
+
if not ssh_ops.key_exists(account.ssh_key):
|
|
33
|
+
typer.echo(f"Error: SSH key not found at {account.ssh_key}", err=True)
|
|
34
|
+
raise typer.Exit(code=1)
|
|
35
|
+
|
|
36
|
+
# Write the gw.account marker first: if a later write in this sequence
|
|
37
|
+
# fails, the marker at least reflects the attempted target rather than
|
|
38
|
+
# silently keeping the stale previous account, and the explicit error
|
|
39
|
+
# below makes the partial state impossible to miss.
|
|
40
|
+
try:
|
|
41
|
+
git_ops.set_local_config("gw.account", account.name)
|
|
42
|
+
git_ops.set_local_config("user.name", account.git_name)
|
|
43
|
+
git_ops.set_local_config("user.email", account.git_email)
|
|
44
|
+
git_ops.set_local_config("core.sshCommand", git_ops.ssh_command_for_key(account.ssh_key))
|
|
45
|
+
git_ops.set_local_credential_helper(account.gh_host)
|
|
46
|
+
except git_ops.GitOpsError as e:
|
|
47
|
+
typer.echo(
|
|
48
|
+
f"Error: failed to update git config while switching to '{account.name}': {e}\n"
|
|
49
|
+
"This repo may now be in a partially-switched state. Re-run "
|
|
50
|
+
f"`gw use {account.name}` to retry.",
|
|
51
|
+
err=True,
|
|
52
|
+
)
|
|
53
|
+
raise typer.Exit(code=1)
|
|
54
|
+
|
|
55
|
+
typer.echo(f"Switched to '{account.name}' ({account.git_email}) for this repo.")
|
|
56
|
+
|
|
57
|
+
remote_url = git_ops.get_remote_url()
|
|
58
|
+
if remote_url:
|
|
59
|
+
try:
|
|
60
|
+
git_ops.record_remote_account(remote_url, account.name)
|
|
61
|
+
except git_ops.GitOpsError as e:
|
|
62
|
+
typer.echo(
|
|
63
|
+
f"Warning: could not record this repo's remote for future account checks: {e}",
|
|
64
|
+
err=True,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if account.gh_username:
|
|
68
|
+
if gh_ops.is_gh_installed():
|
|
69
|
+
try:
|
|
70
|
+
gh_ops.auth_switch(account.gh_host, account.gh_username)
|
|
71
|
+
typer.echo(
|
|
72
|
+
f"gh CLI switched to '{account.gh_username}' "
|
|
73
|
+
"(note: this is global, not per-repo)."
|
|
74
|
+
)
|
|
75
|
+
except gh_ops.GhOpsError:
|
|
76
|
+
typer.echo(
|
|
77
|
+
f"Warning: could not switch gh CLI to '{account.gh_username}'. "
|
|
78
|
+
f"Run `gh auth login --hostname {account.gh_host}` first.",
|
|
79
|
+
err=True,
|
|
80
|
+
)
|
|
81
|
+
else:
|
|
82
|
+
typer.echo("Note: gh CLI not found — skipping gh CLI account switch.")
|
gw/config.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import toml
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ConfigError(Exception):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AccountExistsError(ConfigError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AccountNotFoundError(ConfigError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Account:
|
|
23
|
+
name: str
|
|
24
|
+
git_name: str
|
|
25
|
+
git_email: str
|
|
26
|
+
ssh_key: str
|
|
27
|
+
gh_host: str
|
|
28
|
+
gh_username: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def default_config_path() -> Path:
|
|
32
|
+
return Path.home() / ".config" / "gw" / "accounts.toml"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_accounts(config_path: Path) -> list[Account]:
|
|
36
|
+
if not config_path.exists():
|
|
37
|
+
return []
|
|
38
|
+
try:
|
|
39
|
+
data = toml.loads(config_path.read_text())
|
|
40
|
+
return [Account(**entry) for entry in data.get("accounts", [])]
|
|
41
|
+
except (toml.TomlDecodeError, TypeError) as e:
|
|
42
|
+
raise ConfigError(f"{config_path} is not a valid gw config: {e}") from e
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def save_accounts(config_path: Path, accounts: list[Account]) -> None:
|
|
46
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
data = {"accounts": [asdict(a) for a in accounts]}
|
|
48
|
+
config_path.write_text(toml.dumps(data))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def add_account(config_path: Path, account: Account, force: bool = False) -> None:
|
|
52
|
+
accounts = load_accounts(config_path)
|
|
53
|
+
existing_names = {a.name for a in accounts}
|
|
54
|
+
|
|
55
|
+
if account.name in existing_names:
|
|
56
|
+
if not force:
|
|
57
|
+
raise AccountExistsError(account.name)
|
|
58
|
+
accounts = [a for a in accounts if a.name != account.name]
|
|
59
|
+
|
|
60
|
+
accounts.append(account)
|
|
61
|
+
save_accounts(config_path, accounts)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def remove_account(config_path: Path, name: str) -> None:
|
|
65
|
+
accounts = load_accounts(config_path)
|
|
66
|
+
remaining = [a for a in accounts if a.name != name]
|
|
67
|
+
|
|
68
|
+
if len(remaining) == len(accounts):
|
|
69
|
+
raise AccountNotFoundError(name)
|
|
70
|
+
|
|
71
|
+
save_accounts(config_path, remaining)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_account(config_path: Path, name: str) -> Account:
|
|
75
|
+
for account in load_accounts(config_path):
|
|
76
|
+
if account.name == name:
|
|
77
|
+
return account
|
|
78
|
+
raise AccountNotFoundError(name)
|
gw/gh_ops.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class GhOpsError(Exception):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def is_gh_installed() -> bool:
|
|
12
|
+
return shutil.which("gh") is not None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def auth_login(host: str) -> None:
|
|
16
|
+
subprocess.run(["gh", "auth", "login", "--hostname", host])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def auth_switch(host: str, username: str) -> None:
|
|
20
|
+
result = subprocess.run(
|
|
21
|
+
["gh", "auth", "switch", "--hostname", host, "--user", username],
|
|
22
|
+
capture_output=True,
|
|
23
|
+
text=True,
|
|
24
|
+
)
|
|
25
|
+
if result.returncode != 0:
|
|
26
|
+
raise GhOpsError(f"gh auth switch failed: {result.stderr.strip()}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def current_account(host: str) -> str | None:
|
|
30
|
+
result = subprocess.run(
|
|
31
|
+
["gh", "api", "user", "--hostname", host, "-q", ".login"],
|
|
32
|
+
capture_output=True,
|
|
33
|
+
text=True,
|
|
34
|
+
)
|
|
35
|
+
if result.returncode != 0:
|
|
36
|
+
return None
|
|
37
|
+
login = result.stdout.strip()
|
|
38
|
+
return login or None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_token(host: str, username: str) -> str | None:
|
|
42
|
+
result = subprocess.run(
|
|
43
|
+
["gh", "auth", "token", "--hostname", host, "--user", username],
|
|
44
|
+
capture_output=True,
|
|
45
|
+
text=True,
|
|
46
|
+
)
|
|
47
|
+
if result.returncode != 0:
|
|
48
|
+
return None
|
|
49
|
+
token = result.stdout.strip()
|
|
50
|
+
return token or None
|
gw/git_ops.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shlex
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GitOpsError(Exception):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def is_inside_work_tree(cwd: Path | None = None) -> bool:
|
|
14
|
+
result = subprocess.run(
|
|
15
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
16
|
+
cwd=cwd,
|
|
17
|
+
capture_output=True,
|
|
18
|
+
text=True,
|
|
19
|
+
)
|
|
20
|
+
return result.returncode == 0 and result.stdout.strip() == "true"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def set_local_config(key: str, value: str, cwd: Path | None = None) -> None:
|
|
24
|
+
result = subprocess.run(
|
|
25
|
+
["git", "config", "--local", key, value],
|
|
26
|
+
cwd=cwd,
|
|
27
|
+
capture_output=True,
|
|
28
|
+
text=True,
|
|
29
|
+
)
|
|
30
|
+
if result.returncode != 0:
|
|
31
|
+
raise GitOpsError(f"git config --local {key} failed: {result.stderr.strip()}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_local_config(key: str, cwd: Path | None = None) -> str | None:
|
|
35
|
+
result = subprocess.run(
|
|
36
|
+
["git", "config", "--local", "--get", key],
|
|
37
|
+
cwd=cwd,
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
)
|
|
41
|
+
if result.returncode != 0:
|
|
42
|
+
return None
|
|
43
|
+
return result.stdout.strip()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_remote_url(remote: str = "origin", cwd: Path | None = None) -> str | None:
|
|
47
|
+
return get_local_config(f"remote.{remote}.url", cwd=cwd)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _strip_url_credentials(url: str) -> str:
|
|
51
|
+
parts = urlsplit(url)
|
|
52
|
+
if not parts.scheme or "@" not in parts.netloc:
|
|
53
|
+
return url
|
|
54
|
+
host = parts.netloc.rsplit("@", 1)[-1]
|
|
55
|
+
return urlunsplit((parts.scheme, host, parts.path, parts.query, parts.fragment))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def record_remote_account(remote_url: str, account_name: str) -> None:
|
|
59
|
+
remote_url = _strip_url_credentials(remote_url)
|
|
60
|
+
|
|
61
|
+
# Ensure the home directory exists, as git config needs it to write the global config.
|
|
62
|
+
home = Path.home()
|
|
63
|
+
try:
|
|
64
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
except OSError as e:
|
|
66
|
+
raise GitOpsError(f"could not create home directory {home}: {e}") from e
|
|
67
|
+
|
|
68
|
+
key = f"gw-remote-map.{remote_url}.account"
|
|
69
|
+
result = subprocess.run(
|
|
70
|
+
["git", "config", "--global", key, account_name],
|
|
71
|
+
capture_output=True,
|
|
72
|
+
text=True,
|
|
73
|
+
)
|
|
74
|
+
if result.returncode != 0:
|
|
75
|
+
raise GitOpsError(f"git config --global {key} failed: {result.stderr.strip()}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def lookup_remote_account(remote_url: str) -> str | None:
|
|
79
|
+
remote_url = _strip_url_credentials(remote_url)
|
|
80
|
+
|
|
81
|
+
key = f"gw-remote-map.{remote_url}.account"
|
|
82
|
+
result = subprocess.run(
|
|
83
|
+
["git", "config", "--global", "--get", key],
|
|
84
|
+
capture_output=True,
|
|
85
|
+
text=True,
|
|
86
|
+
)
|
|
87
|
+
if result.returncode != 0:
|
|
88
|
+
return None
|
|
89
|
+
return result.stdout.strip()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def set_local_credential_helper(gh_host: str, cwd: Path | None = None) -> None:
|
|
93
|
+
key = f"credential.https://{gh_host}.helper"
|
|
94
|
+
|
|
95
|
+
# Tolerate "no such key" (exit 5) — first-ever call in a repo has nothing to clear.
|
|
96
|
+
subprocess.run(
|
|
97
|
+
["git", "config", "--local", "--unset-all", key],
|
|
98
|
+
cwd=cwd,
|
|
99
|
+
capture_output=True,
|
|
100
|
+
text=True,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
for value in ("", "!gw credential-helper"):
|
|
104
|
+
result = subprocess.run(
|
|
105
|
+
["git", "config", "--local", "--add", key, value],
|
|
106
|
+
cwd=cwd,
|
|
107
|
+
capture_output=True,
|
|
108
|
+
text=True,
|
|
109
|
+
)
|
|
110
|
+
if result.returncode != 0:
|
|
111
|
+
raise GitOpsError(f"git config --local --add {key} failed: {result.stderr.strip()}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def ssh_command_for_key(ssh_key_path: str) -> str:
|
|
115
|
+
expanded = Path(ssh_key_path).expanduser()
|
|
116
|
+
return f"ssh -i {shlex.quote(str(expanded))} -o IdentitiesOnly=yes"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def is_linked_worktree(cwd: Path | None = None) -> bool:
|
|
120
|
+
git_dir = subprocess.run(
|
|
121
|
+
["git", "rev-parse", "--git-dir"],
|
|
122
|
+
cwd=cwd,
|
|
123
|
+
capture_output=True,
|
|
124
|
+
text=True,
|
|
125
|
+
)
|
|
126
|
+
common_dir = subprocess.run(
|
|
127
|
+
["git", "rev-parse", "--git-common-dir"],
|
|
128
|
+
cwd=cwd,
|
|
129
|
+
capture_output=True,
|
|
130
|
+
text=True,
|
|
131
|
+
)
|
|
132
|
+
if git_dir.returncode != 0 or common_dir.returncode != 0:
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
base = cwd if cwd is not None else Path.cwd()
|
|
136
|
+
resolved_git_dir = (base / git_dir.stdout.strip()).resolve()
|
|
137
|
+
resolved_common_dir = (base / common_dir.stdout.strip()).resolve()
|
|
138
|
+
return resolved_git_dir != resolved_common_dir
|
gw/prompt_install.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
SCRIPT_CONTENT = (
|
|
8
|
+
"#!/bin/sh\n"
|
|
9
|
+
"acct=$(git config --local --get gw.account 2>/dev/null)\n"
|
|
10
|
+
"[ -n \"$acct\" ] && printf '[%s] ' \"$acct\"\n"
|
|
11
|
+
"exit 0\n"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
MARKER_START = "# >>> gw prompt"
|
|
15
|
+
MARKER_END = "# <<< gw prompt <<<"
|
|
16
|
+
|
|
17
|
+
SUPPORTED_SHELLS = {"zsh", "bash"}
|
|
18
|
+
|
|
19
|
+
_RC_FILENAMES = {
|
|
20
|
+
"zsh": ".zshrc",
|
|
21
|
+
"bash": ".bashrc",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PromptInstallError(Exception):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class InstallResult:
|
|
31
|
+
script_path: Path
|
|
32
|
+
rc_path: Path
|
|
33
|
+
newly_installed: bool
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def script_path() -> Path:
|
|
37
|
+
return Path.home() / ".local" / "bin" / "gw-prompt"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def rc_file_path(shell: str) -> Path:
|
|
41
|
+
return Path.home() / _RC_FILENAMES[shell]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def rc_block(shell: str, script_path: Path) -> str:
|
|
45
|
+
var_line = f'export GW_PROMPT="{script_path}"\n'
|
|
46
|
+
if shell == "zsh":
|
|
47
|
+
body = (
|
|
48
|
+
f"{var_line}"
|
|
49
|
+
"setopt PROMPT_SUBST\n"
|
|
50
|
+
"PROMPT='$($GW_PROMPT)'\"$PROMPT\"\n"
|
|
51
|
+
)
|
|
52
|
+
elif shell == "bash":
|
|
53
|
+
body = (
|
|
54
|
+
f"{var_line}"
|
|
55
|
+
"PS1='$($GW_PROMPT)'\"$PS1\"\n"
|
|
56
|
+
)
|
|
57
|
+
else:
|
|
58
|
+
raise PromptInstallError(f"unsupported shell: {shell!r}")
|
|
59
|
+
return (
|
|
60
|
+
f"{MARKER_START} (managed by `gw init {shell} --install`) >>>\n"
|
|
61
|
+
f"{body}"
|
|
62
|
+
f"{MARKER_END}\n"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def is_already_installed(rc_path: Path) -> bool:
|
|
67
|
+
if not rc_path.exists():
|
|
68
|
+
return False
|
|
69
|
+
return MARKER_START in rc_path.read_text()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def install(shell: str) -> InstallResult:
|
|
73
|
+
if shell not in SUPPORTED_SHELLS:
|
|
74
|
+
raise PromptInstallError(f"unsupported shell: {shell!r}")
|
|
75
|
+
|
|
76
|
+
path = script_path()
|
|
77
|
+
try:
|
|
78
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
path.write_text(SCRIPT_CONTENT)
|
|
80
|
+
os.chmod(path, 0o755)
|
|
81
|
+
except OSError as e:
|
|
82
|
+
raise PromptInstallError(f"failed to write {path}: {e}") from e
|
|
83
|
+
|
|
84
|
+
rc_path = rc_file_path(shell)
|
|
85
|
+
try:
|
|
86
|
+
newly_installed = not is_already_installed(rc_path)
|
|
87
|
+
if newly_installed:
|
|
88
|
+
rc_path.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
prefix = "\n" if rc_path.exists() and rc_path.stat().st_size > 0 else ""
|
|
90
|
+
with rc_path.open("a") as f:
|
|
91
|
+
f.write(prefix + rc_block(shell, path))
|
|
92
|
+
except OSError as e:
|
|
93
|
+
raise PromptInstallError(f"failed to update {rc_path}: {e}") from e
|
|
94
|
+
|
|
95
|
+
return InstallResult(script_path=path, rc_path=rc_path, newly_installed=newly_installed)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from gw import prompt_install
|
|
7
|
+
|
|
8
|
+
CHECK_SCRIPT_CONTENT = (
|
|
9
|
+
"#!/bin/sh\n"
|
|
10
|
+
"url=$(git config --local --get remote.origin.url 2>/dev/null)\n"
|
|
11
|
+
"[ -z \"$url\" ] && exit 0\n"
|
|
12
|
+
"expected=$(git config --global --get \"gw-remote-map.$url.account\" 2>/dev/null)\n"
|
|
13
|
+
"[ -z \"$expected\" ] && exit 0\n"
|
|
14
|
+
"active=$(git config --local --get gw.account 2>/dev/null)\n"
|
|
15
|
+
"if [ \"$active\" != \"$expected\" ]; then\n"
|
|
16
|
+
" printf 'gw: this repo usually uses account \"%s\" (currently: \"%s\"). Run `gw use %s` to switch.\\n' \"$expected\" \"${active:-none}\" \"$expected\" >&2\n"
|
|
17
|
+
"fi\n"
|
|
18
|
+
"exit 0\n"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
CHECK_MARKER_START = "# >>> gw remote check"
|
|
22
|
+
CHECK_MARKER_END = "# <<< gw remote check <<<"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def check_script_path() -> Path:
|
|
26
|
+
return Path.home() / ".local" / "bin" / "gw-check"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def chpwd_block(shell: str, check_script_path: Path) -> str:
|
|
30
|
+
var_line = f'export GW_CHECK="{check_script_path}"\n'
|
|
31
|
+
if shell == "zsh":
|
|
32
|
+
body = (
|
|
33
|
+
f"{var_line}"
|
|
34
|
+
'gw_chpwd_check() { "$GW_CHECK"; }\n'
|
|
35
|
+
"chpwd_functions+=(gw_chpwd_check)\n"
|
|
36
|
+
)
|
|
37
|
+
elif shell == "bash":
|
|
38
|
+
body = (
|
|
39
|
+
f"{var_line}"
|
|
40
|
+
'gw_last_pwd_for_check="$PWD"\n'
|
|
41
|
+
"gw_chpwd_check() {\n"
|
|
42
|
+
' if [ "$PWD" != "$gw_last_pwd_for_check" ]; then\n'
|
|
43
|
+
' gw_last_pwd_for_check="$PWD"\n'
|
|
44
|
+
' "$GW_CHECK"\n'
|
|
45
|
+
" fi\n"
|
|
46
|
+
"}\n"
|
|
47
|
+
'PROMPT_COMMAND="gw_chpwd_check${PROMPT_COMMAND:+; $PROMPT_COMMAND}"\n'
|
|
48
|
+
)
|
|
49
|
+
else:
|
|
50
|
+
raise prompt_install.PromptInstallError(f"unsupported shell: {shell!r}")
|
|
51
|
+
return (
|
|
52
|
+
f"{CHECK_MARKER_START} (managed by `gw init {shell} --install`) >>>\n"
|
|
53
|
+
f"{body}"
|
|
54
|
+
f"{CHECK_MARKER_END}\n"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_check_already_installed(rc_path: Path) -> bool:
|
|
59
|
+
if not rc_path.exists():
|
|
60
|
+
return False
|
|
61
|
+
return CHECK_MARKER_START in rc_path.read_text()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def install_check(shell: str) -> prompt_install.InstallResult:
|
|
65
|
+
if shell not in prompt_install.SUPPORTED_SHELLS:
|
|
66
|
+
raise prompt_install.PromptInstallError(f"unsupported shell: {shell!r}")
|
|
67
|
+
|
|
68
|
+
path = check_script_path()
|
|
69
|
+
try:
|
|
70
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
path.write_text(CHECK_SCRIPT_CONTENT)
|
|
72
|
+
os.chmod(path, 0o755)
|
|
73
|
+
except OSError as e:
|
|
74
|
+
raise prompt_install.PromptInstallError(f"failed to write {path}: {e}") from e
|
|
75
|
+
|
|
76
|
+
rc_path = prompt_install.rc_file_path(shell)
|
|
77
|
+
try:
|
|
78
|
+
newly_installed = not is_check_already_installed(rc_path)
|
|
79
|
+
if newly_installed:
|
|
80
|
+
rc_path.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
prefix = "\n" if rc_path.exists() and rc_path.stat().st_size > 0 else ""
|
|
82
|
+
with rc_path.open("a") as f:
|
|
83
|
+
f.write(prefix + chpwd_block(shell, path))
|
|
84
|
+
except OSError as e:
|
|
85
|
+
raise prompt_install.PromptInstallError(f"failed to update {rc_path}: {e}") from e
|
|
86
|
+
|
|
87
|
+
return prompt_install.InstallResult(script_path=path, rc_path=rc_path, newly_installed=newly_installed)
|
gw/ssh_ops.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class SshOpsError(Exception):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def key_exists(path: str) -> bool:
|
|
12
|
+
return Path(path).expanduser().exists()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def public_key_path(private_key_path: str) -> str:
|
|
16
|
+
return f"{private_key_path}.pub"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def read_public_key(path: str) -> str:
|
|
20
|
+
return Path(path).expanduser().read_text().strip()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def generate_key(path: str, email: str) -> None:
|
|
24
|
+
expanded = Path(path).expanduser()
|
|
25
|
+
expanded.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
26
|
+
result = subprocess.run(
|
|
27
|
+
[
|
|
28
|
+
"ssh-keygen",
|
|
29
|
+
"-t", "ed25519",
|
|
30
|
+
"-C", email,
|
|
31
|
+
"-f", str(expanded),
|
|
32
|
+
"-N", "",
|
|
33
|
+
],
|
|
34
|
+
capture_output=True,
|
|
35
|
+
text=True,
|
|
36
|
+
)
|
|
37
|
+
if result.returncode != 0:
|
|
38
|
+
raise SshOpsError(f"ssh-keygen failed: {result.stderr.strip()}")
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: gwitch
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Switch between git/GitHub accounts per repository.
|
|
5
|
+
Project-URL: Homepage, https://github.com/isamrish/gwitch
|
|
6
|
+
Project-URL: Repository, https://github.com/isamrish/gwitch
|
|
7
|
+
Project-URL: Issues, https://github.com/isamrish/gwitch/issues
|
|
8
|
+
Author-email: Amrish Kushwaha <askmaurya48@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
17
|
+
Classifier: Topic :: Utilities
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Requires-Dist: toml>=0.10
|
|
20
|
+
Requires-Dist: typer>=0.9
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# gw
|
|
26
|
+
|
|
27
|
+
[](https://github.com/isamrish/gwitch/actions/workflows/ci.yml)
|
|
28
|
+
[](LICENSE)
|
|
29
|
+
|
|
30
|
+
Switch between multiple git/GitHub accounts, per repository, and see which
|
|
31
|
+
account is active right in your shell prompt.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
pipx install gwitch
|
|
36
|
+
|
|
37
|
+
Published on PyPI as `gwitch`; installs the `gw` command. Or, from a
|
|
38
|
+
checkout of this repository:
|
|
39
|
+
|
|
40
|
+
pipx install .
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
gw account add personal
|
|
45
|
+
gw account add work
|
|
46
|
+
|
|
47
|
+
cd ~/code/some-repo
|
|
48
|
+
gw use personal
|
|
49
|
+
gw status
|
|
50
|
+
|
|
51
|
+
## Shell prompt
|
|
52
|
+
|
|
53
|
+
gw init zsh # or bash / fish
|
|
54
|
+
|
|
55
|
+
prints a small shell function, plus a one-line instruction for wiring it
|
|
56
|
+
into your prompt — paste both into your shell config yourself. `gw` never
|
|
57
|
+
edits your shell rc files automatically.
|
|
58
|
+
|
|
59
|
+
gw init zsh --install # or bash
|
|
60
|
+
|
|
61
|
+
`--install` does the wiring for you, for `zsh`/`bash` only (not `fish`
|
|
62
|
+
yet): it writes a `gw-prompt` script to `~/.local/bin/gw-prompt` and a
|
|
63
|
+
`gw-check` script to `~/.local/bin/gw-check` (the cd-triggered account-check
|
|
64
|
+
described below), and appends two marked blocks to your rc file — one per
|
|
65
|
+
script. It's idempotent — safe to re-run. Note for macOS bash users:
|
|
66
|
+
`~/.bashrc` isn't read by login shells (Terminal and iTerm start bash as a
|
|
67
|
+
login shell), so unless you've already wired `~/.bashrc` to be sourced from
|
|
68
|
+
`~/.bash_profile`, you'll need to add that yourself for the installed
|
|
69
|
+
integration to take effect.
|
|
70
|
+
|
|
71
|
+
## What `gw use <account>` changes
|
|
72
|
+
|
|
73
|
+
- `user.name` / `user.email` — local to the current repo only (`git config --local`)
|
|
74
|
+
- `core.sshCommand` — forces the SSH key registered for that account
|
|
75
|
+
- `gw.account` — a marker read by `gw status` and the shell prompt function
|
|
76
|
+
- `gw-remote-map.<remote URL>.account` — records this repo's remote in **global** git config, for the cd-triggered mismatch check below
|
|
77
|
+
|
|
78
|
+
`gw use` also records this repo's remote-to-account mapping in **global**
|
|
79
|
+
git config (for the cd-triggered mismatch check, see below) — everything
|
|
80
|
+
else stays local-only. None of this touches `~/.ssh/config` or ssh-agent.
|
|
81
|
+
|
|
82
|
+
## Remote-account mismatch check
|
|
83
|
+
|
|
84
|
+
With `gw init <shell> --install`, a `gw-check` script is wired to run every
|
|
85
|
+
time your shell `cd`s into a different directory. If the repo you land in
|
|
86
|
+
has an `origin` remote that `gw use <account>` has previously recorded, and
|
|
87
|
+
the account currently active in that repo doesn't match, it prints a
|
|
88
|
+
warning to stderr telling you which account to switch to. It never blocks
|
|
89
|
+
or changes anything — it's purely informational.
|
|
90
|
+
|
|
91
|
+
## Known limitations
|
|
92
|
+
|
|
93
|
+
**`gh` (GitHub CLI) authentication is global, not per-repo.** GitHub CLI's
|
|
94
|
+
authenticated account is global to your machine (`gh` keeps one active user
|
|
95
|
+
per host), not per-repo — that's inherent to how `gh` itself works. `gw use
|
|
96
|
+
<account>` will call `gh auth switch` for you, which changes `gh`'s active
|
|
97
|
+
account machine-wide, even for other repos/terminal tabs, until you `gw use`
|
|
98
|
+
a different account somewhere else. Your git identity and SSH key stay
|
|
99
|
+
correctly isolated per-repo regardless.
|
|
100
|
+
|
|
101
|
+
`gw status` shows both this repo's git identity and gh's currently active
|
|
102
|
+
account as separate lines, so if they've drifted apart it's visible rather
|
|
103
|
+
than silent.
|
|
104
|
+
|
|
105
|
+
**Linked git worktrees share git identity.** `git config --local` in a
|
|
106
|
+
linked worktree (created via `git worktree add`) resolves to the *same*
|
|
107
|
+
shared config file as the main checkout — git only isolates local config
|
|
108
|
+
per-worktree if the repo opts into `extensions.worktreeConfig`, which `gw`
|
|
109
|
+
does not assume. That means `gw use <account>` run inside a linked worktree
|
|
110
|
+
changes the identity for the main checkout and every sibling worktree of
|
|
111
|
+
that repo too, not just the worktree you ran it in. `gw use` detects this
|
|
112
|
+
and prints a warning when you run it inside a linked worktree, but it does
|
|
113
|
+
not block the switch or set up per-worktree config automatically.
|
|
114
|
+
|
|
115
|
+
**The remote-account check is last-write-wins, URL-exact, and cd-triggered
|
|
116
|
+
only.** The `gw-remote-map` registry has no per-clone tracking: running `gw
|
|
117
|
+
use <account>` anywhere for a given remote overwrites what "the right
|
|
118
|
+
account" means for every other clone of that remote — this is intentional,
|
|
119
|
+
not a bug. It also matches on the exact remote URL string, so an SSH clone
|
|
120
|
+
and an HTTPS clone of the same repo are treated as two unrelated remotes.
|
|
121
|
+
And because the check only runs on `cd`, a shell already sitting in a
|
|
122
|
+
mismatched repo when it starts won't warn until you `cd` away and back.
|
|
123
|
+
|
|
124
|
+
## Commands
|
|
125
|
+
|
|
126
|
+
- `gw account add <name>` — register an account (prompts for git name/email,
|
|
127
|
+
GitHub username; can generate a new SSH key for you)
|
|
128
|
+
- `gw account list` — list registered accounts
|
|
129
|
+
- `gw account remove <name>` — remove a registered account
|
|
130
|
+
- `gw use <name>` — activate an account for the current repo
|
|
131
|
+
- `gw status` / `gw whoami` — show the active account for the current repo
|
|
132
|
+
- `gw init <shell>` — print the shell prompt snippet for `zsh`, `bash`, or
|
|
133
|
+
`fish`; add `--install` (`zsh`/`bash` only) to write and wire it up automatically
|
|
134
|
+
|
|
135
|
+
## Contributing
|
|
136
|
+
|
|
137
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for dev setup and how to run the
|
|
138
|
+
tests. Please follow the [Code of Conduct](CODE_OF_CONDUCT.md). For security
|
|
139
|
+
issues, see [SECURITY.md](SECURITY.md) instead of opening a public issue.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
gw/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
gw/cli.py,sha256=LC79V39Y9D8yyaMbcSOf97qNJzvsL-YLJXkxR4qYq5U,815
|
|
3
|
+
gw/config.py,sha256=G-qvtgpWdJFvnykarB-r5aIW8AHEAnNPHfOHvQCDAaE,2006
|
|
4
|
+
gw/gh_ops.py,sha256=IXq7mXLqN7flz5fR75B4KilP1xL_PUgp8BGxwzfi6Fg,1232
|
|
5
|
+
gw/git_ops.py,sha256=3tSc5LBBem1ECT_SdTlIH6iS4uPZG-0eGNXzbvjLYUc,4216
|
|
6
|
+
gw/prompt_install.py,sha256=VsdVeU2oOAR7KKTxrshapyb6YX6tSbvVtEN34YtOd5I,2494
|
|
7
|
+
gw/remote_check_install.py,sha256=IL60uM3RNFeqQvOJA8P1oI8SC5W4b9OaBRQdmllIufU,3102
|
|
8
|
+
gw/ssh_ops.py,sha256=eHm5IlLwbvZmorNDYkOl6yWN9rDNZGy98qR8NVv8MCM,887
|
|
9
|
+
gw/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
gw/commands/account.py,sha256=VOj9sD1G5MpStdNJYrlgk8EDVHx2CqGxQCiVEmW01J8,3994
|
|
11
|
+
gw/commands/credential_helper.py,sha256=yf5glZ2aTTEKjUYH094qGxq9baGUOI_NrlbhC2sOSLM,1155
|
|
12
|
+
gw/commands/init_cmd.py,sha256=wwtDoog6SixcjkGG0Yx5bpyewXkPls2JZnwJwHlZ2qE,3584
|
|
13
|
+
gw/commands/status.py,sha256=QKh5aD1AJDu51twZwPitw7uoLvRu6JN3xxgkbNpe_L8,2010
|
|
14
|
+
gw/commands/use.py,sha256=uezNUwypc3lmQK4107Rr5lLS9j0vThxYJVCiFh7gb70,3395
|
|
15
|
+
gwitch-0.1.0.dist-info/METADATA,sha256=_QzmNxr68hUJyrVYrlgvGS6TYSu5EC2rIqSeK-zsTWI,6256
|
|
16
|
+
gwitch-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
17
|
+
gwitch-0.1.0.dist-info/entry_points.txt,sha256=YqwMeBups4XCt88wIKG4qxYGsOEOFI-_YNiEoNDJaow,35
|
|
18
|
+
gwitch-0.1.0.dist-info/licenses/LICENSE,sha256=4UBvHnhpvGU9C50HJ-SNBOxNK_JiVhaM_5oXofA_Y-Y,1072
|
|
19
|
+
gwitch-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Amrish Kushwaha
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|