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 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()
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
@@ -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
+ [![CI](https://github.com/isamrish/gwitch/actions/workflows/ci.yml/badge.svg)](https://github.com/isamrish/gwitch/actions/workflows/ci.yml)
28
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gw = gw.cli:main
@@ -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.